evOo-local/apps/api/src/media-http.ts
Claude 239a1eceb0
wip(media): w2-api partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:49 +00:00

267 lines
9.7 KiB
TypeScript

import { createReadStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { MediaServices } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { IsolationError, MediaStateConflictError } from "@rakazo/db";
import { assertInside, PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
import { UploadChunkError, writeUploadChunk } from "./media-uploads.js";
/*
* Byte routes Media Studio cannot express as JSON RPC: chunk uploads and
* streaming of proxies, thumbnails, renders, and bundles. Every id is checked
* against the actor's space before a path is derived, and every path is
* resolved inside the media root.
*/
const ID = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
const CHUNK_INDEX = /^[0-9]{1,7}$/;
/** A single chunk must arrive within this window; slow links resume per chunk. */
const CHUNK_TIMEOUT_MS = 10 * 60_000;
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".srt": "application/x-subrip",
".vtt": "text/vtt; charset=utf-8",
".json": "application/json",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".zip": "application/zip",
".m4a": "audio/mp4",
".wav": "audio/wav",
};
export function mediaContentType(fileName: string): string {
return CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
}
export type ByteRange = { start: number; end: number };
/**
* Parse a single `bytes=` range. Returns null for no (or an ignorable
* multi-part) range and "unsatisfiable" when it cannot be served.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ByteRange | null | "unsatisfiable" {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return header.includes(",") ? null : "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix === 0 || size === 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
if (start >= size || end < start) return "unsatisfiable";
return { start, end };
}
async function sendFile(
c: Context,
absolutePath: string,
options: { fileName: string; download?: boolean },
): Promise<Response> {
const info = await stat(absolutePath).catch(() => null);
if (!info?.isFile()) return c.json({ error: "Not found" }, 404);
const size = info.size;
const headers: Record<string, string> = {
"content-type": mediaContentType(options.fileName),
"accept-ranges": "bytes",
"cache-control": "private, max-age=300",
};
if (options.download) {
headers["content-disposition"] = `attachment; filename="${options.fileName}"`;
}
const range = parseByteRange(c.req.header("range"), size);
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
headers: { ...headers, "content-range": `bytes */${size}` },
});
}
if (c.req.method === "HEAD") {
return new Response(null, {
status: 200,
headers: { ...headers, "content-length": String(size) },
});
}
const start = range?.start ?? 0;
const end = range?.end ?? size - 1;
const body =
size === 0
? null
: (Readable.toWeb(
createReadStream(absolutePath, { start, end }),
) as ReadableStream<Uint8Array>);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...headers,
"content-length": String(size === 0 ? 0 : end - start + 1),
...(range ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
},
});
}
function mediaHttpError(c: Context, error: unknown): Response {
if (error instanceof IsolationError || error instanceof PathOutsideRootError) {
return c.json({ error: "Not found" }, 404);
}
if (error instanceof UploadChunkError) return c.json({ error: error.message }, error.status);
if (error instanceof MediaStateConflictError) return c.json({ error: error.message }, 409);
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
return c.json({ error: "Not found" }, 404);
}
throw error;
}
export function mountMediaHttpRoutes(
app: Hono,
deps: { services: MediaServices | null },
resolveActor: (c: Context) => Promise<Actor | null>,
) {
async function authorize(
c: Context,
): Promise<
{ actor: Actor; services: MediaServices; response?: undefined } | { response: Response }
> {
const actor = await resolveActor(c);
if (!actor) return { response: c.json({ error: "Unauthorized" }, 401) };
if (!deps.services)
return { response: c.json({ error: "Media Studio is not configured" }, 404) };
return { actor, services: deps.services };
}
async function mediaFile(services: MediaServices, relative: string | null) {
if (!relative) return null;
await mkdir(services.deps.mediaRoot, { recursive: true });
return resolveInside(services.deps.mediaRoot, relative);
}
app.put("/api/media/uploads/:uploadId/chunks/:index", async (c) => {
const auth = await authorize(c);
if (auth.response) {
cancelBody(c.req.raw.body);
return auth.response;
}
const uploadId = c.req.param("uploadId");
const index = c.req.param("index");
if (!ID.test(uploadId) || !CHUNK_INDEX.test(index)) {
cancelBody(c.req.raw.body);
return c.json({ error: "Not found" }, 404);
}
const lengthHeader = c.req.header("content-length");
const declared = lengthHeader && /^[0-9]+$/.test(lengthHeader) ? Number(lengthHeader) : null;
const timeout = AbortSignal.timeout(CHUNK_TIMEOUT_MS);
const body = c.req.raw.body;
const onTimeout = () => cancelBody(body);
timeout.addEventListener("abort", onTimeout, { once: true });
try {
const upload = await writeUploadChunk({
services: auth.services,
spaceId: auth.actor.spaceId,
uploadId,
index: Number(index),
body,
declaredLength: declared,
});
return c.json(upload);
} catch (error) {
cancelBody(body);
if (timeout.aborted) return c.json({ error: "Chunk took too long" }, 408);
return mediaHttpError(c, error);
} finally {
timeout.removeEventListener("abort", onTimeout);
}
});
app.get("/api/media/renders/:renderId/files/:fileName", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const renderId = c.req.param("renderId");
const fileName = c.req.param("fileName");
if (!ID.test(renderId) || !FILE_NAME.test(fileName) || fileName.includes("..")) {
return c.json({ error: "Not found" }, 404);
}
try {
const render = await auth.services.deps.repos.getRenderRecord(auth.actor.spaceId, renderId);
const download = c.req.query("download") === "1";
// Only a render that passed its gate leaves the app as a download.
if (download && render.status !== "done") return c.json({ error: "Not found" }, 404);
const absolute = await auth.services.render.outputPath(auth.actor, renderId, fileName);
if (!absolute) return c.json({ error: "Not found" }, 404);
await mkdir(auth.services.deps.mediaRoot, { recursive: true });
const inside = await assertInside(
await resolveInside(
auth.services.deps.mediaRoot,
`${auth.actor.spaceId}/renders/${renderId}`,
),
absolute,
);
return await sendFile(c, inside, { fileName, download });
} catch (error) {
return mediaHttpError(c, error);
}
});
for (const kind of ["thumb", "proxy"] as const) {
app.get(`/api/media/assets/:assetId/${kind}`, async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const assetId = c.req.param("assetId");
if (!ID.test(assetId)) return c.json({ error: "Not found" }, 404);
try {
const asset = await auth.services.deps.repos.getAssetRecord(auth.actor.spaceId, assetId);
const relative = kind === "thumb" ? asset.thumbPath : asset.proxyPath;
const absolute = await mediaFile(auth.services, relative);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, { fileName: path.basename(absolute) });
} catch (error) {
return mediaHttpError(c, error);
}
});
}
app.get("/api/media/bundles/:publicationId", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const publicationId = c.req.param("publicationId");
if (!ID.test(publicationId)) return c.json({ error: "Not found" }, 404);
try {
const publication = await auth.services.deps.repos.getPublicationRecord(
auth.actor.spaceId,
publicationId,
);
if (publication.bundleArtifactId) return c.json({ error: "Not found" }, 404);
const absolute = await mediaFile(
auth.services,
`${auth.actor.spaceId}/bundles/${publication.id}/bundle.zip`,
);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, {
fileName: `bundle-${publication.id}.zip`,
download: true,
});
} catch (error) {
return mediaHttpError(c, error);
}
});
}