Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
2,073 changes: 1,894 additions & 179 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@adobe/helix-admin-support": "5.0.3",
"@adobe/helix-config": "5.8.0",
"@adobe/helix-google-support": "4.0.1",
"@adobe/helix-html2md": "1.1.0",
"@adobe/helix-mediahandler": "2.9.5",
"@adobe/helix-onedrive-support": "12.1.7",
"@adobe/helix-shared-body-data": "2.2.3",
Expand Down
18 changes: 14 additions & 4 deletions src/contentproxy/Forest.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,21 @@ export /* abstract */ class Forest {
const folderPath = path.substring(0, idx);
let items = folders[folderPath];
if (!items) {
// eslint-disable-next-line no-await-in-loop
items = await this.listFolder(rootItem, '', folderPath);
if (!items) {
try {
// eslint-disable-next-line no-await-in-loop
items = await this.listFolder(rootItem, '', folderPath);
if (!items) {
const infoPath = `${folderPath}/*`;
itemList.set(infoPath, { status: 404, path: infoPath });
items = [];
}
} catch (e) {
const infoPath = `${folderPath}/*`;
itemList.set(infoPath, { status: 404, path: infoPath });
itemList.set(infoPath, {
status: e.$metadata.httpStatusCode ?? 500,
path: infoPath,
error: String(e),
});
items = [];
}
folders[folderPath] = items;
Expand Down
4 changes: 4 additions & 0 deletions src/contentproxy/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const errors = [
code: 'AEM_BACKEND_FILE_TOO_BIG',
template: 'Unable to preview \'$1\': Documents larger than 100mb not supported: $2',
},
{
code: 'AEM_BACKEND_TOO_MANY_IMAGES',
template: 'Unable to preview \'$1\': Documents has more than $2 images: $3',
},
{
code: 'AEM_BACKEND_RESOURCE_TOO_BIG',
template: 'Files larger than 500mb are not supported: $1',
Expand Down
4 changes: 2 additions & 2 deletions src/contentproxy/google-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class GoogleForest extends Forest {
* @param {ProgressCallback} progressCB
* @returns {Promise<ResourceInfo[]>} the list of resources
*/
export async function list(context, paths, progressCB) {
export async function list(context, info, paths, progressCB) {
const { config: { content: { contentBusId, source } }, log } = context;

const client = await context.getGoogleClient(contentBusId);
Expand All @@ -133,7 +133,7 @@ export async function list(context, paths, progressCB) {
source: {
name: item.name,
id: item.id,
mimeType: item.mimeType || 'application/octet-stream',
contentType: item.mimeType || 'application/octet-stream',
lastModified: Date.parse(item.modifiedTime),
size: item.size,
type: 'gdrive',
Expand Down
8 changes: 7 additions & 1 deletion src/contentproxy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { error } from './errors.js';
import google from './google.js';
import markup from './markup.js';
import onedrive from './onedrive.js';
import sourcebus from './sourcebus.js';

/**
* @type {import('./contentproxy').ContentSourceHandler[]}
Expand All @@ -25,6 +26,7 @@ export const HANDLERS = { // exported for testing only
google,
onedrive,
markup,
sourcebus,
};

/**
Expand All @@ -34,7 +36,11 @@ export const HANDLERS = { // exported for testing only
* @return {import('./contentproxy').ContentSourceHandler} handler
*/
export function getContentSourceHandler(source) {
return HANDLERS[source.type];
let { type } = source;
if (source.url?.startsWith('https://api.aem.live/')) {
type = 'sourcebus';
}
return HANDLERS[type];
}

/**
Expand Down
50 changes: 50 additions & 0 deletions src/contentproxy/sourcebus-file.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2026 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 https://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 { Response } from '@adobe/fetch';
import { HelixStorage } from '@adobe/helix-shared-storage';
import { validateSource } from './sourcebus-utils.js';

/**
* Fetches file data from the source bus
*
* @param {import('../support/AdminContext').AdminContext} ctx context
* @param {import('../support/RequestInfo').RequestInfo} info request info
* @param {object} [opts] options
* @param {object} [opts.source] content source
* @param {string} [opts.lastModified] last modified
* @param {number} [opts.fetchTimeout] fetch timeout
* @returns {Promise<Response>} response
*/
export async function handleFile(ctx, info, opts) {
const {
org, site, sourcePath, error: errorResp,
} = await validateSource(ctx, info, opts);
if (errorResp) {
return errorResp;
}

// load content from source bus
const sourceBus = HelixStorage.fromContext(ctx).sourceBus();
const meta = {};
const body = await sourceBus.get(`${org}/${site}${sourcePath}`, meta);
if (!body) {
return new Response('', { status: 404 });
}

return new Response(body, {
status: 200,
headers: {
'content-type': meta.ContentType,
'last-modified': meta.LastModified?.toUTCString(),
},
});
}
77 changes: 77 additions & 0 deletions src/contentproxy/sourcebus-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 2026 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 https://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 { Response } from '@adobe/fetch';
import { HelixStorage } from '@adobe/helix-shared-storage';
import { errorResponse } from '../support/utils.js';
import { assertValidSheetJSON } from './utils.js';
import { validateSource } from './sourcebus-utils.js';
import { error } from './errors.js';

function parseSheetJSON(data) {
let json;
try {
json = JSON.parse(data);
} catch {
throw Error('invalid sheet json; failed to parse');
}

assertValidSheetJSON(json);
return json;
}

/**
* Fetches a JSON as sheet/multisheet from the source bus
*
* @param {import('../support/AdminContext').AdminContext} ctx context
* @param {import('../support/RequestInfo').RequestInfo} info request info
* @param {object} [opts] options
* @param {object} [opts.source] content source
* @param {string} [opts.lastModified] last modified
* @param {number} [opts.fetchTimeout] fetch timeout
* @returns {Promise<Response>} response
*/
export async function handleJSON(ctx, info, opts) {
const { log } = ctx;
const {
org, site, sourcePath, error: errorResp,
} = await validateSource(ctx, info, opts);
if (errorResp) {
return errorResp;
}

// load content from source bus
const sourceBus = HelixStorage.fromContext(ctx).sourceBus();
const meta = {};
const body = await sourceBus.get(`${org}/${site}${sourcePath}`, meta);
if (!body) {
return new Response('', { status: 404 });
}

let json;
try {
json = parseSheetJSON(body);
} catch (e) {
return errorResponse(log, 400, error(
'JSON fetched from markup \'$1\' is invalid: $2',
sourcePath,
e.message,
));
}

return new Response(JSON.stringify(json), {
status: 200,
headers: {
'content-type': 'application/json',
'last-modified': meta.LastModified?.toUTCString(),
},
});
}
127 changes: 127 additions & 0 deletions src/contentproxy/sourcebus-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2026 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 https://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 { splitByExtension } from '@adobe/helix-shared-string';
import { HelixStorage } from '@adobe/helix-shared-storage';
import { basename, dirname } from 'path';
import { Forest } from './Forest.js';
import { error } from './errors.js';
import { StatusCodeError } from '../support/StatusCodeError.js';

export class SourceForest extends Forest {
constructor(ctx, info) {
super(ctx.log);
this.ctx = ctx;
this.bucket = HelixStorage.fromContext(ctx).sourceBus();
this.org = info.org;
this.site = info.site;
}

/**
* List items below a root item.
* @returns {Promise<object[]>}
*/
async listFolder(source, rootPath, relPath) {
const key = `${this.org}/${this.site}${relPath}`;
const listing = await this.bucket.list(key);
return listing.map((item) => {
/*
"key": "org/site/documents/index.html",
"lastModified": "2025-01-01T12:34:56.000Z",
"contentLength": 32768,
"contentType": "text/html",
"path": "/index.html"
*/
const path = `${rootPath}${relPath}${item.path}`;
const name = basename(item.path);
if (name === '.props') {
return null;
}
const [baseName, ext] = splitByExtension(path); // eg: /documents/index , .html
const ret = {
...item,
path,
file: true,
resourcePath: path,
name: basename(path),
ext,
};
if (name === 'index.html') {
ret.path = `${dirname(path)}/`;
ret.resourcePath = `${baseName}.md`;
ret.ext = '.md';
} else if (ext === 'html') {
ret.path = baseName;
ret.resourcePath = `${baseName}.md`;
ret.ext = '.md';
}
return ret;
}).filter((item) => !!item);
}
}
/**
* Fetches file data from the external source.
* the paths can specify the files that should be included in the list. if a path ends with `/*`
* its entire subtree is retrieved.
*
* @type {import('./contentproxy.js').FetchList}
* @param {import('../support/AdminContext').AdminContext} ctx context
* @param {PathInfo} info
* @param {import('../support/RequestInfo').RequestInfo} info request info
* @param {string[]} paths
* @param {ProgressCallback} progressCB
* @returns {Promise<ResourceInfo[]>} the list of resources
*/
export async function list(ctx, info, paths, progressCB) {
const { config: { content: { source } } } = ctx;
const sourceUrl = new URL(source.url);

// extract org and site from url.pathname, format: https://api.aem.live/<org>/sites/<site>/source
// e.g. /adobe/sites/foo/source
const pathMatch = sourceUrl.pathname.match(/^\/([^/]+)\/sites\/([^/]+)\/source$/);
if (!pathMatch) {
const { message } = error(
'Source url must be in the format: https://api.aem.live/<org>/sites/<site>/source. Got: $1',
sourceUrl.href,
);
throw new StatusCodeError(message, 400);
} else {
const [, org, site] = pathMatch; // eslint-disable-line prefer-destructuring
if (org !== info.org || site !== info.site) {
const { message } = error(
'Source bus is not allowed for org: $1, site: $2',
info.org,
info.site,
);
throw new StatusCodeError(message, 400);
}
}

const forest = new SourceForest(ctx, info);
const itemList = await forest.generate(source, paths, progressCB);

return itemList.map((item) => {
if (item.status) {
return item;
}
return {
path: item.path,
resourcePath: item.resourcePath,
source: {
name: item.name,
contentType: item.contentType,
lastModified: Date.parse(item.lastModified),
size: item.contentLength,
type: 'source',
},
};
});
}
Loading