From 16 KB a picture's or binary file's entry names its bytes by their SHA-256 instead of holding them; the bytes are on F, content-addressed (GET/POST /blobs with the session's grant, from the editor's origin, at most 16 MiB). A document's blob side says where they are: the editor's fetches and uploads, the room thread's asks F's main thread, and a start waits for the ones asked for; without a side everything stays in the document. Text stays Y.Text. A blob whose bytes are not there is never written back empty. A blob F has not got yet is asked for again a few times, and a running room reads late bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
3.6 KiB
TypeScript
73 lines
3.6 KiB
TypeScript
// The editor's way to the blobs (blobs.ts): GET /blobs/<sha>?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<void> {
|
|
const origin = request.headers.origin;
|
|
const allowed = origin !== undefined && origins.includes(origin);
|
|
const headers: Record<string, string> = 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<Buffer>) {
|
|
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' });
|
|
}
|