From ad053af69ca37f2b48b62a78f2e9a9c29eb52dd3 Mon Sep 17 00:00:00 2001 From: Andreas Helmberger Date: Wed, 8 Oct 2025 22:36:35 +0200 Subject: [PATCH] EDU-1763 Handle media trash requests --- CLAUDE.md | 112 ++++++++++++++++++++++++++++++ src/dev-server/middlewares.js | 2 +- src/lambda/config.js | 1 - src/lambda/cookie-helper.js | 2 +- src/lambda/index.js | 33 ++++----- src/lambda/request-helper.js | 29 ++++++++ src/lambda/request-helper.spec.js | 105 ++++++++++++++++++++++++++++ src/lambda/urls.js | 4 ++ src/lambda/website-api-client.js | 13 +++- 9 files changed, 281 insertions(+), 20 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/lambda/request-helper.js create mode 100644 src/lambda/request-helper.spec.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7a82729 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Lambda@Edge function for authorizing access to private room resources in educandu. It serves as an authentication gateway that verifies user sessions before allowing access to protected media files stored in CloudFront/CDN. + +The package has dual purposes: +1. Deployed as a Lambda@Edge function on AWS CloudFront +2. Published as an npm package (`@educandu/rooms-auth-lambda`) for integration testing + +## Development Commands + +### Common Tasks +- **Lint**: `gulp lint` +- **Fix linting issues**: `gulp fix` +- **Run tests**: `gulp test` +- **Run tests in watch mode**: `gulp testWatch` +- **Build**: `gulp build` (outputs bundled `dist/index.js` and `pack/lambda.zip`) + +### Development Server +- **Start dev server**: `gulp serve` (runs on port 10000) +- **Watch mode**: `gulp watch` (rebuilds and restarts on changes) + +The dev server requires these environment variables: +```sh +export SESSION_COOKIE_NAME="SESSION_ID" +export WEBSITE_BASE_URL="http://localhost:3000" +export X_ROOMS_AUTH_SECRET="" +``` + +### Running Single Tests +Tests use Vitest. Run a single test file: +```sh +npx vitest run src/lambda/index.spec.js +``` + +Run tests matching a pattern: +```sh +npx vitest run -t "pattern" +``` + +## Architecture + +### Core Authentication Flow + +The Lambda@Edge handler ([src/lambda/index.js](src/lambda/index.js)) intercepts CloudFront requests and: + +1. **Request Analysis**: Parses the request URI to determine resource type (via [request-helper.js](src/lambda/request-helper.js)): + - `/room-media/{roomId}/*` - Room-specific media files + - `/document-input-media/{roomId}/{documentInputId}/*` - Document input media files + - `/media-trash/*` - Deleted files media in trash + +2. **Cookie Extraction**: Extracts session cookie from request headers + +3. **Authorization**: Makes API call to educandu website to verify access: + - Sends session cookie and `x-rooms-auth-secret` header + - Checks response status (200/401/403/500) + +4. **Response**: + - 200: Returns original request (allows CloudFront to serve file) + - 401: Redirects to login page with return URL + - 403: Returns forbidden response + - Other: Returns 500 error + +### Key Components + +- **[src/lambda/index.js](src/lambda/index.js)**: Main Lambda@Edge handler +- **[src/lambda/website-api-client.js](src/lambda/website-api-client.js)**: Makes authorization API calls to educandu website +- **[src/lambda/request-helper.js](src/lambda/request-helper.js)**: Parses CloudFront requests to extract resource type and IDs +- **[src/lambda/response-helper.js](src/lambda/response-helper.js)**: Generates CloudFront-compatible response objects +- **[src/lambda/urls.js](src/lambda/urls.js)**: Constructs API endpoint URLs +- **[src/lambda/config.js](src/lambda/config.js)**: Environment variable configuration +- **[src/dev-server/](src/dev-server/)**: Express-based local development server that simulates Lambda@Edge + +### Build System + +Uses Gulp with `@educandu/dev-tools`: +- **esbuild**: Bundles lambda code into single CommonJS file for AWS deployment (Node 16 target) +- **Bundling**: Entry point is `src/lambda/index.js`, outputs to `dist/index.js` (CommonJS format) +- **Packaging**: Creates `pack/lambda.zip` containing the bundled file for AWS deployment + +### Environment Variables + +Required variables (configured in [src/lambda/config.js](src/lambda/config.js)): +- `SESSION_COOKIE_NAME`: Name of the session cookie to extract +- `WEBSITE_BASE_URL`: Base URL of the educandu website API +- `X_ROOMS_AUTH_SECRET`: Shared secret for authenticating Lambda to website API +- `DISABLE_LOGGING`: Optional, set to "true" to disable logging, used in tests + +**Important**: Lambda@Edge doesn't support environment variables natively. Values must be injected into the bundled code after deployment by prepending `process.env` assignments at the top of `dist/index.js`. + +### Testing + +- Test files use `.spec.js` suffix +- Tests use Vitest with Sinon for mocking +- Coverage excludes dev-server and website-api-client +- Test setup in [src/test-setup.js](src/test-setup.js) + +### AWS Lambda@Edge Constraints + +- Only GET and HEAD requests are allowed (enforced in CloudFront config) +- Uses CommonJS format (not ESM) for AWS compatibility +- Target is Node 16 (Lambda@Edge restriction) +- Uses `phin` library as polyfill for `fetch` (until Lambda@Edge supports Node 18+) + +## Code Style + +- Uses ESLint with educandu dev-tools configuration +- ES modules (ESM) in source code +- CommonJS in bundled output for Lambda@Edge diff --git a/src/dev-server/middlewares.js b/src/dev-server/middlewares.js index 86ca255..1a45afa 100644 --- a/src/dev-server/middlewares.js +++ b/src/dev-server/middlewares.js @@ -6,7 +6,7 @@ export function lambdaMiddleware(cdnBaseUrl) { const proxy = httpProxy.createProxyServer({ target: cdnBaseUrl }); return async (req, res, next) => { - if ((/^\/(room-media|document-input-media)\/.+$/).test(req.url)) { + if ((/^\/(room-media|document-input-media|media-trash)\/.+$/).test(req.url)) { // eslint-disable-next-line no-console console.log('Sending request to Lambda@Edge'); try { diff --git a/src/lambda/config.js b/src/lambda/config.js index a46bfeb..ee98998 100644 --- a/src/lambda/config.js +++ b/src/lambda/config.js @@ -17,4 +17,3 @@ if (!X_ROOMS_AUTH_SECRET) { export const CLOUDFRONT_PROTO = new URL(WEBSITE_BASE_URL).protocol; export const DISABLE_LOGGING = process.env.DISABLE_LOGGING === true.toString(); - diff --git a/src/lambda/cookie-helper.js b/src/lambda/cookie-helper.js index 4da7e1d..3d09d64 100644 --- a/src/lambda/cookie-helper.js +++ b/src/lambda/cookie-helper.js @@ -10,5 +10,5 @@ export function parseCookie(headers, cookieName) { }); } - return parsedCookie[cookieName]; + return parsedCookie[cookieName] ?? null; } diff --git a/src/lambda/index.js b/src/lambda/index.js index b998107..52b918b 100644 --- a/src/lambda/index.js +++ b/src/lambda/index.js @@ -1,8 +1,7 @@ import { inspect } from 'node:util'; import { logger } from './logging.js'; -import { parseCookie } from './cookie-helper.js'; -import { SESSION_COOKIE_NAME } from './config.js'; import WebsiteApiClient from './website-api-client.js'; +import { analyzeRequest, REQUEST_TYPE } from './request-helper.js'; import { forbiddenResponse, internalServerErrorResponse, loginRedirectResponse, notFoundResponse } from './response-helper.js'; const websiteApiClient = new WebsiteApiClient(); @@ -16,31 +15,33 @@ export async function handler(event, _context, callback) { return callback(null, internalServerErrorResponse()); } - let roomId; - let documentInputId; - roomId = ((/^\/room-media\/([^/]+)\/.+$/).exec(request.uri) || [])[1]; - if (roomId) { - documentInputId = null; - } else { - [roomId, documentInputId] = ((/^\/document-input-media\/([^/]+)\/([^/]+)\/.+$/).exec(request.uri) || []).slice(1, 3); - } + const { requestType, roomId, documentInputId, sessionCookie } = analyzeRequest(request); - if (!roomId) { + if (requestType === REQUEST_TYPE.unknown) { // Request path does not match any of the required patterns -> 404 return callback(null, notFoundResponse()); } - const sessionCookie = parseCookie(request.headers, SESSION_COOKIE_NAME); if (!sessionCookie) { - // If there is no cookie, we redirect to the login page, so the user can login and then be redirected back to the room resource + // If there is no cookie, we redirect to the login page, so the user can login and then be redirected back to the requested resource return callback(null, loginRedirectResponse(request)); } let verificationResponse; try { - verificationResponse = documentInputId - ? await websiteApiClient.callDocumentInputAccessAuthEndpoint(documentInputId, sessionCookie) - : await websiteApiClient.callRoomAccessAuthEndpoint(roomId, sessionCookie); + switch (requestType) { + case REQUEST_TYPE.roomMedia: + verificationResponse = await websiteApiClient.callRoomAccessAuthEndpoint(roomId, sessionCookie); + break; + case REQUEST_TYPE.documentInputMedia: + verificationResponse = await websiteApiClient.callDocumentInputAccessAuthEndpoint(documentInputId, sessionCookie); + break; + case REQUEST_TYPE.mediaTrash: + verificationResponse = await websiteApiClient.callMediaTrashAccessAuthEndpoint(sessionCookie); + break; + default: + throw new Error(`Unexpected request type: '${requestType}'`); + } } catch (err) { logger.error(inspect(err)); return callback(null, internalServerErrorResponse()); diff --git a/src/lambda/request-helper.js b/src/lambda/request-helper.js new file mode 100644 index 0000000..3cd4643 --- /dev/null +++ b/src/lambda/request-helper.js @@ -0,0 +1,29 @@ +import { parseCookie } from './cookie-helper.js'; +import { SESSION_COOKIE_NAME } from './config.js'; + +export const REQUEST_TYPE = { + roomMedia: 'roomMedia', + documentInputMedia: 'documentInputMedia', + mediaTrash: 'mediaTrash', + unknown: 'unknown' +}; + +export function analyzeRequest(request) { + const sessionCookie = parseCookie(request.headers, SESSION_COOKIE_NAME); + + const roomMediaRoomId = ((/^\/room-media\/([^/]+)\/.+$/).exec(request.uri) || [])[1]; + if (roomMediaRoomId) { + return { requestType: REQUEST_TYPE.roomMedia, roomId: roomMediaRoomId, documentInputId: null, sessionCookie }; + } + + const [documentInputMediaRoomId, documentInputId] = ((/^\/document-input-media\/([^/]+)\/([^/]+)\/.+$/).exec(request.uri) || []).slice(1, 3); + if (documentInputMediaRoomId && documentInputId) { + return { requestType: REQUEST_TYPE.documentInputMedia, roomId: documentInputMediaRoomId, documentInputId, sessionCookie }; + } + + if ((/^\/media-trash\/.+$/).test(request.uri)) { + return { requestType: REQUEST_TYPE.mediaTrash, roomId: null, documentInputId: null, sessionCookie }; + } + + return { requestType: REQUEST_TYPE.unknown, roomId: null, documentInputId: null, sessionCookie }; +} diff --git a/src/lambda/request-helper.spec.js b/src/lambda/request-helper.spec.js new file mode 100644 index 0000000..2e877a8 --- /dev/null +++ b/src/lambda/request-helper.spec.js @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { analyzeRequest, REQUEST_TYPE } from './request-helper.js'; + +const SESSION_COOKIE_VALUE = 's%3AIMb28VLUKex1w166'; + +function createLambdaRequest({ uri = '/', cookie = `SESSION_ID=${SESSION_COOKIE_VALUE}` }) { + return { + uri, + headers: { + cookie: cookie ? [{ key: 'Cookie', value: cookie }] : null + } + }; +} + +describe('request-helper', () => { + + describe('analyzeRequest', () => { + + describe('when there is no cookie set in the request', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({ cookie: null })); + }); + it('should set the sessionCookie to null', () => { + expect(result.sessionCookie).toBeNull(); + }); + }); + + describe('when there is a session cookie set in the request', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({})); + }); + it('should set the sessionCookie to the correct value', () => { + expect(result.sessionCookie).toBe(SESSION_COOKIE_VALUE); + }); + }); + + describe('when the uri is a room media URL', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({ uri: '/room-media/abc/my-image.png' })); + }); + it('should return requestType roomMedia', () => { + expect(result.requestType).toBe(REQUEST_TYPE.roomMedia); + }); + it('should return the correct roomId', () => { + expect(result.roomId).toBe('abc'); + }); + it('should return documentInputId as null', () => { + expect(result.documentInputId).toBeNull(); + }); + }); + + describe('when the uri is a document input media URL', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({ uri: '/document-input-media/xyz/abc/my-image.png' })); + }); + it('should return requestType documentInputMedia', () => { + expect(result.requestType).toBe(REQUEST_TYPE.documentInputMedia); + }); + it('should return the correct roomId', () => { + expect(result.roomId).toBe('xyz'); + }); + it('should return the correct documentInputId', () => { + expect(result.documentInputId).toBe('abc'); + }); + }); + + describe('when the uri is a media trash URL', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({ uri: '/media-trash/my-image.png' })); + }); + it('should return requestType mediaTrash', () => { + expect(result.requestType).toBe(REQUEST_TYPE.mediaTrash); + }); + it('should return roomId as null', () => { + expect(result.roomId).toBeNull(); + }); + it('should return documentInputId as null', () => { + expect(result.documentInputId).toBeNull(); + }); + }); + + describe('when the uri does not match any known pattern', () => { + let result; + beforeEach(() => { + result = analyzeRequest(createLambdaRequest({ uri: '/chambers/123/some-file.png' })); + }); + it('should return requestType unknown', () => { + expect(result.requestType).toBe(REQUEST_TYPE.unknown); + }); + it('should return roomId as null', () => { + expect(result.roomId).toBeNull(); + }); + it('should return documentInputId as null', () => { + expect(result.documentInputId).toBeNull(); + }); + }); + + }); + +}); diff --git a/src/lambda/urls.js b/src/lambda/urls.js index b4622fe..b81fb34 100644 --- a/src/lambda/urls.js +++ b/src/lambda/urls.js @@ -11,3 +11,7 @@ export function getRoomAccessAuthorizationEndpointUrl(roomId) { export function getDocumentInputAccessAuthorizationEndpointUrl(documentInputId) { return `${WEBSITE_BASE_URL}/api/v1/doc-inputs/${encodeURIComponent(documentInputId)}/authorize-resources-access`; } + +export function getMediaTrashAccessAuthorizationEndpointUrl() { + return `${WEBSITE_BASE_URL}/api/v1/media-trash/items/authorize-resources-access`; +} diff --git a/src/lambda/website-api-client.js b/src/lambda/website-api-client.js index 82633f5..70c2e01 100644 --- a/src/lambda/website-api-client.js +++ b/src/lambda/website-api-client.js @@ -1,6 +1,6 @@ import phin from 'phin'; import { SESSION_COOKIE_NAME, X_ROOMS_AUTH_SECRET } from './config.js'; -import { getDocumentInputAccessAuthorizationEndpointUrl, getRoomAccessAuthorizationEndpointUrl } from './urls.js'; +import { getDocumentInputAccessAuthorizationEndpointUrl, getMediaTrashAccessAuthorizationEndpointUrl, getRoomAccessAuthorizationEndpointUrl } from './urls.js'; // Until Lambda@Edge supports node v18.x we have to polyfill native global `fetch`: globalThis.fetch ??= async function fetch(url, options) { @@ -30,4 +30,15 @@ export default class WebsiteApiClient { } }); } + + callMediaTrashAccessAuthEndpoint(sessionCookie) { + return fetch(getMediaTrashAccessAuthorizationEndpointUrl(), { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Cookie': `${SESSION_COOKIE_NAME}=${sessionCookie}`, + 'x-rooms-auth-secret': X_ROOMS_AUTH_SECRET + } + }); + } }