diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1097ea0..0655bae 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,5 +15,6 @@ module.exports = { extends: '@adobe/helix', rules: { 'no-await-in-loop': 0, + 'max-len': ["error", { "code": 200 }], }, }; diff --git a/src/routes/source.js b/src/routes/source.js index 0aefccc..a2ad04d 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -14,7 +14,6 @@ import putObject from '../storage/object/put.js'; import deleteObjects from '../storage/object/delete.js'; import putHelper from '../helpers/source.js'; -import { postObjectVersion } from '../storage/version/put.js'; async function invalidateCollab(api, url, env) { const invPath = `/api/v1/${api}?doc=${url}`; @@ -25,7 +24,6 @@ async function invalidateCollab(api, url, env) { } export async function deleteSource({ req, env, daCtx }) { - await postObjectVersion(req, env, daCtx); const resp = await deleteObjects(env, daCtx); if (resp.status === 204) { diff --git a/src/storage/object/delete.js b/src/storage/object/delete.js index 706ede9..e1b1040 100644 --- a/src/storage/object/delete.js +++ b/src/storage/object/delete.js @@ -9,68 +9,21 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { - S3Client, - DeleteObjectCommand, - ListObjectsV2Command, -} from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; - -import getS3Config from '../utils/config.js'; - -function buildInput(org, key) { - return { - Bucket: `${org}-content`, - Prefix: `${key}/`, - }; -} - -export async function deleteObject(client, org, Key) { - try { - const delCommand = new DeleteObjectCommand({ Bucket: `${org}-content`, Key }); - const url = await getSignedUrl(client, delCommand, { expiresIn: 3600 }); - return fetch(url, { method: 'DELETE' }); - } catch (e) { - // eslint-disable-next-line no-console - console.log(`There was an error deleting ${Key}.`); - return e; - } -} export default async function deleteObjects(env, daCtx) { - const config = getS3Config(env); - const client = new S3Client(config); - const input = buildInput(daCtx.org, daCtx.key); - - let ContinuationToken; - + const fullKey = `${daCtx.org}/${daCtx.key}`; + const prefix = `${fullKey}/`; // The input prefix has a forward slash to prevent (drafts + drafts-new, etc.). // Which means the list will only pickup children. This adds to the initial list. - const sourceKeys = [daCtx.key, `${daCtx.key}.props`]; + const sourceKeys = [fullKey, `${fullKey}.props`]; + let truncated = false; do { - try { - const command = new ListObjectsV2Command({ ...input, ContinuationToken }); - const resp = await client.send(command); - - const { Contents = [], NextContinuationToken } = resp; - sourceKeys.push(...Contents.map(({ Key }) => Key)); - - await Promise.all( - new Array(1).fill(null).map(async () => { - while (sourceKeys.length) { - await deleteObject(client, daCtx.org, sourceKeys.pop()); - } - }), - ); - - ContinuationToken = NextContinuationToken; - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - return { body: '', status: 404 }; - } - } while (ContinuationToken); - + const r2objects = await env.DA_CONTENT.list({ prefix, limit: 500 }); + const { objects } = r2objects; + truncated = r2objects.truncated; + sourceKeys.push(...objects.map(({ key }) => key)); + await env.DA_CONTENT.delete(sourceKeys); + } while (truncated); return { body: null, status: 204 }; } diff --git a/src/storage/object/move.js b/src/storage/object/move.js index 39d14aa..2a6adf5 100644 --- a/src/storage/object/move.js +++ b/src/storage/object/move.js @@ -16,7 +16,7 @@ import { } from '@aws-sdk/client-s3'; import getS3Config from '../utils/config.js'; -import { deleteObject } from './delete.js'; +import deleteObjects from './delete.js'; function buildInput(org, key) { return { @@ -71,7 +71,7 @@ export default async function moveObject(env, daCtx, details) { const copied = await copyFile(client, daCtx.org, key, details); // Only delete the source if the file was successfully copied if (copied.$metadata.httpStatusCode === 200) { - const deleted = await deleteObject(client, daCtx.org, key); + const deleted = await deleteObjects(client, daCtx.org, key); result.status = deleted.status === 204 ? 204 : deleted.status; } else { result.status = copied.$metadata.httpStatusCode; diff --git a/src/storage/version/put.js b/src/storage/version/put.js index 93de2ec..3ce38c2 100644 --- a/src/storage/version/put.js +++ b/src/storage/version/put.js @@ -74,7 +74,7 @@ export async function putObjectWithVersion(env, daCtx, update, body) { const Version = current.metadata?.version || crypto.randomUUID(); const Users = JSON.stringify(daCtx.users); const input = buildInput(update); - const Timestamp = `${Date.now()}`; + const Timestamp = Date.now(); const Path = update.key; if (current.status === 404) { @@ -100,7 +100,7 @@ export async function putObjectWithVersion(env, daCtx, update, body) { // Store the body if preparsingstore is not defined, so a once-off store const storeBody = !body && pps === '0'; - const Preparsingstore = storeBody ? Timestamp : pps; + const Preparsingstore = storeBody ? `${Timestamp}` : pps; const Label = storeBody ? 'Collab Parse' : update.label; const versionResp = await putVersion(config, { diff --git a/test/it/delete.spec.js b/test/it/delete.spec.js new file mode 100644 index 0000000..437945f --- /dev/null +++ b/test/it/delete.spec.js @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert' +import { destroyMiniflare, getMiniflare } from '../mocks/miniflare.js'; +import worker from '../../src/index.js'; + +describe('DELETE HTTP Requests', () => { + let mf; + let env; + beforeEach(async () => { + mf = await getMiniflare(); + env = await mf.getBindings(); + }); + afterEach(async () => { + await destroyMiniflare(mf); + }); + + describe ('/source', async () => { + it('handles non-existing file', async () => { + const req = new Request('https://admin.da.live/source/wknd/does-not-exist', { method: 'DELETE' }); + const resp = await worker.fetch(req, env); + assert.strictEqual(resp.status, 204); + }); + + it('handles existing file', async () => { + const req = new Request('https://admin.da.live/source/wknd/index.html', { method: 'DELETE' }); + const resp = await worker.fetch(req, env); + assert.strictEqual(resp.status, 204); + const obj = await env.DA_CONTENT.get('wknd/index.html'); + assert.ifError(obj); + }); + }); +}) diff --git a/test/it/get.spec.js b/test/it/get.spec.js index 88b4def..6411e88 100644 --- a/test/it/get.spec.js +++ b/test/it/get.spec.js @@ -15,7 +15,7 @@ import { destroyMiniflare, getMiniflare } from '../mocks/miniflare.js'; import worker from '../../src/index.js'; import { SignJWT } from 'jose'; -describe('GET HTTP Requests', async () => { +describe('GET HTTP Requests', () => { let mf; let env; beforeEach(async () => { diff --git a/test/it/head.spec.js b/test/it/head.spec.js index 8dc13e5..ff5c320 100644 --- a/test/it/head.spec.js +++ b/test/it/head.spec.js @@ -13,8 +13,6 @@ import assert from 'node:assert'; import { destroyMiniflare, getMiniflare } from '../mocks/miniflare.js'; import worker from '../../src/index.js'; -import { SignJWT } from 'jose'; - describe('HEAD HTTP Requests', async () => { let mf; diff --git a/test/mocks/miniflare.js b/test/mocks/miniflare.js index 426f794..cad7d11 100644 --- a/test/mocks/miniflare.js +++ b/test/mocks/miniflare.js @@ -45,6 +45,13 @@ export async function getMiniflare() { } } `, + serviceBindings: { + dacollab() { + return { + fetch: () => { /* no-op fetch */ }, + }; + }, + }, kvNamespaces: { DA_AUTH: 'DA_AUTH', DA_CONFIG: 'DA_CONFIG' }, r2Buckets: { DA_CONTENT: 'DA_CONTENT' }, bindings: { DA_BUCKET_NAME: 'da-content' }, @@ -52,18 +59,13 @@ export async function getMiniflare() { const env = await mf.getBindings(); for (let name of orgs) { const auth = config[name]; + const content = `Hello ${name}!`; await env.DA_CONTENT.put( `${name}/index.html`, - `Hello ${name}!`, + content, { httpMetadata: { contentType: 'text/html' }, - customMetadata: { - id: '123', - version: '123', - users: `[{"email":"user@${name}.com"}]`, - timestamp: '1720723249932', - path: `${name}/index.html` - } + customMetadata: { id: '123', version: '123', users: `[{"email":"user@${name}.com"}]`, timestamp: '1720723249932', path: `${name}/index.html` } } ); if (auth) await env.DA_CONFIG.put(name, JSON.stringify(auth)); diff --git a/test/routes/source.test.js b/test/routes/source.test.js index d641a26..c154a2d 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -157,14 +157,6 @@ describe('Source Route', () => { const env = { dacollab }; const daCtx = {}; - const postObjVerCalled = []; - const postObjVerResp = async (r, e, c) => { - if (r === req && e === env && c === daCtx) { - postObjVerCalled.push('postObjectVersion'); - return {status: 201}; - } - }; - const deleteCalled = []; const deleteResp = async (e, c) => { if (e === env && c === daCtx) { @@ -178,16 +170,12 @@ describe('Source Route', () => { '../../src/storage/object/delete.js': { default: deleteResp }, - '../../src/storage/version/put.js': { - postObjectVersion: postObjVerResp - } } ); const resp = await deleteSource({req, env, daCtx}); assert.equal(204, resp.status); - assert.deepStrictEqual(['postObjectVersion'], postObjVerCalled); assert.deepStrictEqual(deleteCalled, ['deleteObject']); assert.deepStrictEqual(daCalled, ['https://localhost/api/v1/deleteadmin?doc=http://somehost.com/somedoc.html']); }); -}); \ No newline at end of file +}); diff --git a/test/storage/object/delete.test.js b/test/storage/object/delete.test.js new file mode 100644 index 0000000..4883270 --- /dev/null +++ b/test/storage/object/delete.test.js @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; +import { destroyMiniflare, getMiniflare } from '../../mocks/miniflare.js'; +import listObjects from '../../../src/storage/object/list.js'; +import deleteObjects from '../../../src/storage/object/delete.js'; + +describe('delete object(s)', () => { + let mf; + let env; + beforeEach(async () => { + mf = await getMiniflare(); + env = await mf.getBindings(); + }); + afterEach(async () => { + await destroyMiniflare(mf); + }); + + it('handles no object', async () => { + const daCtx = { users: [{email: 'aparker@geometrixx.info'}], org: 'geometrixx', key: 'does-not-exist.html' }; + const resp = await deleteObjects(env, daCtx); + assert.strictEqual(resp.status, 204); + }); + + it('deletes a single object', async () => { + const daCtx = { users: [{email: 'aparker@geometrixx.info'}], org: 'geometrixx', key: 'outdoors/index.html' }; + + await env.DA_CONTENT.put('geometrixx/shapes.props', '{"key":"value"}'); + await env.DA_CONTENT.put('geometrixx/we-retail.props', '{"key":"value"}'); + await env.DA_CONTENT.put('geometrixx/outdoors/index.html', 'Hello'); + + const resp = await deleteObjects(env, daCtx); + assert.strictEqual(resp.status, 204); + const head = await env.DA_CONTENT.head('geometrixx/outdoors/index.html'); + assert.ifError(head); + }); + + it('deletes a folder', async () => { + await env.DA_CONTENT.put('geometrixx/outdoors/index.html', 'Hello!'); + await env.DA_CONTENT.put('geometrixx/outdoors/logo.jpg', '1234'); + await env.DA_CONTENT.put('geometrixx/outdoors/hero.jpg', '1234'); + await env.DA_CONTENT.put('geometrixx/outdoors/coats/coats.props', '{"key": "value"}'); + await env.DA_CONTENT.put('geometrixx/outdoors/pants/pants.props', '{"key": "value"}'); + await env.DA_CONTENT.put('geometrixx/outdoors/hats/hats.props', '{"key": "value"}'); + + const daCtx = { users: [{email: 'aparker@geometrixx.info'}], org: 'geometrixx', key: 'outdoors' }; + const resp = await deleteObjects(env, daCtx); + assert.strictEqual(resp.status, 204); + const list = await env.DA_CONTENT.list({ prefix: 'geometrixx/outdoors' }); + assert.strictEqual(list.objects.length, 0); + }); + + it('deletes a folder (truncated list === true)', async function() { + this.timeout(10000); + await env.DA_CONTENT.put('geometrixx/outdoors/index.html', 'Hello!'); + for (let i = 0; i < 1000; i++) { + await env.DA_CONTENT.put(`geometrixx/outdoors/${i}/${i}.html`, 'Content'); + } + + const daCtx = { users: [{email: 'aparker@geometrixx.info'}], org: 'geometrixx', key: 'outdoors' }; + const resp = await deleteObjects(env, daCtx); + assert.strictEqual(resp.status, 204); + const list = await env.DA_CONTENT.list({ prefix: 'geometrixx/outdoors' }); + assert.strictEqual(list.objects.length, 0); + }); +});