diff --git a/src/index.js b/src/index.js index 4ca3688c..3d28235d 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,7 @@ import discover from './discover/handler.js'; import index from './index/handler.js'; import live from './live/handler.js'; import log from './log/handler.js'; +import medialog from './medialog/handler.js'; import { adobeExchange, auth, discoveryKeys } from './auth/handler.js'; import { login, logout } from './login/handler.js'; import media from './media/handler.js'; @@ -84,6 +85,7 @@ export const router = new Router(nameSelector) .add('/:org/sites/:site/index/*', index) .add('/:org/sites/:site/live/*', live) .add('/:org/sites/:site/log', log) + .add('/:org/sites/:site/medialog', medialog) .add('/:org/sites/:site/login', login) .add('/:org/sites/:site/media/*', media) .add('/:org/sites/:site/preview/*', preview) diff --git a/src/medialog/add.js b/src/medialog/add.js new file mode 100644 index 00000000..4dbd950c --- /dev/null +++ b/src/medialog/add.js @@ -0,0 +1,55 @@ +/* + * 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 { Response } from '@adobe/fetch'; +import { errorResponse } from '../support/utils.js'; +import { MediaLogBatch } from '../support/medialog.js'; + +/** + * Adds entries to a media log. + * + * @param {import('../support/AdminContext.js').AdminContext} context context + * @param {import('../support/RequestInfo.js').RequestInfo} info request info + * @returns {Promise} response + */ +export default async function add(context) { + const { authInfo, contentBusId, log } = context; + const { data: { entries } } = context; + + if (!entries || !Array.isArray(entries)) { + return errorResponse(log, 400, 'Adding media logs requires an array in \'entries\''); + } + if (entries.length > 10) { + return errorResponse(log, 400, 'Array in \'entries\' should not contain more than 10 messages'); + } + + const user = authInfo.resolveEmail(); + const batch = new MediaLogBatch(contentBusId); + + entries.forEach((entry) => { + const notification = { + ...entry, + timestamp: Date.now(), + }; + if (user && !notification.user) { + notification.user = user; + } + batch.addNotification(notification); + }); + await batch.send(context); + + return new Response('', { + status: 201, + headers: { + 'content-type': 'text/plain; charset=utf-8', + }, + }); +} diff --git a/src/medialog/handler.js b/src/medialog/handler.js new file mode 100644 index 00000000..bdfaa28a --- /dev/null +++ b/src/medialog/handler.js @@ -0,0 +1,44 @@ +/* + * 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 { Response } from '@adobe/fetch'; +import add from './add.js'; +import query from './query.js'; + +/** + * Allowed methods for that handler + */ +const ALLOWED_METHODS = ['GET', 'POST']; + +/** + * Handles the /medialog route + * + * @param {import('../support/AdminContext').AdminContext} context context + * @param {import('../support/RequestInfo').RequestInfo} info request info + * @returns {Promise} response + */ +export default async function medialogHandler(context, info) { + const { authInfo } = context; + + if (ALLOWED_METHODS.indexOf(info.method) < 0) { + return new Response('method not allowed', { + status: 405, + }); + } + + authInfo.assertPermissions('log:read'); + if (info.method === 'GET') { + return query(context, info); + } + + authInfo.assertPermissions('log:write'); + return add(context, info); +} diff --git a/src/medialog/query.js b/src/medialog/query.js new file mode 100644 index 00000000..c7da81a4 --- /dev/null +++ b/src/medialog/query.js @@ -0,0 +1,91 @@ +/* + * 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 { Response } from '@adobe/fetch'; +import { MediaLog } from '@adobe/helix-admin-support'; +import { errorResponse } from '../support/utils.js'; +import { + decode, encode, getNextLinkUrl, parseIntWithCond, parseTimespan, +} from '../log/utils.js'; + +/** + * Total size of collected entries in log, when stringified. + */ +export const MAX_ENTRIES_SIZE = 3_000_000; + +/** + * Queries a media log. + * + * @param {import('../support/AdminContext.js').AdminContext} context context + * @param {import('../support/RequestInfo.js').RequestInfo} info request info + * @returns {Promise} response + */ +export default async function query(context, info) { + const { + contentBusId, log, data: { + from: fromS, to: toS, since: sinceS, limit: limitS, nextToken, + }, + } = context; + + let from; + let to; + + try { + ([from, to] = parseTimespan(fromS, toS, sinceS)); + } catch (e) { + return errorResponse(log, 400, e.message); + } + + const limit = parseIntWithCond(limitS, (value) => { + if (value >= 1 && value <= 1000) { + return true; + } + log.warn(`'limit' should be between 1 and 1000: ' ${value}`); + return false; + }, 1000); + + const mediaLogReader = MediaLog.createReader(contentBusId, log); + + try { + await mediaLogReader.init(); + + const { entries, next } = await mediaLogReader.getEntries( + from, + to, + { limit, maxSize: MAX_ENTRIES_SIZE }, + decode(nextToken), + ); + const result = { + from: new Date(from).toISOString(), + to: new Date(to).toISOString(), + entries, + }; + if (next) { + result.nextToken = encode(next); + result.links = { + next: getNextLinkUrl(info, { + from: result.from, + to: result.to, + limit: limitS, + nextToken: result.nextToken, + }), + }; + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + } finally { + mediaLogReader.close(); + } +} diff --git a/src/support/AuditBatch.js b/src/support/AuditBatch.js index 31bd6247..da7c1fa8 100644 --- a/src/support/AuditBatch.js +++ b/src/support/AuditBatch.js @@ -24,7 +24,7 @@ export function shouldAudit(context, info, res) { const { attributes, log } = context; const { route, method, org } = info; - if (route === 'log') { + if (['log', 'medialog'].includes(route)) { return false; } if (!['POST', 'DELETE', 'PUT'].includes(method)) { diff --git a/src/support/medialog.js b/src/support/medialog.js new file mode 100644 index 00000000..26bba090 --- /dev/null +++ b/src/support/medialog.js @@ -0,0 +1,67 @@ +/* + * 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 { BatchedQueueClient } from '@adobe/helix-admin-support'; +import { getPackedMessageQueue } from './utils.js'; + +/** + * Represents a batch of media log messages. + */ +export class MediaLogBatch { + constructor(contentBusId) { + this.contentBusId = contentBusId; + this.updates = []; + } + + /** + * Add a notification to the batch. + * + * @param {object} notification media log notification + */ + addNotification(notification) { + this.updates.push(notification); + } + + /** + * Send the batch as a package to the media log queue. + * + * @param {import('./AdminContext.js').AdminContext} context admin context + */ + async send(context) { + const { contentBusId, updates } = this; + + /* c8 ignore next 3 - defensive check */ + if (updates.length === 0) { + return; + } + + const { log, runtime: { region, accountId } } = context; + const payload = { + MessageGroupId: contentBusId, + MessageDeduplicationId: crypto.randomUUID(), + MessageBody: JSON.stringify({ contentBusId, updates }), + }; + + const queueClient = new BatchedQueueClient({ + log, + outQueue: getPackedMessageQueue(region, accountId, 'media-log', !!process.env.HLX_DEV_SERVER_HOST), + swapBucket: context.attributes.bucketMap.content, + }); + + try { + const messageIds = await queueClient.send([payload]); + log.info(`Sent media log batch: [${messageIds.map((messageId) => messageId.substring(0, 8)).join(',')}]`); + } finally { + queueClient.close(); + } + } +} diff --git a/test/medialog/handler.test.js b/test/medialog/handler.test.js new file mode 100644 index 00000000..2efbcf07 --- /dev/null +++ b/test/medialog/handler.test.js @@ -0,0 +1,350 @@ +/* + * 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. + */ +/* eslint-env mocha */ +import assert from 'assert'; +import crypto from 'crypto'; +import sinon from 'sinon'; +import { Request } from '@adobe/fetch'; +import { MediaLog } from '@adobe/helix-admin-support'; +import { AuthInfo } from '../../src/auth/AuthInfo.js'; +import { main } from '../../src/index.js'; +import { Nock, SITE_CONFIG } from '../utils.js'; + +describe('MediaLog Handler Tests', () => { + /** @type {import('../utils.js').NockEnv} */ + let nock; + + beforeEach(() => { + nock = new Nock().env(); + nock.siteConfig(SITE_CONFIG); + }); + + afterEach(() => { + nock.done(); + }); + + function setupTest(method = 'POST') { + const suffix = '/org/sites/site/medialog'; + + const request = new Request(`https://api.aem.live${suffix}`, { + method, + headers: { + 'x-request-id': 'rid', + }, + }); + const context = { + pathInfo: { suffix }, + attributes: { + authInfo: AuthInfo.Default().withAuthenticated(true), + }, + env: { + HLX_CONFIG_SERVICE_TOKEN: 'token', + }, + }; + return { request, context }; + } + + it('returns 405 for unsupported method', async () => { + const { request, context } = setupTest('PUT'); + const response = await main(request, context); + + assert.strictEqual(response.status, 405); + assert.strictEqual(await response.text(), 'method not allowed'); + }); +}); + +describe('MediaLog Add Tests', () => { + /** @type {import('../utils.js').NockEnv} */ + let nock; + + beforeEach(() => { + nock = new Nock().env(); + nock.siteConfig(SITE_CONFIG); + }); + + afterEach(() => { + nock.done(); + }); + + function setupTest(data, authInfo) { + const suffix = '/org/sites/site/medialog'; + + const request = new Request(`https://api.aem.live${suffix}`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'content-type': 'application/json', + }, + }); + const context = { + pathInfo: { suffix }, + attributes: { + authInfo: authInfo ?? AuthInfo.Admin().withAuthenticated(true), + }, + runtime: { + accountId: '123456789012', + region: 'us-east-1', + }, + }; + return { request, context }; + } + + describe('validates input', () => { + it('sends 400 for missing `entries`', async () => { + const { request, context } = setupTest({}); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + 'x-error': 'Adding media logs requires an array in \'entries\'', + }); + }); + + it('sends 400 for `entries` that is not an array', async () => { + const { request, context } = setupTest({ entries: {} }); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + 'x-error': 'Adding media logs requires an array in \'entries\'', + }); + }); + + it('sends 400 for `entries` that is too large', async () => { + const { request, context } = setupTest({ entries: new Array(20) }); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + 'x-error': 'Array in \'entries\' should not contain more than 10 messages', + }); + }); + }); + + describe('validates notification sent', () => { + let capturedMessages; + + beforeEach(() => { + capturedMessages = []; + nock('https://sqs.us-east-1.amazonaws.com') + .post('/', (body) => { + const { QueueUrl = '' } = body; + return QueueUrl.split('/').at(-1) === 'helix-media-log.fifo'; + }) + .reply((_, body) => { + const { Entries } = JSON.parse(body); + const parsed = JSON.parse(Entries[0].MessageBody); + parsed.updates.forEach((u) => { + // eslint-disable-next-line no-param-reassign + delete u.timestamp; + }); + capturedMessages.push({ MessageBody: parsed, MessageGroupId: Entries[0].MessageGroupId }); + return [200, JSON.stringify({ + MessageId: '374cec7b-d0c8-4a2e-ad0b-67be763cf97e', + MD5OfMessageBody: crypto.createHash('md5').update(body, 'utf-8').digest().toString('hex'), + })]; + }); + }); + + it('sends a notification and returns 201', async () => { + const { request, context } = setupTest({ + entries: [{ operation: 'ingest', path: '/media/image.png' }], + }); + const response = await main(request, context); + + assert.strictEqual(response.status, 201); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + }); + assert.strictEqual(capturedMessages.length, 1); + const { contentBusId } = SITE_CONFIG.content; + assert.strictEqual(capturedMessages[0].MessageGroupId, contentBusId); + assert.strictEqual(capturedMessages[0].MessageBody.contentBusId, contentBusId); + assert.strictEqual(capturedMessages[0].MessageBody.updates.length, 1); + assert.strictEqual(capturedMessages[0].MessageBody.updates[0].operation, 'ingest'); + assert.strictEqual(capturedMessages[0].MessageBody.updates[0].path, '/media/image.png'); + }); + + it('sends a notification with user information', async () => { + const authInfo = AuthInfo.Admin() + .withAuthenticated(true) + .withProfile({ email: 'bob@example.com' }); + + const { request, context } = setupTest({ + entries: [{ operation: 'ingest', path: '/media/image.png' }], + }, authInfo); + const response = await main(request, context); + + assert.strictEqual(response.status, 201); + assert.strictEqual(capturedMessages[0].MessageBody.updates[0].user, 'bob@example.com'); + }); + + it('does not override user already present in entry', async () => { + const authInfo = AuthInfo.Admin() + .withAuthenticated(true) + .withProfile({ email: 'bob@example.com' }); + + const { request, context } = setupTest({ + entries: [{ operation: 'ingest', path: '/media/image.png', user: 'original@example.com' }], + }, authInfo); + const response = await main(request, context); + + assert.strictEqual(response.status, 201); + assert.strictEqual(capturedMessages[0].MessageBody.updates[0].user, 'original@example.com'); + }); + }); +}); + +describe('MediaLog Query Tests', () => { + /** @type {import('../utils.js').NockEnv} */ + let nock; + + /** @type {import('sinon').SinonSandbox} */ + let sandbox; + + beforeEach(() => { + nock = new Nock().env(); + sandbox = sinon.createSandbox(); + + nock.siteConfig(SITE_CONFIG); + }); + + afterEach(() => { + sandbox.restore(); + nock.done(); + }); + + function setupTest(data) { + const suffix = '/org/sites/site/medialog'; + const query = new URLSearchParams(data); + + const request = new Request(`https://api.aem.live${suffix}?${query}`, { + method: 'GET', + }); + const context = { + pathInfo: { suffix }, + attributes: { + authInfo: AuthInfo.Admin().withAuthenticated(true), + }, + }; + return { request, context }; + } + + describe('invalid input', () => { + it('sends 400 for bad `since`', async () => { + const { request, context } = setupTest({ since: '5 decades' }); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + 'x-error': '\'since\' should match a number followed by \'s(econds)\', \'m(inutes)\', \'h(ours)\' or \'d(ays)\': 5 decades', + }); + }); + + it('sends 400 for `since` combined with `from`', async () => { + const { request, context } = setupTest({ from: '2023-09-22', since: '15m' }); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + assert.deepStrictEqual(response.headers.plain(), { + 'cache-control': 'no-store, private, must-revalidate', + 'content-type': 'text/plain; charset=utf-8', + 'x-error': '\'since\' should not be used with either \'from\' or \'to\'', + }); + }); + + it('sends 400 for `from` not smaller than `to`', async () => { + const { request, context } = setupTest({ from: '2023-09-22', to: '2023-09-22' }); + const response = await main(request, context); + + assert.strictEqual(response.status, 400); + }); + }); + + describe('valid input', () => { + let args; + + beforeEach(() => { + sandbox.stub(MediaLog, 'createReader').returns({ + init: () => {}, + getEntries: (after, before, { limit, maxSize }, location) => { + args = { + after, before, limit, maxSize, location, + }; + return { entries: [], next: null }; + }, + close: () => {}, + }); + }); + + it('returns 200 with entries', async () => { + const { request, context } = setupTest({ since: '1h' }); + const response = await main(request, context); + + assert.strictEqual(response.status, 200); + const body = await response.json(); + assert.ok(body.from); + assert.ok(body.to); + assert.deepStrictEqual(body.entries, []); + assert.ok(!body.nextToken); + }); + + it('passes correct timespan to reader', async () => { + const { request, context } = setupTest({ since: '1h' }); + await main(request, context); + + assert.strictEqual(args.before - args.after, 60 * 60 * 1000); + }); + + it('uses `limit` if specified', async () => { + const { request, context } = setupTest({ from: '2023-09-22', to: '2023-09-23', limit: 500 }); + await main(request, context); + + assert.strictEqual(args.limit, 500); + }); + + it('ignores `limit` if out of range', async () => { + const { request, context } = setupTest({ from: '2023-09-22', to: '2023-09-23', limit: 9999 }); + await main(request, context); + + assert.strictEqual(args.limit, 1000); + }); + }); + + describe('with pagination', () => { + it('returns a continuation token if there are more pages', async () => { + sandbox.stub(MediaLog, 'createReader').returns({ + init: () => {}, + getEntries: () => ({ entries: [], next: { key: 'value' } }), + close: () => {}, + }); + + const { request, context } = setupTest({ from: '2023-09-22', to: '2023-09-23' }); + const response = await main(request, context); + + assert.strictEqual(response.status, 200); + const { nextToken, links } = await response.json(); + assert.ok(nextToken); + const link = new URL(links.next); + assert.strictEqual(link.searchParams.get('nextToken'), nextToken); + }); + }); +});