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 = { ".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 { const info = await stat(absolutePath).catch(() => null); if (!info?.isFile()) return c.json({ error: "Not found" }, 404); const size = info.size; const headers: Record = { "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); 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, ) { 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); } }); }