diff --git a/src/handlers/post.js b/src/handlers/post.js index 1a71ad02..2dc35725 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 112c3716..25bcd9d8 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..537db909 --- /dev/null +++ b/src/routes/media.js @@ -0,0 +1,45 @@ +/* + * 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.org}/${daCtx.site}/main${daCtx.aemPathname}`; + + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': contentType, + Authorization: `token ${env.AEM_ADMIN_MEDIA_API_KEY}`, + }, + body, + }); + + if (!resp.ok) { + if (resp.status === 404) { + return { status: 404, body: JSON.stringify({ error: 'Project not found in AEM - cannot upload media' }) }; + } + return { status: resp.status, headers: resp.headers }; + } + 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 3ac0565f..63d6cdd3 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 { notifyCollab } 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 fd926165..10febbcc 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 9aff9b11..452defeb 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/handlers/post.test.js b/test/handlers/post.test.js index 3e245879..ac4d95c1 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 'node: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); + }); }); diff --git a/test/helpers/source.test.js b/test/helpers/source.test.js index 8aa898f9..7f045ff4 100644 --- a/test/helpers/source.test.js +++ b/test/helpers/source.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import assert from 'node:assert'; -import putHelper from '../../src/helpers/source.js'; +import { putHelper } from '../../src/helpers/source.js'; import env from '../utils/mocks/env.js'; diff --git a/test/routes/media.test.js b/test/routes/media.test.js new file mode 100644 index 00000000..70ae20eb --- /dev/null +++ b/test/routes/media.test.js @@ -0,0 +1,330 @@ +/* + * 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 'node: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', + org: 'org', + site: 'test', + aemPathname: '/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/main/image.jpg'); + 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', + org: 'org', + site: 'test', + aemPathname: '/image.png', + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 500); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns 404 with error body when project not found in AEM', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'image/jpeg' } }); + const getFileBody = async (data) => ({ body: 'jpeg-data', type: data.type }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: false, + status: 404, + }); + + 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', + org: 'org', + site: 'test', + aemPathname: '/image.jpg', + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 404); + const body = JSON.parse(resp.body); + assert.strictEqual(body.error, 'Project not found in AEM - cannot upload media'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('forwards status and headers for non-404 API errors', async () => { + const hasPermission = () => true; + const putHelper = async () => ({ data: { type: 'image/png' } }); + const getFileBody = async (data) => ({ body: 'png-data', type: data.type }); + + const mockHeaders = new Headers({ 'Retry-After': '60', 'X-Request-Id': 'req-456' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: false, + status: 503, + headers: mockHeaders, + }); + + 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', + org: 'org', + site: 'test', + aemPathname: '/image.png', + }; + + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 503); + assert.strictEqual(resp.headers, mockHeaders); + } 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 { + // eslint-disable-next-line no-await-in-loop + 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]}`, + }; + + // eslint-disable-next-line no-await-in-loop + const resp = await postMedia.default({ req, env, daCtx }); + assert.strictEqual(resp.status, 200, `Failed for content type: ${contentType}`); + } finally { + globalThis.fetch = originalFetch; + } + } + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 3b291399..505caeb7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -26,6 +26,7 @@ ENVIRONMENT = "production" VERSION = "@@VERSION@@" DA_COLLAB = "https://collab.da.page" AEM_BUCKET_NAME = "aem-content" +AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" # ---------------------------------------------------------------------- # stage environment @@ -50,6 +51,7 @@ ENVIRONMENT = "stage" VERSION = "@@VERSION@@-stage" DA_COLLAB = "https://collab.da.page" AEM_BUCKET_NAME = "aem-content-stage" +AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" # ---------------------------------------------------------------------- # ci environment (using stage content) @@ -74,6 +76,7 @@ ENVIRONMENT = "ci" VERSION = "@@VERSION@@-stage" DA_COLLAB = "https://collab.da.page" AEM_BUCKET_NAME = "aem-content-stage" +AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" # ---------------------------------------------------------------------- @@ -99,6 +102,7 @@ ENVIRONMENT = "dev" VERSION="0.0.0-dev" DA_COLLAB = "http://localhost:4711" AEM_BUCKET_NAME = "aem-content-stage" +AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media" # ---------------------------------------------------------------------- # integration test environment (local) @@ -123,4 +127,5 @@ ENVIRONMENT = "it" VERSION="0.0.0-it" DA_COLLAB = "http://localhost:4711" AEM_BUCKET_NAME = "aem-content-local" +AEM_ADMIN_MEDIA_API = "https://admin.hlx.page/media"