diff --git a/src/code/handler.js b/src/code/handler.js index cb01637..8f88c03 100644 --- a/src/code/handler.js +++ b/src/code/handler.js @@ -10,11 +10,13 @@ * governing permissions and limitations under the License. */ import { Response } from '@adobe/fetch'; +import listBranches from './list-branches.js'; +import status from './status.js'; /** * Allowed methods for that handler. */ -const ALLOWED_METHODS = ['POST']; +const ALLOWED_METHODS = ['GET', 'POST', 'DELETE']; /** * Handles the code route @@ -29,6 +31,12 @@ export default async function codeHandler(context, info) { status: 405, }); } + if (info.method === 'GET') { + if (info.ref === '*') { + return listBranches(context, info); + } + return status(context, info); + } return new Response('NYI', { status: 405, }); diff --git a/src/code/info.js b/src/code/info.js new file mode 100644 index 0000000..dda0cea --- /dev/null +++ b/src/code/info.js @@ -0,0 +1,59 @@ +/* + * 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 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 { fetchS3 } from '@adobe/helix-admin-support'; + +/** + * Returns the code bus info for the given resource. If the resource is missing, it will not + * have a contentType. + * + * @param {import('../support/AdminContext').AdminContext} context context + * @param {import('../support/RequestInfo').RequestInfo} info request info + * + * @returns {Promise} a resource + */ +export async function getCodeBusInfo(context, info) { + const { attributes: { authInfo, bucketMap: { code } } } = context; + const { + owner, repo, ref, rawPath, + } = info; + + if (!authInfo.hasPermissions('code:read')) { + return { + status: 403, + }; + } + if (!info.rawPath) { + return { + status: 400, + permissions: authInfo.getPermissions('code:'), + }; + } + + const key = `${owner}/${repo}/${ref}${info.rawPath}`; + const resp = await fetchS3(context, 'code', key, true); + const ret = { + status: resp.status, + codeBusId: `${code}/${key}`, + permissions: authInfo.getPermissions('code:'), + }; + const { GH_RAW_URL = 'https://raw.githubusercontent.com' } = context.env; + if (resp.ok) { + ret.contentType = resp.headers.get('content-type'); + ret.lastModified = resp.headers.get('last-modified'); + ret.contentLength = resp.headers.get('x-source-content-length') || undefined; + ret.sourceLastModified = resp.headers.get('x-source-last-modified') || undefined; + ret.sourceLocation = `${GH_RAW_URL}/${owner}/${repo}/${ref}${rawPath}`; + } else if (resp.status !== 404) { + ret.error = resp.headers.get('x-error'); + } + return ret; +} diff --git a/src/code/list-branches.js b/src/code/list-branches.js new file mode 100644 index 0000000..4f5f8ac --- /dev/null +++ b/src/code/list-branches.js @@ -0,0 +1,41 @@ +/* + * 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 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'; + +/** + * Lists the branches of the repository present in code bus (not github) + * + * @param {import('../support/AdminContext').AdminContext} context context + * @param {import('../support/RequestInfo').RequestInfo} info request info + * @returns {Promise} response + */ +export default async function listBranches(context, info) { + context.attributes.authInfo.assertPermissions('code:read'); + + const { owner, repo, path } = info; + const codeBus = HelixStorage.fromContext(context).codeBus(); + const branches = await codeBus.listFolders(`${owner}/${repo}/`); + const resp = { + owner, + repo, + branches: branches + .filter((branch) => !branch.endsWith('.helix/')) + .map((branch) => `/code/${branch.substring(0, branch.length - 1)}${path}`), + }; + + return new Response(JSON.stringify(resp, null, 2), { + headers: { + 'content-type': 'application/json', + }, + }); +} diff --git a/src/code/status.js b/src/code/status.js new file mode 100644 index 0000000..5d2f316 --- /dev/null +++ b/src/code/status.js @@ -0,0 +1,60 @@ +/* + * 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 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 { getCodeBusInfo } from './info.js'; +import { StatusCodeError } from '../support/StatusCodeError.js'; + +/** + * Updates a code resource by fetching the content from github and storing it in the code-bus. + * + * @param {import('../support/AdminContext').AdminContext} context context + * @param {import('../support/RequestInfo').RequestInfo} info request info + * @returns {Promise} response + */ +export default async function status(context, info) { + const { + owner, repo, ref, resourcePath, + } = info; + + const codeInfo = await getCodeBusInfo(context, info); + if (codeInfo.status === 404) { + return new Response('', { + status: 404, + }); + } + if (codeInfo.status !== 200) { + throw new StatusCodeError(codeInfo.error, codeInfo.status); + } + + const resp = { + webPath: info.resourcePath, + resourcePath: info.resourcePath, + code: codeInfo, + live: { + url: info.getLiveUrl(), + }, + preview: { + url: info.getPreviewUrl(), + }, + edit: { + url: `https://github.com/${owner}/${repo}/edit/${ref}${resourcePath}`, + }, + // TODO: should be derived from route + // links: getAPIUrls(ctx, info, 'status', 'preview', 'live', 'code'), + }; + + return new Response(JSON.stringify(resp, null, 2), { + headers: { + 'content-type': 'application/json', + }, + }); +} diff --git a/src/discover/cdn-identifier.js b/src/discover/cdn-identifier.js index e9c43b5..51ace09 100644 --- a/src/discover/cdn-identifier.js +++ b/src/discover/cdn-identifier.js @@ -54,12 +54,12 @@ export function generate(config) { * @param {import('../support/RequestInfo').RequestInfo} info request info * @returns {Promise} list of org/site that match */ -export async function querySiblingSites(ctx, info) { - const { log } = ctx; +export async function querySiblingSites(context, info) { + const { log } = context; const { owner, repo } = info; const codeBusId = `${owner}/${repo}`; - const inventory = new Inventory(log, HelixStorage.fromContext(ctx).contentBus()); + const inventory = new Inventory(log, HelixStorage.fromContext(context).contentBus()); if (!await inventory.load()) { log.warn('Inventory not available'); return []; diff --git a/src/status/status.js b/src/status/status.js index b7cbff9..7eee30c 100644 --- a/src/status/status.js +++ b/src/status/status.js @@ -13,10 +13,11 @@ import { Response } from '@adobe/fetch'; import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; import { AccessDeniedError } from '../auth/AccessDeniedError.js'; import { LOGOUT_PATH } from '../auth/support.js'; +import { getCodeBusInfo } from '../code/info.js'; import getLiveInfo from '../live/info.js'; -import getPreviewInfo from '../preview/info.js'; import web2edit from '../lookup/web2edit.js'; import edit2web from '../lookup/edit2web.js'; +import getPreviewInfo from '../preview/info.js'; /** * Handles GET status. @@ -112,6 +113,7 @@ export default async function status(context, info) { live: await getLiveInfo(context, info), preview: await getPreviewInfo(context, info), edit, + code: await getCodeBusInfo(context, info), // TODO links: getAPIUrls(context, info, 'status', 'preview', 'live', 'code'), }; diff --git a/test/index.test.js b/test/index.test.js index 3d654bd..a5fb904 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -52,7 +52,9 @@ describe('Index Tests', () => { nock.siteConfig(SITE_CONFIG, { org: 'owner', site: 'repo' }); nock.orgConfig(ORG_CONFIG, { org: 'owner' }); - const result = await main(new Request('https://localhost/'), { + const result = await main(new Request('https://localhost/', { + method: 'PUT', + }), { pathInfo: { suffix: '/owner/sites/repo/code/main/', }, @@ -125,6 +127,9 @@ describe('Index Tests', () => { .reply(200, '', { 'last-modified': 'Thu, 08 Jul 2021 09:04:16 GMT' }) .getObject('/preview/redirects.json') .reply(404); + nock.code() + .head('/document') + .reply(404); const result = await main(new Request('https://localhost/'), { pathInfo: { @@ -139,6 +144,14 @@ describe('Index Tests', () => { }); assert.strictEqual(result.status, 200); assert.deepStrictEqual(await result.json(), { + code: { + codeBusId: 'helix-code-bus/owner/repo/main/document', + permissions: [ + 'read', + 'write', + ], + status: 404, + }, edit: {}, live: { contentBusId: 'helix-content-bus/853bced1f82a05e9d27a8f63ecac59e70d9c14680dc5e417429f65e988f/live/document.md', diff --git a/test/status/handler.test.js b/test/status/handler.test.js index 4209742..8a46b8a 100644 --- a/test/status/handler.test.js +++ b/test/status/handler.test.js @@ -99,6 +99,9 @@ describe('Status Tests', () => { assert.strictEqual(result.status, 200); assert.deepStrictEqual(await result.json(), { + code: { + status: 403, + }, edit: { status: 403, }, @@ -137,6 +140,11 @@ describe('Status Tests', () => { modifiedTime: 'Tue, 15 Jun 2021 03:54:28 GMT', }], '1BHM3lyqi0bEeaBZho8UD328oFsmsisyJ'); + // getCodeBusInfo + nock.code() + .head('/folder/page') + .reply(404); + // getContentBusInfo (preview/live) nock.content() .head('/preview/folder/page.md') @@ -200,6 +208,14 @@ describe('Status Tests', () => { sourceLocation: 'gdrive:1LSIpJMKoYeVn8-o4c2okZ6x0EwdGKtgOEkaxbnM8nZ4', status: 200, }, + code: { + codeBusId: 'helix-code-bus/owner/repo/main/folder/page', + permissions: [ + 'read', + 'write', + ], + status: 404, + }, }); }); @@ -222,6 +238,11 @@ describe('Status Tests', () => { modifiedTime: 'Tue, 15 Jun 2021 03:54:28 GMT', }); + // getCodeBusInfo + nock.code() + .head('/') + .reply(404); + // getContentBusInfo (preview/live) nock.content() .head('/preview/index.md') @@ -238,6 +259,14 @@ describe('Status Tests', () => { }); assert.strictEqual(result.status, 200); assert.deepStrictEqual(await result.json(), { + code: { + codeBusId: 'helix-code-bus/owner/repo/main/', + permissions: [ + 'read', + 'write', + ], + status: 404, + }, edit: { contentType: 'application/vnd.google-apps.document', folders: [ diff --git a/test/utils.js b/test/utils.js index e397a28..a12b2c6 100644 --- a/test/utils.js +++ b/test/utils.js @@ -135,6 +135,11 @@ export function Nock() { return scope; }; + nocker.code = () => { + const { code: { owner, repo } } = SITE_CONFIG; + return nocker.s3('helix-code-bus', `${owner}/${repo}/main`); + }; + nocker.content = (contentBusId) => nocker.s3('helix-content-bus', contentBusId ?? SITE_CONFIG.content.contentBusId); nocker.media = (contentBusId) => nocker.s3('helix-media-bus', contentBusId ?? SITE_CONFIG.content.contentBusId);