import { Hono } from "hono"; import { config } from "./config.ts"; import { createFile, getFile, peekFile, unlinkFile } from "./db.ts"; const DURATION_UNITS: Record = { s: 1, m: 60, h: 3600, d: 86400, }; function parseDuration(s: string): number | undefined { const n = parseInt(s); const mult = DURATION_UNITS[s.slice(-1)]; if (isNaN(n) || mult === undefined) return undefined; return n * mult; } const FILES_DIR = `${config.dataDir}/files`; const MAX_FILE_SIZE = config.maxFileSize; const MAX_TTL = parseDuration(config.maxTtl)!; const file = new Hono(); file.post("/", async (c) => { const formData = await c.req.formData(); const fileField = formData.get("file"); const expiresIn = formData.get("expiresIn"); const burnAfterRead = formData.get("burnAfterRead") === "true"; const metaEnc = formData.get("meta_enc"); if (!fileField || !(fileField instanceof File)) { return c.json({ error: "file field is required" }, 400); } if (!metaEnc || typeof metaEnc !== "string") { return c.json({ error: "meta_enc field is required" }, 400); } if (Buffer.from(metaEnc, "base64").byteLength !== 4096) { return c.json({ error: "Invalid meta_enc" }, 400); } if (fileField.size > MAX_FILE_SIZE) { return c.json({ error: "File too large" }, 413); } const expiresInStr = typeof expiresIn === "string" ? expiresIn.trim() : ""; const expiresInSec = expiresInStr ? parseDuration(expiresInStr) : undefined; if (!expiresInSec) { return c.json( { error: "Invalid lifetime. Use a duration like 30m, 24h, 7d" }, 400, ); } if (expiresInSec > MAX_TTL) { return c.json({ error: "expiresIn exceeds maximum allowed TTL" }, 400); } const id = crypto.randomUUID(); const expiresAt = Math.floor(Date.now() / 1000) + expiresInSec; const filePath = `${FILES_DIR}/${id}`; const buffer = await fileField.arrayBuffer(); await Bun.write(filePath, buffer); try { createFile(id, expiresAt, burnAfterRead, metaEnc); } catch (err) { unlinkFile(id); throw err; } return c.json({ id }); }); file.get("/:id/info", (c) => { const id = c.req.param("id"); const row = peekFile(id); if (!row) { return c.json({ error: "File not found or expired" }, 404); } const bunFile = Bun.file(`${FILES_DIR}/${id}`); return c.json({ id, expiresAt: row.expires_at, burnAfterRead: row.burn_after_read === 1, metaEnc: row.meta_enc, size: bunFile.size, }); }); file.get("/:id", (c) => { const id = c.req.param("id"); const row = getFile(id); if (!row) { return c.json({ error: "File not found or expired" }, 404); } const filePath = `${FILES_DIR}/${id}`; const bunFile = Bun.file(filePath); if (row.burn_after_read) { setTimeout(() => unlinkFile(id), 0); } const range = c.req.header("Range"); if (range) { const parts = range.split("=").at(-1)!.split("-"); const start = parts[0] ? Number(parts[0]) : 0; const end = parts[1] ? Number(parts[1]) + 1 : undefined; return new Response(bunFile.slice(start, end), { status: 206, headers: { "Content-Type": "application/octet-stream" }, }); } return new Response(bunFile, { headers: { "Content-Type": "application/octet-stream" }, }); }); export default file;