/** * The HTTP-facing upload + serve layer: parse multipart requests, validate, * store, and serve files back. Built on the store registry (`client.ts`). */ import type { Context } from "@wrnexus/core"; import { getStore, hasStorage, type Store } from "./client.ts"; import type { StoredObject } from "./driver.ts"; import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts"; /** Reserved prefix the framework serves PUBLIC local objects from. */ export const UPLOADS_PREFIX = "/__wrnexus/uploads/"; export interface UploadedFile { /** Storage key — pass to `getStore().driver.get/delete` or a serve route. */ key: string; /** A servable URL for public objects, or `null` for private stores. */ url: string | null; /** Original (sanitized) client filename, for display. */ name: string; type: string; size: number; } export interface UploadOptions { /** Only read files from this form field (default: every file field). */ field?: string; /** Override the store's `maxBytes`. */ maxBytes?: number; /** Override the store's `accept` list. */ accept?: string[]; /** Key prefix, e.g. `"avatars"` → keys become `avatars///.`. */ prefix?: string; /** Virus/DLP/content scanner invoked before bytes enter storage. Throw or return unsafe to reject. */ scan?: (file: UploadScanInput) => UploadScanResult | Promise; /** Image/video/indexing hook invoked after storage. Failure removes the just-written object. */ afterStore?: (file: UploadedFile & { bytes: Uint8Array; store: Store }) => void | Promise; } export interface UploadScanInput { name: string; type: string; size: number; bytes: Uint8Array; store: Store; } export interface UploadScanResult { safe: boolean; reason?: string; scanner?: string; } /** A 4xx-carrying error so `handleUpload` can map it to a status. */ export class UploadError extends Error { constructor( message: string, readonly status = 400, ) { super(message); this.name = "UploadError"; } } function randomId(): string { const b = new Uint8Array(16); crypto.getRandomValues(b); return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); } const pad = (n: number): string => (n < 10 ? "0" + n : String(n)); /** Build a collision-proof, path-safe key — NEVER derived from client input. */ function makeKey(file: { name: string; type: string }, prefix?: string): string { const now = new Date(); const ext = extOf(file.name) || extForType(file.type); const dated = `${now.getUTCFullYear()}/${pad(now.getUTCMonth() + 1)}/${randomId()}`; const withExt = ext ? `${dated}.${ext}` : dated; const p = (prefix ?? "").replace(/^\/+|\/+$/g, ""); if (p && !isSafeKey(p)) throw new UploadError("unsafe upload prefix", 400); return p ? `${p}/${withExt}` : withExt; } function isSafeKey(key: string): boolean { return ( !!key && !key.includes("\\") && !key.includes("\0") && !key.startsWith("/") && key.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..") ); } /** Strip any path parts from a client filename; keep a friendly display name. */ function displayName(name: string): string { return (name.split(/[\\/]/).pop() || "file").slice(0, 255); } /** The servable URL for a stored object (public → URL, private → null). */ export function storedUrl(store: Store, key: string): string | null { if (store.access !== "public") return null; return store.driver.publicUrl(key) ?? `${UPLOADS_PREFIX}${store.name}/${key}`; } /** * Read multipart file(s) from a request and store them. Throws `UploadError` * (4xx) on validation failures. Call it directly, or use `handleUpload`. */ export async function upload( storeName: string | undefined, req: Request, opts: UploadOptions = {}, ): Promise<{ files: UploadedFile[] }> { const store = getStore(storeName); const maxBytes = opts.maxBytes ?? store.config.maxBytes; const accept = opts.accept ?? store.config.accept; let form: FormData; try { form = await req.formData(); } catch { throw new UploadError("expected a multipart/form-data upload", 400); } const blobs: File[] = []; for (const [name, value] of form.entries()) { if (typeof value === "string") continue; if (opts.field && name !== opts.field) continue; blobs.push(value as File); } if (blobs.length === 0) throw new UploadError("no files in upload", 400); const files: UploadedFile[] = []; for (const file of blobs) { const type = file.type || contentTypeOf(file.name); if (maxBytes && file.size > maxBytes) { throw new UploadError(`file too large (max ${maxBytes} bytes)`, 413); } if (!accepts(accept, { type, name: file.name })) { throw new UploadError(`file type not allowed: ${type || extOf(file.name) || "unknown"}`, 415); } const key = makeKey({ name: file.name, type }, opts.prefix); const data = new Uint8Array(await file.arrayBuffer()); if (opts.scan) { const result = await opts.scan({ name: displayName(file.name), type, size: file.size, bytes: data, store, }); if (!result.safe) throw new UploadError( `file rejected by ${result.scanner ?? "security scanner"}${result.reason ? `: ${result.reason}` : ""}`, 422, ); } await store.driver.put(key, data, { contentType: type, filename: displayName(file.name) }); const uploaded: UploadedFile = { key, url: storedUrl(store, key), name: displayName(file.name), type, size: file.size, }; try { await opts.afterStore?.({ ...uploaded, bytes: data, store }); } catch (error) { await store.driver.delete(key); throw new UploadError( `post-upload processing failed: ${error instanceof Error ? error.message : String(error)}`, 422, ); } files.push(uploaded); } return { files }; } /** * Ready-made POST handler: * * // app/api/upload.ts * export const POST = handleUpload({ store: "public" }); * * Returns `{ ok:true, files:[…] }` on success, or `{ ok:false, error }` with a * 4xx/5xx status. */ export function handleUpload(opts: UploadOptions & { store?: string } = {}) { return async (ctx: Context): Promise => { try { const { files } = await upload(opts.store, ctx.req, opts); return Response.json({ ok: true, files }); } catch (err) { const status = err instanceof UploadError ? err.status : 500; const error = err instanceof Error ? err.message : "upload failed"; return Response.json({ ok: false, error }, { status }); } }; } function fileResponse(obj: StoredObject, cache: string): Response { const headers: Record = { "content-type": obj.contentType, "cache-control": cache, "x-content-type-options": "nosniff", }; if ( /^(?:text\/html|image\/svg\+xml|application\/(?:xhtml\+xml|javascript))\b/i.test( obj.contentType, ) ) { headers["content-disposition"] = 'attachment; filename="download"'; } if (typeof obj.size === "number") headers["content-length"] = String(obj.size); return new Response(obj.body as BodyInit, { headers }); } /** * Serve an object from a store as a route handler — mount it behind your auth * middleware to gate PRIVATE files: * * // app/api/files/[key].ts * export const GET = serveFromStore("docs"); * * Reads the key from `ctx.params.key` (or `ctx.params.path`); it may contain `/`. */ export function serveFromStore(storeName?: string, opts: { param?: string } = {}) { return async (ctx: Context): Promise => { const store = getStore(storeName); const key = ctx.params[opts.param ?? "key"] ?? ctx.params.path ?? ""; if (!isSafeKey(key)) return new Response("Not Found", { status: 404 }); const obj = await store.driver.get(key); if (!obj) return new Response("Not Found", { status: 404 }); const cache = store.access === "public" ? "public, max-age=31536000, immutable" : "private, no-store"; return fileResponse(obj, cache); }; } /** * Framework asset hook: serve PUBLIC local objects at * `/__wrnexus/uploads//`. Returns `null` for anything it doesn't * own (unknown/private/S3-backed store) so the caller falls through. Wired into * the dev + prod asset servers. */ export async function serveStoredFile(pathname: string): Promise { if (!pathname.startsWith(UPLOADS_PREFIX)) return null; const rest = pathname.slice(UPLOADS_PREFIX.length); const slash = rest.indexOf("/"); if (slash <= 0) return null; let storeName: string; let key: string; try { storeName = decodeURIComponent(rest.slice(0, slash)); key = decodeURIComponent(rest.slice(slash + 1)); } catch { return null; } if (!isSafeKey(key)) return null; if (!hasStorage(storeName)) return null; const store = getStore(storeName); if (store.access !== "public") return null; // private → served via the app route const obj = await store.driver.get(key); if (!obj) return new Response("Not Found", { status: 404 }); return fileResponse(obj, "public, max-age=31536000, immutable"); }