zero-knowledge file sharing
at main 110 lines 2.7 kB view raw
1import { Hono } from "hono"; 2 3import { config } from "./config.ts"; 4import { createFile, getFile, peekFile, unlinkFile } from "./db.ts"; 5 6const DURATION_UNITS: Record<string, number> = { 7 s: 1, 8 m: 60, 9 h: 3600, 10 d: 86400, 11}; 12 13function parseDuration(s: string): number | undefined { 14 const n = parseInt(s); 15 const mult = DURATION_UNITS[s.slice(-1)]; 16 if (isNaN(n) || mult === undefined) return undefined; 17 return n * mult; 18} 19 20const FILES_DIR = `${config.dataDir}/files`; 21const MAX_FILE_SIZE = config.maxFileSize; 22const MAX_TTL = parseDuration(config.maxTtl)!; 23 24const file = new Hono(); 25 26file.post("/", async (c) => { 27 const formData = await c.req.formData(); 28 const fileField = formData.get("file"); 29 const expiresIn = formData.get("expiresIn"); 30 const burnAfterRead = formData.get("burnAfterRead") === "true"; 31 32 if (!fileField || !(fileField instanceof File)) { 33 return c.json({ error: "file field is required" }, 400); 34 } 35 36 if (fileField.size > MAX_FILE_SIZE) { 37 return c.json({ error: "File too large" }, 413); 38 } 39 40 const expiresInStr = typeof expiresIn === "string" ? expiresIn.trim() : ""; 41 const expiresInSec = expiresInStr ? parseDuration(expiresInStr) : undefined; 42 if (!expiresInSec) { 43 return c.json( 44 { error: "Invalid lifetime. Use a duration like 30m, 24h, 7d" }, 45 400, 46 ); 47 } 48 if (expiresInSec > MAX_TTL) { 49 return c.json({ error: "expiresIn exceeds maximum allowed TTL" }, 400); 50 } 51 52 const id = crypto.randomUUID(); 53 const expiresAt = Math.floor(Date.now() / 1000) + expiresInSec; 54 const filePath = `${FILES_DIR}/${id}`; 55 56 const buffer = await fileField.arrayBuffer(); 57 await Bun.write(filePath, buffer); 58 59 try { 60 createFile(id, expiresAt, burnAfterRead); 61 } catch (err) { 62 unlinkFile(id); 63 throw err; 64 } 65 66 return c.json({ id }); 67}); 68 69file.get("/:id/info", (c) => { 70 const id = c.req.param("id"); 71 const row = peekFile(id); 72 73 if (!row) { 74 return c.json({ error: "File not found or expired" }, 404); 75 } 76 77 const bunFile = Bun.file(`${FILES_DIR}/${id}`); 78 79 return c.json({ 80 id, 81 expiresAt: row.expires_at, 82 burnAfterRead: row.burn_after_read === 1, 83 size: bunFile.size, 84 }); 85}); 86 87file.get("/:id", (c) => { 88 const id = c.req.param("id"); 89 const row = getFile(id); 90 91 if (!row) { 92 return c.json({ error: "File not found or expired" }, 404); 93 } 94 95 const filePath = `${FILES_DIR}/${id}`; 96 const bunFile = Bun.file(filePath); 97 98 const headers = new Headers({ 99 "Content-Type": "application/octet-stream", 100 "Content-Length": String(bunFile.size), 101 }); 102 103 if (row.burn_after_read) { 104 setTimeout(() => unlinkFile(id), 0); 105 } 106 107 return new Response(bunFile, { headers }); 108}); 109 110export default file;