zero-knowledge file sharing
1import { Database } from "bun:sqlite";
2import { mkdirSync, unlinkSync } from "fs";
3
4import { config } from "./config.ts";
5
6const DATA_DIR = config.dataDir;
7const FILES_DIR = `${DATA_DIR}/files`;
8mkdirSync(FILES_DIR, { recursive: true });
9
10const db = new Database(`${DATA_DIR}/drop.db`, { create: true });
11db.run("PRAGMA journal_mode = WAL;");
12
13db.run(`
14 CREATE TABLE IF NOT EXISTS files (
15 id TEXT PRIMARY KEY,
16 expires_at INTEGER NOT NULL,
17 burn_after_read INTEGER NOT NULL DEFAULT 0
18 );
19`);
20
21const insertStmt = db.prepare<void, [string, number, number]>(
22 "INSERT INTO files (id, expires_at, burn_after_read) VALUES (?, ?, ?)",
23);
24
25type FileRow = {
26 id: string;
27 expires_at: number;
28 burn_after_read: number;
29};
30
31const selectStmt = db.prepare<FileRow, [string]>(
32 "SELECT * FROM files WHERE id = ?",
33);
34
35const deleteStmt = db.prepare<void, [string]>("DELETE FROM files WHERE id = ?");
36
37const burnStmt = db.prepare<FileRow, [string]>(
38 "DELETE FROM files WHERE id = ? AND burn_after_read = 1 RETURNING *",
39);
40
41const cleanStmt = db.prepare<{ id: string }, [number]>(
42 "DELETE FROM files WHERE expires_at <= ? RETURNING id",
43);
44
45export function createFile(
46 id: string,
47 expiresAt: number,
48 burnAfterRead: boolean,
49): void {
50 insertStmt.run(id, expiresAt, burnAfterRead ? 1 : 0);
51}
52
53// Read-only lookup — does not trigger burn-after-read or delete expired rows
54export function peekFile(id: string) {
55 const row = selectStmt.get(id);
56 if (!row) return null;
57 if (row.expires_at <= Math.floor(Date.now() / 1000)) return null;
58 return row;
59}
60
61export function getFile(id: string) {
62 // Try atomic burn-after-read first — if the row has burn_after_read=1,
63 // this deletes and returns it in one step, preventing double-reads
64 const burned = burnStmt.get(id);
65 if (burned) {
66 if (burned.expires_at <= Math.floor(Date.now() / 1000)) return null;
67 return burned;
68 }
69 const row = selectStmt.get(id);
70 if (!row) return null;
71 if (row.expires_at <= Math.floor(Date.now() / 1000)) {
72 deleteStmt.run(id);
73 return null;
74 }
75 return row;
76}
77
78export function unlinkFile(id: string): void {
79 try {
80 unlinkSync(`${FILES_DIR}/${id}`);
81 } catch {}
82}
83
84export function cleanExpired(): void {
85 const now = Math.floor(Date.now() / 1000);
86 for (const { id } of cleanStmt.all(now)) {
87 unlinkFile(id);
88 }
89}