import { json, jsonError } from "../http"; import { log } from "../log"; import { getBoundary, MultipartError, multipartParts, type MultipartPart } from "../multipart"; import { hashPassword, hashSecret, newFileId, sanitizeMime, sanitizeOriginalName, } from "../security"; import { UploadTooLargeError, type Storage } from "../storage"; const MAX_PASSWORD_LENGTH = 256; async function readField(part: MultipartPart, limit: number): Promise { let value = ""; for await (const chunk of part.chunks) { value += Buffer.from(chunk).toString("utf8"); if (value.length > limit) throw new MultipartError("field too long"); } return value; } export async function handleUpload( req: Request, storage: Storage, maxFileSize: number, ttlMs: number, publicBaseUrl?: string, maxStorageUsage = 0.5, ): Promise { const contentType = req.headers.get("content-type"); const boundary = contentType ? getBoundary(contentType) : null; if (!boundary || !req.body) return jsonError(400, "expected a multipart/form-data upload"); const id = newFileId(); const key = id; try { const usage = await storage.storageUsageRatio(); if (usage > maxStorageUsage) { log( "warn", `upload rejected id=${id}: storage usage ${(usage * 100).toFixed(1)}% exceeds limit ${(maxStorageUsage * 100).toFixed(0)}%`, ); return jsonError(503, "storage is full, try again later"); } let filename = ""; let mime = "application/octet-stream"; let size = 0; let hasFile = false; let password = ""; for await (const part of multipartParts(req.body, boundary)) { if (part.filename != null) { if (!hasFile) { hasFile = true; filename = part.filename; mime = sanitizeMime(part.contentType); size = await storage.saveFile(id, part.chunks, maxFileSize); } else { for await (const _ of part.chunks) { void _; } } } else if (part.name === "password" && password === "") { password = await readField(part, MAX_PASSWORD_LENGTH); } else { for await (const _ of part.chunks) { void _; } } } if (!hasFile) return jsonError(400, "no file part in the upload"); if (!password.trim()) return jsonError(400, "a password is required"); const originalName = sanitizeOriginalName(filename); await storage.writeMetadata({ id, originalName, size, mimeType: mime, downloadKeyHash: hashSecret(key), passwordHash: hashPassword(password), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + ttlMs).toISOString(), }); const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`; log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); return json({ id, key, url, originalName, size, protected: true }); } catch (err) { await storage.delete(id); await storage.deletePartial(id); if (err instanceof UploadTooLargeError) { log("warn", `upload rejected id=${id} exceeding max size ${maxFileSize}`); return jsonError(413, "file too large"); } if (err instanceof MultipartError) { log("warn", `upload rejected id=${id}: ${err.message}`); return jsonError(400, "invalid multipart body"); } log("error", `upload failed id=${id}: ${err instanceof Error ? err.message : String(err)}`); return jsonError(500, "upload failed"); } }