import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { unlink } from "node:fs/promises"; import { Readable } from "node:stream"; import { Database } from "bun:sqlite"; import cron from "@elysiajs/cron"; import { html } from "@elysiajs/html"; import staticPlugin from "@elysiajs/static"; import busboy from "busboy"; import { Elysia, StatusMap, t } from "elysia"; import { fileTypeFromBuffer } from "file-type"; import { filetypes, Index, NotFound, SetCookie, ShowFile, textPreviewHtml, WrongPassword, type Preview, } from "./components"; import { config } from "./config"; import { decryptToStream, encryptStream } from "./crypto"; import { getTheme } from "./shared"; // Paths default to ./db; overridable via env so tests can point at a throwaway // directory instead of the real database/blobs. const BLOB_DIR = Bun.env.ZBIN_BLOB_DIR ?? "./db/blobs"; mkdirSync(BLOB_DIR, { recursive: true }); const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`; const safeUnlink = (path: string) => unlink(path).catch(() => {}); const filetypeSet = new Set(filetypes); const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection const MAX_FILENAME_LEN = 255; // cap on a user-supplied filename (bytes/chars) // Above this on-disk size we don't read+highlight a text file inline (it would // buffer the whole file, and its HTML is larger still); /show offers download // instead. /raw still streams the full file regardless. const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB const db = new Database(Bun.env.ZBIN_DB_PATH ?? "./db/db.sqlite"); db.run("PRAGMA foreign_keys = ON"); db.run("PRAGMA journal_mode = WAL"); // Content is stored on disk at ./db/blobs/; the row keeps only metadata. // `size` is the on-disk byte count (post-encryption) and drives the total-bytes // cap; `media_mime` is the MIME sniffed from the plaintext head at upload time, // letting /show preview media without decrypting. db.run( "CREATE TABLE IF NOT EXISTS files (uuid TEXT PRIMARY KEY, filename TEXT NOT NULL, filetype TEXT NOT NULL, encrypted INTEGER NOT NULL, size INTEGER NOT NULL, media_mime TEXT, delete_at INTEGER) STRICT", ); // The expiry cron scans by delete_at every few seconds; index it so that stays // a range lookup instead of a full table scan as the table grows. db.run("CREATE INDEX IF NOT EXISTS idx_files_delete_at ON files(delete_at)"); db.run("PRAGMA optimize=0x10002"); // Running total of stored content bytes, initialized once from the DB and then // maintained in memory (incremented on upload, decremented when files expire). let totalBytes = ( db.prepare("SELECT COALESCE(SUM(size), 0) AS t FROM files").get() as { t: number; } ).t; // uuid route params are constrained to this shape so they can't be used to // inject CRLF/extra directives into the Set-Cookie Path or content-disposition, // or to escape the blob directory. const UUID_PATTERN = "^[0-9a-fA-F-]{36}$"; // Per-IP timestamp of the last accepted upload / last server-side decryption // attempt, used for the respective cooldowns. Pruned by the cron below so they // can't grow without bound. const lastUpload = new Map(); const lastDecrypt = new Map(); type MinimalServer = { requestIP(req: Request): { address: string } | null; } | null; function clientIp( server: MinimalServer, request: Request, headers: Record, ): string { if (config.behindProxy) { const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim(); if (xff) return xff; const real = headers["x-real-ip"]; if (real) return real; } return server?.requestIP(request)?.address ?? "unknown"; } // An upload failure that maps to a specific HTTP status + message. class UploadError extends Error { constructor( readonly status: number, message: string, ) { super(message); } } // Read and discard the rest of a request body. When /upload rejects a request // before it parses the body (cooldown, bad content type), the client is still // streaming the file up; returning immediately cancels that in-flight stream and // the client sees a reset (NET_INTERRUPTED / H3_REQUEST_CANCELLED over HTTP/2/3) // instead of our response. Draining lets the request finish so the error gets // delivered. Best-effort: a client that hangs up mid-drain just errors here. async function drainBody(request: Request): Promise { if (!request.body) return; const reader = request.body.getReader(); try { while (!(await reader.read()).done) {} } catch { } finally { reader.releaseLock(); } } async function pump( stream: ReadableStream, onChunk: (chunk: Uint8Array) => void | Promise, ): Promise { const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; // Await the callback so a slow sink applies backpressure (Bun's FileSink // .write returns a Promise when the write is still pending) instead of // buffering the whole upload in memory. await onChunk(value); } } async function sniffMime(bytes: Uint8Array): Promise { if (bytes.length === 0) return null; return (await fileTypeFromBuffer(bytes))?.mime ?? null; } // Media we preview inline via /