import { accepts, contentTypeOf, extOf } from "./mime.ts"; export interface UploadPolicy { maxBytes?: number; accept?: string[]; requireChecksum?: boolean; filenamePattern?: RegExp; } export interface UploadInspection { filename: string; contentType: string; size: number; sha256: string; extension: string; } export class UploadPolicyError extends Error { constructor( message: string, readonly code: string, ) { super(message); this.name = "UploadPolicyError"; } } function safePrefix(prefix: string): string { const segments = prefix .split(/[\\/]+/) .map((segment) => segment.trim()) .filter((segment) => segment && segment !== "." && segment !== "..") .map((segment) => segment.replace(/[^A-Za-z0-9._-]+/g, "-")) .filter(Boolean); return segments.join("/") || "uploads"; } export function safeObjectKey(filename: string, prefix = "uploads"): string { const extension = extOf(filename) .replace(/[^A-Za-z0-9.]/g, "") .slice(0, 16); const date = new Date().toISOString().slice(0, 10); return `${safePrefix(prefix)}/${date}/${crypto.randomUUID()}${extension}`; } export async function inspectUpload( filename: string, bytes: Uint8Array, declaredType?: string, ): Promise { const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer); const sha256 = [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); return { filename, contentType: declaredType || contentTypeOf(filename), size: bytes.byteLength, sha256, extension: extOf(filename), }; } export function enforceUploadPolicy( inspection: UploadInspection, policy: UploadPolicy, expectedChecksum?: string, ): void { if (policy.maxBytes !== undefined) { if (!Number.isFinite(policy.maxBytes) || policy.maxBytes < 0) { throw new TypeError("Upload maxBytes must be a non-negative number"); } if (inspection.size > policy.maxBytes) { throw new UploadPolicyError(`File exceeds ${policy.maxBytes} bytes`, "UPLOAD_TOO_LARGE"); } } if ( policy.accept?.length && !accepts(policy.accept, { name: inspection.filename, type: inspection.contentType, }) ) { throw new UploadPolicyError("File type is not allowed", "UPLOAD_TYPE_REJECTED"); } if (policy.filenamePattern && !policy.filenamePattern.test(inspection.filename)) { throw new UploadPolicyError("Filename is not allowed", "UPLOAD_FILENAME_REJECTED"); } if (policy.requireChecksum && !expectedChecksum) { throw new UploadPolicyError("Checksum is required", "UPLOAD_CHECKSUM_REQUIRED"); } if (expectedChecksum && expectedChecksum.toLowerCase() !== inspection.sha256) { throw new UploadPolicyError("Checksum does not match", "UPLOAD_CHECKSUM_MISMATCH"); } } function signature(bytes: Uint8Array, values: number[]): boolean { return values.every((value, index) => bytes[index] === value); } export function sniffContentType(bytes: Uint8Array): string | null { if (signature(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { return "image/png"; } if (signature(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; if (signature(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"; if (signature(bytes, [0x25, 0x50, 0x44, 0x46])) return "application/pdf"; if (signature(bytes, [0x50, 0x4b, 0x03, 0x04])) return "application/zip"; if ( signature(bytes, [0x52, 0x49, 0x46, 0x46]) && String.fromCharCode(...bytes.slice(8, 12)) === "WEBP" ) { return "image/webp"; } return null; } export interface SignedFileToken { store: string; key: string; expiresAt: number; disposition?: "inline" | "attachment"; } function base64url(value: Uint8Array): string { let text = ""; for (const byte of value) text += String.fromCharCode(byte); return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function assertSigningSecret(secret: string): void { if (secret.length < 16) { throw new TypeError("Signed file token secret must be at least 16 characters"); } } function assertSignedFileToken(value: SignedFileToken): void { if (!value.store.trim()) throw new TypeError("Signed file token store is required"); if (!value.key.trim()) throw new TypeError("Signed file token key is required"); if (!Number.isFinite(value.expiresAt)) { throw new TypeError("Signed file token expiresAt must be finite"); } if ( value.disposition !== undefined && value.disposition !== "inline" && value.disposition !== "attachment" ) { throw new TypeError("Signed file token disposition is invalid"); } } async function sign(value: string, secret: string): Promise { assertSigningSecret(secret); const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); return base64url( new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))), ); } function constantTimeEqual(left: string, right: string): boolean { const length = Math.max(left.length, right.length); let difference = left.length ^ right.length; for (let index = 0; index < length; index++) { difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); } return difference === 0; } export async function createSignedFileToken( input: SignedFileToken, secret: string, ): Promise { assertSignedFileToken(input); const payload = base64url(new TextEncoder().encode(JSON.stringify(input))); return `${payload}.${await sign(payload, secret)}`; } export async function verifySignedFileToken( token: string, secret: string, now = Date.now(), ): Promise { const parts = token.split("."); if (parts.length !== 2) return null; const [payload, signature] = parts; if (!payload || !signature) return null; const expected = await sign(payload, secret); if (!constantTimeEqual(expected, signature)) return null; try { const padding = payload.length % 4 ? "=".repeat(4 - (payload.length % 4)) : ""; const raw = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + padding); const value = JSON.parse( new TextDecoder().decode(Uint8Array.from(raw, (character) => character.charCodeAt(0))), ) as SignedFileToken; assertSignedFileToken(value); return value.expiresAt > now ? value : null; } catch { return null; } }