import type { StorageDriver } from "./driver.ts"; import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts"; import { UploadError, type UploadedFile } from "./upload.ts"; export interface ResumableUploadSession { id: string; key: string; name: string; type: string; size: number; chunkSize: number; totalChunks: number; createdAt: number; expiresAt: number; chunks: Record; digests: Record; } export interface ResumableSessionStore { get(id: string): Promise; put(session: ResumableUploadSession): Promise; delete(id: string): Promise; list(): Promise; } export function memoryResumableSessionStore(): ResumableSessionStore { const sessions = new Map(); return { async get(id) { return sessions.get(id) ?? null; }, async put(session) { sessions.set(session.id, session); }, async delete(id) { sessions.delete(id); }, async list() { return [...sessions.values()]; }, }; } export interface ResumableUploadManagerOptions { driver: StorageDriver; sessions?: ResumableSessionStore; maxBytes?: number; chunkSize?: number; maxSessions?: number; ttlMs?: number; accept?: string[]; prefix?: string; publicUrl?: (key: string) => string | null; now?: () => number; } export interface CreateResumableUpload { name: string; type?: string; size: number; chunkSize?: number; } export interface ResumableChunkResult { receivedChunks: number; totalChunks: number; complete: boolean; file?: UploadedFile; } export interface ResumableUploadManager { create(input: CreateResumableUpload): Promise; uploadChunk( id: string, index: number, data: Uint8Array, sha256?: string, ): Promise; status( id: string, ): Promise<{ received: number[]; totalChunks: number; expiresAt: number } | null>; cancel(id: string): Promise; prune(): Promise; } function safePrefix(prefix: string): string { const value = prefix.replace(/^\/+|\/+$/g, ""); if ( value && (!/^[A-Za-z0-9._/-]+$/.test(value) || value.split("/").some((part) => !part || part === "." || part === "..")) ) { throw new UploadError("unsafe upload prefix", 400); } return value; } async function digest(data: Uint8Array): Promise { return Array.from( new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); } export function createResumableUploadManager( options: ResumableUploadManagerOptions, ): ResumableUploadManager { const sessions = options.sessions ?? memoryResumableSessionStore(); const maxBytes = options.maxBytes ?? 100 * 1024 * 1024; const defaultChunkSize = options.chunkSize ?? 1024 * 1024; const maxSessions = options.maxSessions ?? 1000; const ttlMs = options.ttlMs ?? 24 * 60 * 60_000; if (!Number.isInteger(maxBytes) || maxBytes < 1) throw new RangeError("resumable maxBytes must be positive"); if (!Number.isInteger(defaultChunkSize) || defaultChunkSize < 1) throw new RangeError("resumable chunkSize must be positive"); if (!Number.isInteger(maxSessions) || maxSessions < 1) throw new RangeError("resumable maxSessions must be positive"); if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new RangeError("resumable ttlMs must be positive"); const prefix = safePrefix(options.prefix ?? "resumable"); const now = options.now ?? Date.now; const finalizing = new Map>(); const manager: ResumableUploadManager = { async create(input) { await manager.prune(); if (!Number.isInteger(input.size) || input.size < 1 || input.size > maxBytes) throw new UploadError(`invalid upload size (max ${maxBytes} bytes)`, 413); const type = input.type || contentTypeOf(input.name); if (!accepts(options.accept, { type, name: input.name })) throw new UploadError(`file type not allowed: ${type}`, 415); if ((await sessions.list()).length >= maxSessions) throw new UploadError("too many active resumable uploads", 429); const chunkSize = input.chunkSize ?? defaultChunkSize; if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > maxBytes) throw new UploadError("invalid resumable chunk size", 400); const id = crypto.randomUUID(); const extension = extOf(input.name) || extForType(type); const filename = extension ? `${id}.${extension}` : id; const timestamp = now(); const session: ResumableUploadSession = { id, key: prefix ? `${prefix}/${filename}` : filename, name: input.name.split(/[\\/]/).pop()?.slice(0, 255) || "file", type, size: input.size, chunkSize, totalChunks: Math.ceil(input.size / chunkSize), createdAt: timestamp, expiresAt: timestamp + ttlMs, chunks: {}, digests: {}, }; await sessions.put(session); return session; }, async uploadChunk(id, index, data, expectedDigest) { const session = await sessions.get(id); if (!session || session.expiresAt <= now()) throw new UploadError("upload session not found or expired", 404); if (!Number.isInteger(index) || index < 0 || index >= session.totalChunks) throw new UploadError("invalid chunk index", 400); const expectedSize = index === session.totalChunks - 1 ? session.size - session.chunkSize * (session.totalChunks - 1) : session.chunkSize; if (data.byteLength !== expectedSize) throw new UploadError(`invalid chunk size (expected ${expectedSize})`, 400); const actualDigest = await digest(data); if (expectedDigest && expectedDigest.toLowerCase() !== actualDigest) throw new UploadError("chunk checksum mismatch", 422); if (session.digests[index] && session.digests[index] !== actualDigest) throw new UploadError("chunk already uploaded with different content", 409); session.chunks[index] ??= data.slice(); session.digests[index] = actualDigest; await sessions.put(session); const receivedChunks = Object.keys(session.chunks).length; if (receivedChunks !== session.totalChunks) { return { receivedChunks, totalChunks: session.totalChunks, complete: false }; } let completion = finalizing.get(id); if (!completion) { completion = (async () => { const output = new Uint8Array(session.size); let offset = 0; for (let chunk = 0; chunk < session.totalChunks; chunk++) { const value = session.chunks[chunk]; if (!value) throw new UploadError("upload is missing a chunk", 409); output.set(value, offset); offset += value.byteLength; } await options.driver.put(session.key, output, { contentType: session.type, filename: session.name, }); await sessions.delete(id); return { key: session.key, url: options.publicUrl?.(session.key) ?? null, name: session.name, type: session.type, size: session.size, }; })().finally(() => finalizing.delete(id)); finalizing.set(id, completion); } return { receivedChunks, totalChunks: session.totalChunks, complete: true, file: await completion, }; }, async status(id) { const session = await sessions.get(id); return session ? { received: Object.keys(session.chunks) .map(Number) .sort((a, b) => a - b), totalChunks: session.totalChunks, expiresAt: session.expiresAt, } : null; }, async cancel(id) { if (!(await sessions.get(id))) return false; await sessions.delete(id); return true; }, async prune() { let removed = 0; for (const session of await sessions.list()) { if (session.expiresAt <= now()) { await sessions.delete(session.id); removed++; } } return removed; }, }; return manager; }