Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions src/source/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,78 @@
import { Response } from '@adobe/fetch';
import { HelixStorage } from '@adobe/helix-shared-storage';
import { createErrorResponse } from '../contentbus/utils.js';
import { deleteFolder } from './folder.js';
import { getS3KeyFromInfo } from './utils.js';
import { RequestInfo } from '../support/RequestInfo.js';
import { StatusCodeError } from '../support/StatusCodeError.js';
import { copyDocument, copyFolder } from './put.js';
import { getDocPathFromS3Key, getS3Key, getS3KeyFromInfo } from './utils.js';

/**
* Delete from the source bus.
* Trash a folder by moving all of its contents to the trash in the same folder structure.
* If the trash already contains a folder with this name, a base-36 encoded timestamp is appended.
*
* @param {import('../support/AdminContext').AdminContext} context context
* @param {import('../support/RequestInfo').RequestInfo} info request info
* @return {Promise<Response>} response
* @returns {Promise<Response>} response, status 204 if successful.
*/
async function trashFolder(context, info) {
const bucket = HelixStorage.fromContext(context).sourceBus();

const segments = info.rawPath.split('/');
const destDir = `/.trash/${segments[segments.length - 2]}`;
Comment thread
bosschaert marked this conversation as resolved.
Outdated

// Ensure that there is no folder in the trash with this name yet
const listResp = await bucket.list(`${getS3Key(info.org, info.site, destDir)}/`, { shallow: true });
const destPath = listResp.length > 0 ? `${destDir}-${Date.now().toString(36)}/` : `${destDir}/`;

const srcKey = getS3Key(info.org, info.site, info.rawPath);
const newInfo = RequestInfo.clone(info, { path: destPath });
const copyOpts = (sKey) => ({ addMetadata: { 'doc-path': getDocPathFromS3Key(sKey) } });

try {
const resp = await copyFolder(context, srcKey, newInfo, true, copyOpts, { collision: 'unique' });
if (resp.length > 0) {
return new Response('', { status: 204 });
}
throw new StatusCodeError('Trashing of folder failed', 500);
} catch (e) {
const opts = { e, log: context.log };
opts.status = e.$metadata?.httpStatusCode;
return createErrorResponse(opts);
}
}

