zero-knowledge file sharing
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;