Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
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 @@
respObj = await headHandler({ env, daCtx });
break;
case 'GET':
respObj = await getHandler({ env, daCtx });
respObj = await getHandler({ req, env, daCtx });

Check warning on line 39 in src/index.js

View check run for this annotation

Codecov / codecov/patch

src/index.js#L39

Added line #L39 was not covered by tests
break;
case 'PUT':
respObj = await postHandler({ req, env, daCtx });
Expand Down
52 changes: 52 additions & 0 deletions src/routes/list-paginated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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';

async function handleBuckets({ env, daCtx }) {
const res = await listBuckets(env, daCtx);

Check warning on line 17 in src/routes/list-paginated.js

View check run for this annotation

Codecov / codecov/patch

src/routes/list-paginated.js#L16-L17

Added lines #L16 - L17 were not covered by tests

if (res.status !== 200) return { body: '', status: res.status };

Check warning on line 19 in src/routes/list-paginated.js

View check run for this annotation

Codecov / codecov/patch

src/routes/list-paginated.js#L19

Added line #L19 was not covered by tests

try {
const parsedBody = JSON.parse(res.body);
return {
body: JSON.stringify({
data: parsedBody,
limit: 1000,
offset: 0,
}),
status: 200,
};
} catch (e) {
return { body: '', status: 500 };
}
}

Check warning on line 34 in src/routes/list-paginated.js

View check run for this annotation

Codecov / codecov/patch

src/routes/list-paginated.js#L21-L34

Added lines #L21 - L34 were not covered by tests

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

Check warning on line 81 in src/storage/object/list.js

View check run for this annotation

Codecov / codecov/patch

src/storage/object/list.js#L80-L81

Added lines #L80 - L81 were not covered by tests
}

export default async function listObjects(env, daCtx, maxKeys) {
const config = getS3Config(env);
const client = new S3Client(config);
Expand All @@ -35,7 +89,6 @@
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)) 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;

// See if the folder is already in the list
if (ext === 'props') {
if (combined.some((item) => item.name === name)) 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
101 changes: 101 additions & 0 deletions test/routes/list-paginated.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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 { describe, it, afterEach, vi, beforeAll } from 'vitest';
import getListPaginated from '../../src/routes/list-paginated.js';
import { listObjectsPaginated } from '../../src/storage/object/list.js';
import { hasPermission, getChildRules } from '../../src/utils/auth.js';

describe('List Route', () => {
beforeAll(() => {
vi.mock('../../src/storage/object/list.js', () => ({
listObjectsPaginated: vi.fn()
}));
vi.mock('../../src/utils/auth.js', async () => {
const actual = await vi.importActual('../../src/utils/auth.js');
return {
...actual,
hasPermission: vi.fn(),
getChildRules: vi.fn()
};
});
});

afterEach(() => {
vi.restoreAllMocks();
});

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

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

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() };
const daCtx = { org: 'bar', key: 'q/q', users: [], aclCtx };

// Mock getChildRules to set childRules on the aclCtx
getChildRules.mockImplementation((ctx) => {
ctx.aclCtx.childRules = ['/q/q/**=read,write'];
});

await getListPaginated({ req, env: {}, daCtx });
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 = [];
listObjectsPaginated.mockImplementation((e, c, limit, offset) => {
console.log({offset, limit});
loCalled.push({ offset, limit });
return {};
});

hasPermission.mockImplementation(() => true);
getChildRules.mockImplementation(() => {});

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 });
});
});
Loading