From b210f3b712e9717c55bb23b83a6418d566775658 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 2 Oct 2025 11:12:18 +0200 Subject: [PATCH 1/4] feat: upload assets to media bus --- src/handlers/post.js | 2 ++ src/helpers/source.js | 12 ++++++++++- src/routes/media.js | 40 +++++++++++++++++++++++++++++++++++++ src/routes/source.js | 2 +- src/storage/object/put.js | 13 ++---------- src/utils/constants.js | 10 ++++++++++ test/helpers/source.test.js | 2 +- wrangler.toml | 2 +- 8 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 src/routes/media.js diff --git a/src/handlers/post.js b/src/handlers/post.js index 103cb978..7dfd4c7d 100644 --- a/src/handlers/post.js +++ b/src/handlers/post.js @@ -15,6 +15,7 @@ import { postVersionSource } from '../routes/version.js'; import copyHandler from '../routes/copy.js'; import logout from '../routes/logout.js'; import moveRoute from '../routes/move.js'; +import postMedia from '../routes/media.js'; export default async function postHandler({ req, env, daCtx }) { const { path } = daCtx; @@ -25,6 +26,7 @@ export default async function postHandler({ req, env, daCtx }) { if (path.startsWith('/copy')) return copyHandler({ req, env, daCtx }); if (path.startsWith('/move')) return moveRoute({ req, env, daCtx }); if (path.startsWith('/logout')) return logout({ env, daCtx }); + if (path.startsWith('/media')) return postMedia({ req, env, daCtx }); return undefined; } diff --git a/src/helpers/source.js b/src/helpers/source.js index f725adc7..cfc01a96 100644 --- a/src/helpers/source.js +++ b/src/helpers/source.js @@ -59,7 +59,7 @@ async function formPutHandler(req) { return formData ? getFormEntries(formData) : null; } -export default async function putHelper(req, env, daCtx) { +export async function putHelper(req, env, daCtx) { const contentType = req.headers.get('content-type')?.split(';')[0]; if (!contentType) return null; @@ -68,3 +68,13 @@ export default async function putHelper(req, env, daCtx) { return undefined; } + +export async function getFileBody(data) { + await data.text(); + return { body: data, type: data.type }; +} + +export function getObjectBody(data) { + // TODO: This will not correctly handle HTML as data + return { body: JSON.stringify(data), type: 'application/json' }; +} diff --git a/src/routes/media.js b/src/routes/media.js new file mode 100644 index 00000000..7c23dc16 --- /dev/null +++ b/src/routes/media.js @@ -0,0 +1,40 @@ +/* + * 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 { hasPermission } from '../utils/auth.js'; +import { MEDIA_TYPES } from '../utils/constants.js'; +import { getFileBody, putHelper } from '../helpers/source.js'; + +export default async function postMedia({ req, env, daCtx }) { + if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 }; + + const obj = await putHelper(req, env, daCtx); + const { body, type: contentType } = await getFileBody(obj.data); + + if (!MEDIA_TYPES.includes(contentType)) return { status: 400 }; + + const adminMediaAPI = env.AEM_ADMIN_MEDIA_API; + const url = `${adminMediaAPI}/${daCtx.fullKey}/main`; + + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': contentType, + Authorization: `token ${env.AEM_ADMIN_MEDIA_API_KEY}`, + }, + body, + }); + + if (!resp.ok) return { status: resp.status }; + const data = await resp.json(); + return { status: 200, body: JSON.stringify(data), contentType: 'application/json' }; +} diff --git a/src/routes/source.js b/src/routes/source.js index c2ab5b8c..e1e6dd05 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -14,7 +14,7 @@ import putObject from '../storage/object/put.js'; import deleteObjects from '../storage/object/delete.js'; import { invalidateCollab } from '../storage/utils/object.js'; -import putHelper from '../helpers/source.js'; +import { putHelper } from '../helpers/source.js'; import deleteHelper from '../helpers/delete.js'; import { hasPermission } from '../utils/auth.js'; diff --git a/src/storage/object/put.js b/src/storage/object/put.js index 0278746c..7412aad9 100644 --- a/src/storage/object/put.js +++ b/src/storage/object/put.js @@ -15,18 +15,9 @@ import { } from '@aws-sdk/client-s3'; import getS3Config from '../utils/config.js'; -import { sourceRespObject } from '../../helpers/source.js'; -import { putObjectWithVersion } from '../version/put.js'; - -async function getFileBody(data) { - await data.text(); - return { body: data, type: data.type }; -} +import { sourceRespObject, getFileBody, getObjectBody } from '../../helpers/source.js'; -function getObjectBody(data) { - // TODO: This will not correctly handle HTML as data - return { body: JSON.stringify(data), type: 'application/json' }; -} +import { putObjectWithVersion } from '../version/put.js'; function buildInput({ bucket, org, key, body, type, diff --git a/src/utils/constants.js b/src/utils/constants.js index f674b13a..f933caa1 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -18,6 +18,16 @@ export const SUPPORTED_TYPES = [ 'image/gif', 'image/png', 'image/svg+xml', + 'image/webp', + 'video/mp4', +]; + +export const MEDIA_TYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + 'image/svg+xml', + 'image/webp', 'video/mp4', ]; diff --git a/test/helpers/source.test.js b/test/helpers/source.test.js index c63f31bb..b9f484bf 100644 --- a/test/helpers/source.test.js +++ b/test/helpers/source.test.js @@ -1,5 +1,5 @@ import assert from 'assert'; -import putHelper from '../../src/helpers/source.js'; +import { putHelper } from '../../src/helpers/source.js'; import env from '../utils/mocks/env.js'; const daCtx = { org: 'cq', key: 'geometrixx/hello.html', propsKey: 'geometrixx/hello.html.props' }; diff --git a/wrangler.toml b/wrangler.toml index 88b30f7e..7da901c0 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,7 +3,7 @@ main = "src/index.js" compatibility_date = "2024-11-11" keep_vars = true -vars = { DA_COLLAB = "https://collab.da.page", AEM_BUCKET_NAME = "aem-content" } +vars = { DA_COLLAB = "https://collab.da.page", AEM_BUCKET_NAME = "aem-content", AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" } services = [ { binding = "dacollab", service = "da-collab" } From 4bdad4f4ee1e49c7c1f8334fabd4b74eb01efaa7 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 2 Oct 2025 11:26:57 +0200 Subject: [PATCH 2/4] chore: tests --- test/routes/media.test.js | 237 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 test/routes/media.test.js diff --git a/test/routes/media.test.js b/test/routes/media.test.js new file mode 100644 index 00000000..c9187037 --- /dev/null +++ b/test/routes/media.test.js @@ -0,0 +1,237 @@ +/* + * 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('Media Route', () => { + it('returns 403 when user lacks write permission', async () => { + const hasPermission = () => false; + + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + const req = {}; + const env = {}; + const daCtx = { key: '/test/image.jpg' }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 403); + }); + + it('returns 400 for unsupported media type', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'text/plain' } }); + const getFileBody = async (data) => ({ body: data, type: data.type }); + + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + '../../src/helpers/source.js': { + putHelper, + getFileBody, + }, + }); + + const req = {}; + const env = {}; + const daCtx = { key: '/test/document.txt' }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 400); + }); + + it('successfully uploads supported media type', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'image/jpeg' } }); + const getFileBody = async (data) => ({ body: 'binary-image-data', type: data.type }); + + // Mock fetch to simulate successful API response + const originalFetch = globalThis.fetch; + const fetchCalls = []; + globalThis.fetch = async (url, options) => { + fetchCalls.push({ url, options }); + return { + ok: true, + json: async () => ({ id: 'media-123', url: 'https://example.com/media/123' }), + }; + }; + + try { + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + '../../src/helpers/source.js': { + putHelper, + getFileBody, + }, + }); + + const req = {}; + const env = { + AEM_ADMIN_MEDIA_API: 'https://api.example.com/media', + AEM_ADMIN_MEDIA_API_KEY: 'test-api-key', + }; + const daCtx = { + key: '/test/image.jpg', + fullKey: 'org/test/image.jpg' + }; + + const resp = await postMedia.default({ req, env, daCtx }); + + // Verify response + assert.strictEqual(resp.status, 200); + assert.strictEqual(resp.contentType, 'application/json'); + const responseData = JSON.parse(resp.body); + assert.strictEqual(responseData.id, 'media-123'); + assert.strictEqual(responseData.url, 'https://example.com/media/123'); + + // Verify API call + assert.strictEqual(fetchCalls.length, 1); + const call = fetchCalls[0]; + assert.strictEqual(call.url, 'https://api.example.com/media/org/test/image.jpg/main'); + assert.strictEqual(call.options.method, 'POST'); + assert.strictEqual(call.options.headers['Content-Type'], 'image/jpeg'); + assert.strictEqual(call.options.headers.Authorization, 'token test-api-key'); + assert.strictEqual(call.options.body, 'binary-image-data'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('handles API error responses', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'image/png' } }); + const getFileBody = async (data) => ({ body: 'png-data', type: data.type }); + + // Mock fetch to simulate API error + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: false, + status: 500, + }); + + try { + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + '../../src/helpers/source.js': { + putHelper, + getFileBody, + }, + }); + + const req = {}; + const env = { + AEM_ADMIN_MEDIA_API: 'https://api.example.com/media', + AEM_ADMIN_MEDIA_API_KEY: 'test-api-key', + }; + const daCtx = { + key: '/test/image.png', + fullKey: 'org/test/image.png' + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 500); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('supports all defined media types', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'video/mp4' } }); + const getFileBody = async (data) => ({ body: 'video-data', type: data.type }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ id: 'video-123' }), + }); + + try { + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + '../../src/helpers/source.js': { + putHelper, + getFileBody, + }, + }); + + const req = {}; + const env = { + AEM_ADMIN_MEDIA_API: 'https://api.example.com/media', + AEM_ADMIN_MEDIA_API_KEY: 'test-key', + }; + const daCtx = { + key: '/test/video.mp4', + fullKey: 'org/test/video.mp4' + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('handles different supported image formats', async () => { + const supportedTypes = ['image/jpeg', 'image/gif', 'image/png', 'image/svg+xml', 'image/webp']; + + for (const contentType of supportedTypes) { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: contentType } }); + const getFileBody = async (data) => ({ body: 'image-data', type: data.type }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ id: 'image-123' }), + }); + + try { + const postMedia = await esmock('../../src/routes/media.js', { + '../../src/utils/auth.js': { + hasPermission, + }, + '../../src/helpers/source.js': { + putHelper, + getFileBody, + }, + }); + + const req = {}; + const env = { + AEM_ADMIN_MEDIA_API: 'https://api.example.com/media', + AEM_ADMIN_MEDIA_API_KEY: 'test-key', + }; + const daCtx = { + key: `/test/image.${contentType.split('/')[1]}`, + fullKey: `org/test/image.${contentType.split('/')[1]}` + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 200, `Failed for content type: ${contentType}`); + } finally { + globalThis.fetch = originalFetch; + } + } + }); +}); From 7a7ddfb183af8c0209afaddfafe8101c6465a848 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 2 Oct 2025 11:47:30 +0200 Subject: [PATCH 3/4] chore: coverage --- test/handlers/post.test.js | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/handlers/post.test.js b/test/handlers/post.test.js index 3ab85f18..ce99effe 100644 --- a/test/handlers/post.test.js +++ b/test/handlers/post.test.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ import assert from 'assert'; +import esmock from 'esmock'; import postHandler from '../../src/handlers/post.js'; @@ -31,4 +32,46 @@ describe('Post Route', () => { assert(deleteCalled.includes('foo@bar.org')); assert(deleteCalled.includes('blah@blah.org')); }); + + it('Test media route', async () => { + const mediaCalled = []; + const mockPostMedia = async ({ req, env, daCtx }) => { + mediaCalled.push({ req, env, daCtx }); + return { status: 200, body: JSON.stringify({ id: 'media-123' }) }; + }; + + const postHandlerWithMock = await esmock('../../src/handlers/post.js', { + '../../src/routes/media.js': { + default: mockPostMedia, + }, + }); + + const req = { method: 'POST' }; + const env = { AEM_ADMIN_MEDIA_API: 'https://api.example.com' }; + const daCtx = { + path: '/media/image.jpg', + key: 'test/image.jpg', + }; + + const resp = await postHandlerWithMock.default({ req, env, daCtx }); + + assert.strictEqual(resp.status, 200); + assert.strictEqual(mediaCalled.length, 1); + assert.strictEqual(mediaCalled[0].req, req); + assert.strictEqual(mediaCalled[0].env, env); + assert.strictEqual(mediaCalled[0].daCtx, daCtx); + }); + + it('Test unknown route returns undefined', async () => { + const req = { method: 'POST' }; + const env = {}; + const daCtx = { + path: '/unknown/route', + key: 'test/unknown', + }; + + const resp = await postHandler({ req, env, daCtx }); + + assert.strictEqual(resp, undefined); + }); }); From 18bf82682313e771ce85a9a7e0230ad099b0bfb1 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 2 Oct 2025 11:59:39 +0200 Subject: [PATCH 4/4] chore: use variable for all env --- wrangler.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index 7da901c0..355ffd8a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -20,7 +20,7 @@ r2_buckets = [ ] [env.stage] -vars = { ENVIRONMENT = "stage", DA_COLLAB = "https://collab.da.page", AEM_BUCKET_NAME = "aem-content-stage" } +vars = { ENVIRONMENT = "stage", DA_COLLAB = "https://collab.da.page", AEM_BUCKET_NAME = "aem-content-stage", AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" } services = [ { binding = "dacollab", service = "da-collab-stage" } @@ -37,7 +37,7 @@ r2_buckets = [ ] [env.dev] -vars = { ENVIRONMENT = "dev", DA_COLLAB = "http://localhost:4711", AEM_BUCKET_NAME = "aem-content-dev" } +vars = { ENVIRONMENT = "dev", DA_COLLAB = "http://localhost:4711", AEM_BUCKET_NAME = "aem-content-dev", AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" } services = [ { binding = "dacollab", service = "da-collab-local" }