// The editor's way to the blobs (blobs.ts): GET /blobs/?project=… for // whoever has a grant to the project, POST /blobs?project=… (the bytes as // the body) for whoever may write in it — not in a frozen project, and only // from the editor's origin (a page elsewhere under the same domain would // bring the person's cookie along). The answer to a POST is the name F gave // the bytes. For the editor's origins F answers CORS, so that a page served // from elsewhere (the development page) can reach it with its cookie. import type { IncomingMessage, ServerResponse } from 'node:http'; import { BLOB_MAX_BYTES, type BlobStore } from './blobs.js'; import type { Log } from './log.js'; import type { SessionStore } from './sessions.js'; import { cookieValue, SESSION_COOKIE } from './sync.js'; import { isUuid } from './ticket.js'; export interface BlobRouteOptions { readonly blobs: BlobStore; readonly sessions: SessionStore; readonly log: Log; readonly origins: readonly string[]; } export async function handleBlobs(request: IncomingMessage, response: ServerResponse, url: URL, { blobs, sessions, log, origins }: BlobRouteOptions): Promise { const origin = request.headers.origin; const allowed = origin !== undefined && origins.includes(origin); const headers: Record = allowed ? { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', vary: 'Origin' } : {}; const send = (status: number, value: object) => { response.writeHead(status, { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(value)); }; if (request.method === 'OPTIONS') { response.writeHead(allowed ? 204 : 403, { ...headers, 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '600' }); response.end(); return; } const project = url.searchParams.get('project'); if (!isUuid(project)) return send(400, { error: 'notAProject' }); const key = cookieValue(request.headers.cookie, SESSION_COOKIE); const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await sessions.access(key, project); if (access.kind !== 'granted') return send(403, { error: access.kind }); const name = /^\/blobs\/([^/]+)$/.exec(url.pathname)?.[1]; if (request.method === 'GET' && name !== undefined) { const bytes = (await blobs.get([name])).get(name); if (bytes === undefined) return send(404, { error: 'unknownBlob' }); // A blob's bytes never change under its name. response.writeHead(200, { ...headers, 'content-type': 'application/octet-stream', 'content-length': String(bytes.length), 'cache-control': 'private, max-age=31536000, immutable' }); response.end(bytes); return; } if (request.method === 'POST' && url.pathname === '/blobs') { if (!allowed) { log.warn('blob.foreignOrigin', { project, origin: origin ?? null }); return send(403, { error: 'foreignOrigin' }); } if (access.level !== 'write') return send(403, { error: 'readOnly' }); if (access.frozen) return send(403, { error: 'frozen' }); const parts: Buffer[] = []; let size = 0; for await (const part of request as AsyncIterable) { size += part.length; if (size > BLOB_MAX_BYTES) { log.warn('blob.tooLarge', { project, user: access.person.login }); return send(413, { error: 'tooLarge' }); } parts.push(part); } const sha = await blobs.put(Buffer.concat(parts)); log.info('blob.kept', { project, user: access.person.login, sha, size }); return send(201, { sha }); } send(404, { error: 'notFound' }); }