/**
* Delete from the source bus, which means moving it to the trash. Both
* documents and folders are supported. The trashed documents gets an extra
* metadata field 'doc-path' which is the path where it was deleted from.
*
* @param {import('../support/AdminContext').AdminContext} context context
* @param {import('../support/RequestInfo').RequestInfo} info request info
* @return {Promise<Response>} response, status 204 if successful.
*/
export async function deleteSource(context, info) {
if (info.rawPath.endsWith('/')) {
return deleteFolder(context, info);
return trashFolder(context, info);
}
const { log } = context;

const bucket = HelixStorage.fromContext(context).sourceBus();
const key = getS3KeyFromInfo(info);
// Trash a document.
const docName = info.rawPath.split('/').pop();
const srcKey = getS3KeyFromInfo(info);
const newInfo = RequestInfo.clone(info, { path: `/.trash/${docName}` });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this desired? why don't you include the path somehow? otherwise, deleting common files, like index.html will end up overwriting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to how a Trash works in an OS environment, e.g. on Mac OS individually deleted files end up in the root of the trash, if there is already a file with that name, they get a unique suffix:

Image

We do a similar thing. So files are not overwritten, but if one with the name already exists, it will get a unique suffix (alpha sortable to get the order in which they are deleted):

Image

const copyOpts = {
addMetadata: {
'doc-path': info.resourcePath,
},
};

try {
const resp = await bucket.remove(key);
return new Response('', { status: resp.$metadata?.httpStatusCode });
const resp = await copyDocument(context, srcKey, newInfo, true, copyOpts, { collision: 'unique' });
if (resp.length !== 1) {
throw new StatusCodeError('Trashing of document failed', 500);
}
return new Response('', { status: 204 });
} catch (e) {
const opts = { e, log };
const opts = { e, log: context.log };
opts.status = e.$metadata?.httpStatusCode;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dominique-pfister since delete now doesn't do bucket.remove() any more but rather a copy, we need to go back to checking e.$metadata?.httpStatusCode am I correct?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so: AFAICS, Bucket operations catch all errors thrown and rethrow an exception where the information in $metadata is available in the exception, e.g.:
https://github.com/adobe/helix-shared/blob/726a38139542da81fc0a1b26a414339b8ba6562b/packages/helix-shared-storage/src/storage.js#L430-L438

return createErrorResponse(opts);
}
Expand Down
77 changes: 51 additions & 26 deletions src/source/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { StatusCodeError } from '../support/StatusCodeError.js';
import { checkConditionals } from './header-utils.js';
import {
contentTypeFromExtension,
getDocPathFromS3Key,
getS3KeyFromInfo,
getS3Key,
getDocID,
Expand All @@ -35,7 +36,13 @@ import { postVersion } from './versions.js';
* @param {string} destKey destination S3 key
* @param {boolean} move true if this is a move operation
* @param {object} initialOpts metadata options for the copy operation
* @param {object} collOpts collision options (e.g { copy: 'overwrite' } )
* @param {object} collOpts collision options (e.g { collision: 'overwrite' } ),
* these collision options are used to handle conflicts when copying the source to the
* destination.
* - 'overwrite' - overwrite the destination if it exists, but create a version of
* the destination first.
* - 'unique' - append a (base-36) encoded timestamp to the destination key to make it unique.
* These timestamps are alphabetically sortable.
*/
async function copyWithRetry(
context,
Expand All @@ -46,6 +53,7 @@ async function copyWithRetry(
collOpts,
) {
const bucket = HelixStorage.fromContext(context).sourceBus();
let destinationKey = destKey;
let opts = initialOpts;

// We start with assuming that there is nothing at the destination, the happy path
Expand All @@ -57,7 +65,7 @@ async function copyWithRetry(
try {
const allOpts = { copyOpts, ...opts };
// eslint-disable-next-line no-await-in-loop
await bucket.copy(srcKey, destKey, allOpts);
await bucket.copy(srcKey, destinationKey, allOpts);

break; // copy was successful, break out of the loop - we're done!
} catch (e) {
Expand All @@ -73,14 +81,16 @@ async function copyWithRetry(
if (status !== 412) throw e;
// 412: precondition failed - something is at the destination already.

if (move) {
// TODO add move collision handling
throw new StatusCodeError('Collision: something is at the destination already', 409);
} else {
if (collOpts.copy !== 'overwrite') {
throw new StatusCodeError('Collision: something is at the destination already, no overwrite option provided', 409);
}
if (collOpts.collision === 'unique') {
// The request is to move and make the destination file unique.
// We do this by appending a ms timestamp to the destination key.

const ext = `.${destKey.split('.').pop()}`;
const destWithoutExt = destKey.slice(0, -ext.length);

const ts = Date.now().toString(36);
destinationKey = `${destWithoutExt}-${ts}${ext}`;
} else if (collOpts.collision === 'overwrite') {
// eslint-disable-next-line no-await-in-loop
const dest = await bucket.head(destKey);

Expand All @@ -104,6 +114,8 @@ async function copyWithRetry(
// Now only copy over the destination if it's still the same as what we did a head() of
copyOpts = { IfMatch: dest.ETag };
}
} else {
throw new StatusCodeError('Collision: something is at the destination already', 409);
}
}
}
Expand All @@ -117,12 +129,12 @@ async function copyWithRetry(
}
}

async function copyFile(context, srcKey, destKey, move, collOpts) {
const opts = {};
async function copyFile(context, srcKey, destKey, move, opts, collOpts) {
const copyOpts = { ...opts };
if (!move) {
opts.addMetadata = { 'doc-id': ulid() };
copyOpts.addMetadata = { 'doc-id': ulid() };
}
await copyWithRetry(context, srcKey, destKey, move, opts, collOpts);
await copyWithRetry(context, srcKey, destKey, move, copyOpts, collOpts);
}

/**
Expand All @@ -132,12 +144,13 @@ async function copyFile(context, srcKey, destKey, move, collOpts) {
* @param {string} src source S3 key
* @param {import('../support/RequestInfo').RequestInfo} info destination info
* @param {boolean} move whether to move the source
* @param {object} opts additional options for the copy operation
* @param {object} collOpts collision options
* @returns {Promise<Array<{src: string, dst: string}>>} the copied file details
*/
async function copyDocument(context, src, info, move, collOpts) {
export async function copyDocument(context, src, info, move, opts, collOpts) {
const dst = getS3KeyFromInfo(info);
await copyFile(context, src, dst, move, collOpts);
await copyFile(context, src, dst, move, opts, collOpts);
return [{ src, dst }];
}

Expand All @@ -148,10 +161,13 @@ async function copyDocument(context, src, info, move, collOpts) {
* @param {string} srcKey source S3 key
* @param {import('../support/RequestInfo').RequestInfo} info destination info
* @param {boolean} move whether to move the source
* @param {function(string, string): object} fnOpts additional options for the copy operation.
* This function is called for each object being copied with source and destination S3 keys as
* arguments.
* @param {object} collOpts collision options
* @returns {Promise<Array<{src: string, dst: string}>>} the copied files
*/
async function copyFolder(context, srcKey, info, move, collOpts) {
export async function copyFolder(context, srcKey, info, move, fnOpts, collOpts) {
const tasks = [];
const destKey = getS3Key(info.org, info.site, info.rawPath);

Expand All @@ -167,9 +183,15 @@ async function copyFolder(context, srcKey, info, move, collOpts) {
});
});

if (tasks.length === 0) {
// Nothing found at source
throw new StatusCodeError('Not found', 404);
}

const copied = [];
await processQueue(tasks, async (task) => {
await copyFile(context, task.src, task.dst, move, collOpts);
const opts = fnOpts(task.src, task.dst);
await copyFile(context, task.src, task.dst, move, opts, collOpts);
copied.push({ src: task.src, dst: task.dst });
});
return copied;
Expand Down Expand Up @@ -197,12 +219,18 @@ async function copySource(context, info, move, collOpts) {
}

const copied = isFolder
? await copyFolder(context, srcKey, info, move, collOpts)
: await copyDocument(context, srcKey, info, move, collOpts);
? await copyFolder(context, srcKey, info, move, () => ({}), collOpts)
: await copyDocument(context, srcKey, info, move, {}, collOpts);

// The copied paths returned are without the org and site segments
const copiedPaths = copied.map((c) => ({
src: getDocPathFromS3Key(c.src),
dst: getDocPathFromS3Key(c.dst),
}));

const operation = move ? 'moved' : 'copied';
return new Response({
[operation]: copied,
[operation]: copiedPaths,
});
} catch (e) {
const opts = { e, log };
Expand All @@ -221,12 +249,9 @@ async function copySource(context, info, move, collOpts) {
export async function putSource(context, info) {
if (context.data.source) {
const move = String(context.data.move) === 'true';
const collOpts = {};
if (move) {
collOpts.move = context.data.collision;
} else {
collOpts.copy = context.data.collision;
}
const collOpts = {
collision: context.data.collision,
};
return copySource(context, info, move, collOpts);
}

Expand Down
11 changes: 11 additions & 0 deletions src/source/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ export function getS3KeyFromInfo(info) {
return getS3Key(org, site, resourcePath);
}

/**
* Get the document path from the source bus S3 key.
*
* @param {string} sKey source bus S3 key
* @returns {string} the document path
*/
export function getDocPathFromS3Key(sKey) {
const path = sKey.split('/').slice(2).join('/');
return `/${path}`;
}

/**
* Get the document ID from the head, by reading it from the Metadata.
*
Expand Down
Loading
Loading