Skip to content
4 changes: 3 additions & 1 deletion src/handlers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { getSource } from '../routes/source.js';
import getList from '../routes/list.js';
import getListPaginated from '../routes/list-paginated.js';
import logout from '../routes/logout.js';
import { getConfig } from '../routes/config.js';
import { getVersionSource, getVersionList } from '../routes/version.js';
Expand All @@ -24,13 +25,14 @@ function getRobots() {
return { body, status: 200 };
}

export default async function getHandler({ env, daCtx }) {
export default async function getHandler({ req, env, daCtx }) {
const { path } = daCtx;

if (path.startsWith('/favicon.ico')) return get404();
if (path.startsWith('/robots.txt')) return getRobots();

if (path.startsWith('/source')) return getSource({ env, daCtx });
if (path.startsWith('/list-paginated')) return getListPaginated({ req, env, daCtx });
if (path.startsWith('/list')) return getList({ env, daCtx });
if (path.startsWith('/config')) return getConfig({ env, daCtx });
if (path.startsWith('/versionlist')) return getVersionList({ env, daCtx });
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default {
respObj = await headHandler({ env, daCtx });
break;
case 'GET':
respObj = await getHandler({ env, daCtx });
respObj = await getHandler({ req, env, daCtx });
break;
case 'PUT':
respObj = await postHandler({ req, env, daCtx });
Expand Down
32 changes: 32 additions & 0 deletions src/routes/list-paginated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 listBuckets from '../storage/bucket/list.js';
import { listObjectsPaginated } from '../storage/object/list.js';
import { getChildRules, hasPermission } from '../utils/auth.js';

export default async function getListPaginated({ req, env, daCtx }) {
if (!daCtx.org) return listBuckets(env, daCtx);
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.

Should we even support listing buckets on this new API endpoint? With the infrastructure move, this shouldn't be needed anymore, as we have all sites in one bucket @auniverseaway .

if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 };

// Get the child rules of the current folder and store this in daCtx.aclCtx
getChildRules(daCtx);

const { searchParams } = new URL(req.url);
const limit = Number.parseInt(searchParams.get('limit'), 10) ?? null;
const offset = Number.parseInt(searchParams.get('offset'), 10) ?? null;

function numOrUndef(num) {
return Number.isNaN(num) ? undefined : num;
}

return /* await */ listObjectsPaginated(env, daCtx, numOrUndef(limit), numOrUndef(offset));
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.

The ?? null will never trigger with NaN. Assuming that 0 is an invalid param for limit and offset you could use:

const limit = Number.parseInt(searchParams.get('limit'), 10) || undefined;

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.

Thanks. Unfortunately 0 for offset is a valid input - I'll just remove the ?? null, it's a leftover from before I realised that it parseInt returns NaN and not null.

}
59 changes: 56 additions & 3 deletions src/storage/object/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,72 @@ import {
} from '@aws-sdk/client-s3';

import getS3Config from '../utils/config.js';
import formatList from '../utils/list.js';
import formatList, { formatPaginatedList } from '../utils/list.js';

function buildInput({ org, key, maxKeys }) {
const LIST_LIMIT = 5000;

function buildInput({
org, key, maxKeys, continuationToken,
}) {
const input = {
Bucket: `${org}-content`,
Prefix: key ? `${key}/` : null,
Delimiter: '/',
};
if (maxKeys) input.MaxKeys = maxKeys;
if (continuationToken) input.ContinuationToken = continuationToken;
return input;
}

async function scanFiles({
daCtx, env, offset, limit,
}) {
const config = getS3Config(env);
const client = new S3Client(config);

let continuationToken = null;
const visibleFiles = [];

while (visibleFiles.length < offset + limit) {
const remainingKeys = offset + limit - visibleFiles.length;
// fetch 25 extra to account for some hidden files
const numKeysToFetch = Math.min(1000, remainingKeys + 25);

const input = buildInput({ ...daCtx, maxKeys: numKeysToFetch, continuationToken });
const command = new ListObjectsV2Command(input);

const resp = await client.send(command);
continuationToken = resp.NextContinuationToken;
visibleFiles.push(...formatPaginatedList(resp, daCtx));

if (!continuationToken) break;
}

return visibleFiles.slice(offset, offset + limit);
}

export async function listObjectsPaginated(env, daCtx, maxKeys = 1000, offset = 0) {
if (offset + maxKeys > LIST_LIMIT) {
return { status: 400 };
}

try {
const files = await scanFiles({
daCtx, env, limit: maxKeys, offset,
});
return {
body: JSON.stringify({
offset,
limit: maxKeys,
data: files,
}),
status: 200,
};
} catch (e) {
return { body: '', status: 404 };
}
}

export default async function listObjects(env, daCtx, maxKeys) {
const config = getS3Config(env);
const client = new S3Client(config);
Expand All @@ -35,7 +89,6 @@ export default async function listObjects(env, daCtx, maxKeys) {
const command = new ListObjectsV2Command(input);
try {
const resp = await client.send(command);
// console.log(resp);
const body = formatList(resp, daCtx);
return {
body: JSON.stringify(body),
Expand Down
111 changes: 66 additions & 45 deletions src/storage/utils/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,68 @@ import {
ListObjectsV2Command,
} from '@aws-sdk/client-s3';

function mapPrefixes(CommonPrefixes, daCtx) {
return CommonPrefixes?.map((prefix) => {
const name = prefix.Prefix.slice(0, -1).split('/').pop();
const splitName = name.split('.');

// Do not add any extension folders
if (splitName.length > 1) return null;

const path = `/${daCtx.org}/${prefix.Prefix.slice(0, -1)}`;

return { path, name };
}).filter((x) => !!x) ?? [];
}

function mapContents(Contents, folders, daCtx) {
return Contents?.map((content) => {
const itemName = content.Key.split('/').pop();
const splitName = itemName.split('.');
// file.jpg.props should not be a part of the list
// hidden files (.props) should not be a part of this list
if (splitName.length !== 2) return null;

const [name, ext, props] = splitName;

// Do not show any props sidecar files
if (props) return null;

// See if the folder is already in the list
if (ext === 'props') {
if (folders.some((item) => item.name === name && !item.ext)) return null;

// Remove props from the key so it can look like a folder
// eslint-disable-next-line no-param-reassign
content.Key = content.Key.replace('.props', '');
}

// Do not show any hidden files.
if (!name) return null;
const item = { path: `/${daCtx.org}/${content.Key}`, name };
if (ext !== 'props') {
item.ext = ext;
item.lastModified = content.LastModified.getTime();
}

return item;
}).filter((x) => !!x) ?? [];
}

// Performs the same as formatList, but doesn't sort
// This prevents bugs when sorting across pages of the paginated api response
// However, the order is slightly different to the formatList return value
export function formatPaginatedList(resp, daCtx) {
const { Contents } = resp;

const combined = [];

const files = mapContents(Contents, [], daCtx);
combined.push(...files);

return combined;
}

export default function formatList(resp, daCtx) {
function compare(a, b) {
if (a.name < b.name) return -1;
Expand All @@ -24,52 +86,11 @@ export default function formatList(resp, daCtx) {

const combined = [];

if (CommonPrefixes) {
CommonPrefixes.forEach((prefix) => {
const name = prefix.Prefix.slice(0, -1).split('/').pop();
const splitName = name.split('.');
const folders = mapPrefixes(CommonPrefixes, daCtx);
combined.push(...folders);

// Do not add any extension folders
if (splitName.length > 1) return;

const path = `/${daCtx.org}/${prefix.Prefix.slice(0, -1)}`;
combined.push({ path, name });
});
}

if (Contents) {
Contents.forEach((content) => {
const itemName = content.Key.split('/').pop();
const splitName = itemName.split('.');
// file.jpg.props should not be a part of the list
// hidden files (.props) should not be a part of this list
if (splitName.length !== 2) return;

const [name, ext, props] = splitName;

// Do not show any props sidecar files
if (props) return;

if (ext === 'props') {
// Do not add if it already exists as a folder (does not have an extension)
if (combined.some((item) => item.name === name && !item.ext)) return;

// Remove props from the key so it can look like a folder
// eslint-disable-next-line no-param-reassign
content.Key = content.Key.replace('.props', '');
}

// Do not show any hidden files.
if (!name) return;
const item = { path: `/${daCtx.org}/${content.Key}`, name };
if (ext !== 'props') {
item.ext = ext;
item.lastModified = content.LastModified.getTime();
}

combined.push(item);
});
}
const files = mapContents(Contents, folders, daCtx);
combined.push(...files);

return combined.sort(compare);
}
Expand Down
95 changes: 95 additions & 0 deletions test/routes/list-paginated.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2025 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 'assert';
import esmock from 'esmock';

describe('List Route', () => {
it('Test getListPaginated with permissions', async () => {
const loCalled = [];
const listObjectsPaginated = (e, c) => {
loCalled.push({ e, c });
return {};
}

const ctx = { org: 'foo', key: 'q/q/q' };
const hasPermission = (c, k, a) => {
if (k === 'q/q/q' && a === 'read') {
return false;
}
return true;
}

const getListPaginated = await esmock(
'../../src/routes/list-paginated.js', {
'../../src/storage/object/list.js': {
listObjectsPaginated
},
'../../src/utils/auth.js': {
hasPermission
}
}
);

const req = {
url: new URL('https://admin.da.live/list/foo/bar'),
}

const resp = await getListPaginated({ req, env: {}, daCtx: ctx, aclCtx: {} });
assert.strictEqual(403, resp.status);
assert.strictEqual(0, loCalled.length);

const aclCtx = { pathLookup: new Map() };
await getListPaginated({ req, env: {}, daCtx: { org: 'bar', key: 'q/q', users: [], aclCtx }});
assert.strictEqual(1, loCalled.length);
assert.strictEqual('q/q', loCalled[0].c.key);

const childRules = aclCtx.childRules;
assert.strictEqual(1, childRules.length);
assert(childRules[0].startsWith('/q/q/**='), 'Should have defined some child rule');
});

it('parses request params', async () => {
const loCalled = [];
const listObjectsPaginated = (e, c, limit, offset) => {
console.log({offset, limit})
loCalled.push({ offset, limit });
return {};
}

const hasPermission = () => true;

const getListPaginated = await esmock(
'../../src/routes/list-paginated.js', {
'../../src/storage/object/list.js': {
listObjectsPaginated
},
'../../src/utils/auth.js': {
hasPermission,
getChildRules: () => {}
}
}
);

const ctx = { org: 'foo', };
const reqs = [
{ url: 'https://admin.da.live/list/foo/bar?limit=12&offset=1' },
{ url: 'https://admin.da.live/list/foo/bar?limit=asdf&offset=17' },
{ url: 'https://admin.da.live/list/foo/bar?limit=12&offset=asdf' },
];
await getListPaginated({ req: reqs[0], env: {}, daCtx: ctx, aclCtx: {} });
assert.deepStrictEqual(loCalled[0], { limit: 12, offset: 1 });
await getListPaginated({ req: reqs[1], env: {}, daCtx: ctx, aclCtx: {} });
assert.deepStrictEqual(loCalled[1], { limit: undefined, offset: 17 });
await getListPaginated({ req: reqs[2], env: {}, daCtx: ctx, aclCtx: {} });
assert.deepStrictEqual(loCalled[2], { limit: 12, offset: undefined });
});
});
10 changes: 0 additions & 10 deletions test/storage/object/delete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,6 @@ describe('Object delete', () => {
getSignedUrl: mockSignedUrl,
}
},
{
import: {
fetch: async () => ({ status: 200 }),
}
}
);
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: 'foo/bar.html' }] });
const resp = await deleteObjects(env, daCtx, {});
Expand Down Expand Up @@ -287,11 +282,6 @@ describe('Object delete', () => {
getSignedUrl: mockSignedUrl,
}
},
{
import: {
fetch: async () => ({ status: 200 }),
}
}
);
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: 'foo/bar.html' }], NextContinuationToken: 'token' });
const resp = await deleteObjects(env, daCtx, {});
Expand Down
Loading