zero-knowledge file sharing
at main 89 lines 2.3 kB view raw
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}