first commit
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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/<yyyy>/<mm>/<rand>.<ext>`. */
|
||||
prefix?: 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());
|
||||
await store.driver.put(key, data, { contentType: type, filename: displayName(file.name) });
|
||||
files.push({
|
||||
key,
|
||||
url: storedUrl(store, key),
|
||||
name: displayName(file.name),
|
||||
type,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
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<Response> => {
|
||||
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<string, string> = {
|
||||
"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<Response> => {
|
||||
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/<store>/<key>`. 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<Response | null> {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user