index.ts
Raw
1import { randomUUID } from "node:crypto";
2import { mkdirSync } from "node:fs";
3import { unlink } from "node:fs/promises";
4import { Readable } from "node:stream";
5import { Database } from "bun:sqlite";
6import cron from "@elysiajs/cron";
7import { html } from "@elysiajs/html";
8import staticPlugin from "@elysiajs/static";
9import busboy from "busboy";
10import { Elysia, StatusMap, t } from "elysia";
11import { fileTypeFromBuffer } from "file-type";
12import {
13 filetypes,
14 Index,
15 NotFound,
16 SetCookie,
17 ShowFile,
18 textPreviewHtml,
19 WrongPassword,
20 type Preview,
21} from "./components";
22import { config } from "./config";
23import { decryptToStream, encryptStream } from "./crypto";
24
25const BLOB_DIR = "./db/blobs";
26mkdirSync(BLOB_DIR, { recursive: true });
27const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`;
28const safeUnlink = (path: string) => unlink(path).catch(() => {});
29
30const filetypeSet = new Set(filetypes);
31const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection
32
33const db = new Database("./db/db.sqlite");
34db.run("PRAGMA foreign_keys = ON");
35db.run("PRAGMA journal_mode = WAL");
36// Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata.
37// `size` is the on-disk byte count (post-encryption) and drives the total-bytes
38// cap; `media_mime` is the MIME sniffed from the plaintext head at upload time,
39// letting /show preview media without decrypting.
40db.run(
41 "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",
42);
43db.run("PRAGMA optimize");
44
45// Running total of stored content bytes, initialized once from the DB and then
46// maintained in memory (incremented on upload, decremented when files expire).
47let totalBytes = (
48 db.prepare("SELECT COALESCE(SUM(size), 0) AS t FROM files").get() as {
49 t: number;
50 }
51).t;
52
53// uuid route params are constrained to this shape so they can't be used to
54// inject CRLF/extra directives into the Set-Cookie Path or content-disposition,
55// or to escape the blob directory.
56const UUID_PATTERN = "^[0-9a-fA-F-]{36}$";
57
58// Per-IP timestamp of the last accepted upload / last server-side decryption
59// attempt, used for the respective cooldowns. Pruned by the cron below so they
60// can't grow without bound.
61const lastUpload = new Map<string, number>();
62const lastDecrypt = new Map<string, number>();
63
64type MinimalServer = {
65 requestIP(req: Request): { address: string } | null;
66} | null;
67
68function clientIp(
69 server: MinimalServer,
70 request: Request,
71 headers: Record<string, string | undefined>,
72): string {
73 if (config.behindProxy) {
74 const xff = headers["x-forwarded-for"]?.split(",")[0]?.trim();
75 if (xff) return xff;
76 const real = headers["x-real-ip"];
77 if (real) return real;
78 }
79 return server?.requestIP(request)?.address ?? "unknown";
80}
81
82// An upload failure that maps to a specific HTTP status + message.
83class UploadError extends Error {
84 constructor(
85 readonly status: number,
86 message: string,
87 ) {
88 super(message);
89 }
90}
91
92async function pump(
93 stream: ReadableStream<Uint8Array>,
94 onChunk: (chunk: Uint8Array) => void | Promise<void>,
95): Promise<void> {
96 const reader = stream.getReader();
97 while (true) {
98 const { done, value } = await reader.read();
99 if (done) break;
100 // Await the callback so a slow sink applies backpressure (Bun's FileSink
101 // .write returns a Promise when the write is still pending) instead of
102 // buffering the whole upload in memory.
103 await onChunk(value);
104 }
105}
106
107async function sniffMime(bytes: Uint8Array): Promise<string | null> {
108 if (bytes.length === 0) return null;
109 return (await fileTypeFromBuffer(bytes))?.mime ?? null;
110}
111
112const app = new Elysia({
113 serve: {
114 maxRequestBodySize: config.maxUploadBytes,
115 },
116})
117 .use(staticPlugin({ assets: "./assets", prefix: "/" }))
118 .use(html())
119 .use(
120 cron({
121 name: "delete",
122 pattern: "*/5 * * * * *",
123 async run() {
124 const expired = db
125 .prepare(
126 "SELECT uuid, size FROM files WHERE delete_at < strftime('%s', 'now')",
127 )
128 .all() as { uuid: string; size: number }[];
129 if (expired.length > 0) {
130 // Delete by the exact uuids selected above, not a second
131 // strftime('now') comparison (which evaluates at a later instant and
132 // could delete a row we didn't account for here — leaking the counter
133 // and orphaning its blob).
134 const placeholders = expired.map(() => "?").join(",");
135 db.exec(
136 `DELETE FROM files WHERE uuid IN (${placeholders})`,
137 expired.map((e) => e.uuid),
138 );
139 for (const { uuid, size } of expired) {
140 await safeUnlink(blobPath(uuid));
141 totalBytes -= size;
142 }
143 if (totalBytes < 0) totalBytes = 0;
144 }
145 const now = Date.now();
146 if (config.uploadCooldownSeconds > 0) {
147 const cutoff = now - config.uploadCooldownSeconds * 1000;
148 for (const [ip, ts] of lastUpload) {
149 if (ts < cutoff) lastUpload.delete(ip);
150 }
151 }
152 if (config.decryptCooldownSeconds > 0) {
153 const cutoff = now - config.decryptCooldownSeconds * 1000;
154 for (const [ip, ts] of lastDecrypt) {
155 if (ts < cutoff) lastDecrypt.delete(ip);
156 }
157 }
158 },
159 }),
160 )
161 .use(
162 cron({
163 // Run SQLite's optimizer periodically (not just at startup), per its docs.
164 name: "optimize",
165 pattern: "0 0 * * * *",
166 run() {
167 db.exec("PRAGMA optimize");
168 },
169 }),
170 )
171 .get("/", ({ server }) => Index(server?.url.toString() ?? ""))
172 .post(
173 "/upload",
174 async ({ set, server, request, headers }) => {
175 const ip = clientIp(server, request, headers);
176 const now = Date.now();
177 if (config.uploadCooldownSeconds > 0) {
178 const last = lastUpload.get(ip) ?? 0;
179 if (now - last < config.uploadCooldownSeconds * 1000) {
180 set.status = 429; // Too Many Requests
181 return "Upload cooldown active, please wait before uploading again";
182 }
183 }
184
185 const contentType = request.headers.get("content-type") ?? "";
186 if (!contentType.includes("multipart/form-data") || !request.body) {
187 set.status = 400;
188 return "Expected a multipart/form-data upload";
189 }
190
191 const uuid = randomUUID();
192 const path = blobPath(uuid);
193
194 // The in-flight blob write. A rejection can settle the upload while this
195 // is still draining to disk; the catch awaits it before unlinking so a
196 // late write can't recreate the blob after cleanup (orphan).
197 let writing: Promise<unknown> | undefined;
198
199 try {
200 // Parse the multipart body as it streams in. The file part must come
201 // LAST so the other fields (password, filetype, ...) are known before
202 // the bytes flow and can drive on-the-fly encryption to disk.
203 const result = await new Promise<{
204 filename: string;
205 filetype: string;
206 encrypted: boolean;
207 size: number;
208 mediaMime: string | null;
209 deleteAt: number | null;
210 }>((resolve, reject) => {
211 const bb = busboy({
212 headers: { "content-type": contentType },
213 limits: { files: 1, fileSize: config.maxUploadBytes },
214 });
215 const fields: Record<string, string> = {};
216 let fileSeen = false;
217 // A form field arriving after the file part means the file wasn't
218 // sent last. We don't act on it here (the file is still streaming) —
219 // we record it and report it once the whole body is parsed, so the
220 // "file must be last" error always wins over an incidental symptom
221 // like a not-yet-seen filetype.
222 let fieldAfterFile = false;
223
224 bb.on("field", (name, value) => {
225 if (fileSeen) fieldAfterFile = true;
226 else fields[name] = value;
227 });
228
229 bb.on("file", (name, stream, info) => {
230 if (name !== "file") {
231 stream.resume();
232 return;
233 }
234 fileSeen = true;
235 writing = streamToBlob(stream, info);
236 });
237
238 bb.on("error", reject);
239
240 // Settle once the entire body is parsed: by now the field set and the
241 // file's position relative to other fields are both fully known.
242 bb.on("close", async () => {
243 try {
244 if (!fileSeen) throw new UploadError(400, "No file provided");
245 if (fieldAfterFile) {
246 throw new UploadError(
247 400,
248 "the file field must be the last form field",
249 );
250 }
251 // Set because a file part was seen; await it to surface any
252 // streaming error and read the stored size/type.
253 const file = await (writing as ReturnType<typeof streamToBlob>);
254
255 const filetype = fields.filetype ?? "";
256 if (!filetypeSet.has(filetype)) {
257 throw new UploadError(400, "Invalid or missing filetype");
258 }
259 const dim = fields.delete_in_minutes;
260 if (dim && !/^[0-9]+$/.test(dim)) {
261 throw new UploadError(400, "Invalid delete_in_minutes");
262 }
263 let minutes: number | null =
264 dim && Number(dim) > 0 ? Number(dim) : null;
265 if (config.maxAgeMinutes !== null) {
266 minutes = Math.min(
267 minutes ?? config.maxAgeMinutes,
268 config.maxAgeMinutes,
269 );
270 }
271 const deleteAt =
272 minutes !== null ? Math.floor(now / 1000) + minutes * 60 : null;
273
274 resolve({
275 filename: fields.filename || file.infoFilename || "file",
276 filetype,
277 encrypted: file.encrypted,
278 size: file.size,
279 mediaMime: file.mediaMime,
280 deleteAt,
281 });
282 } catch (e) {
283 reject(e);
284 }
285 });
286
287 // Streams the file part to ./db/blobs/<uuid>, encrypting on the fly
288 // when a password was provided (server-side) or storing opaque bytes
289 // for an already-encrypted upload. Uses the fields seen so far; if the
290 // file wasn't last that set is incomplete, but the close handler
291 // rejects such uploads before anything is persisted.
292 async function streamToBlob(stream: Readable, info: busboy.FileInfo) {
293 let limitExceeded = false;
294 stream.on("limit", () => {
295 limitExceeded = true;
296 });
297 const webIn = Readable.toWeb(
298 stream,
299 ) as unknown as ReadableStream<Uint8Array>;
300
301 const sink = Bun.file(path).writer();
302 let size = 0;
303 let mediaMime: string | null = null;
304 let encrypted = false;
305
306 if (fields.encrypted === "on") {
307 // Client-side encrypted: opaque bytes, store as-is, no sniffing.
308 encrypted = true;
309 await pump(webIn, async (c) => {
310 size += c.length;
311 await sink.write(c);
312 });
313 } else if (fields.password) {
314 // Server-side encryption: encrypt the stream to disk, sniffing
315 // the plaintext head for a preview MIME type.
316 encrypted = true;
317 let head: Uint8Array | undefined;
318 const cipher = await encryptStream(
319 webIn,
320 fields.password,
321 (h) => {
322 head = h;
323 },
324 SNIFF_BYTES,
325 );
326 await pump(cipher, async (c) => {
327 size += c.length;
328 await sink.write(c);
329 });
330 if (head) mediaMime = await sniffMime(head);
331 } else {
332 // Plaintext: stream to disk, collecting the head for sniffing.
333 const headParts: Uint8Array[] = [];
334 let headLen = 0;
335 await pump(webIn, async (c) => {
336 size += c.length;
337 await sink.write(c);
338 if (headLen < SNIFF_BYTES) {
339 const slice = c.subarray(0, SNIFF_BYTES - headLen);
340 headParts.push(slice);
341 headLen += slice.length;
342 }
343 });
344 mediaMime = await sniffMime(Buffer.concat(headParts));
345 }
346
347 await sink.end();
348 if (limitExceeded) {
349 throw new UploadError(
350 413,
351 `File exceeds the maximum upload size of ${config.maxUploadBytes} bytes`,
352 );
353 }
354 return {
355 encrypted,
356 size,
357 mediaMime,
358 infoFilename: info.filename ?? "",
359 };
360 }
361
362 Readable.fromWeb(
363 request.body as unknown as import("node:stream/web").ReadableStream<Uint8Array>,
364 ).pipe(bb);
365 });
366
367 // Enforce the total-bytes cap against the in-memory counter. Concurrent
368 // uploads can transiently overshoot by up to (concurrency * per-file)
369 // before this check; acceptable at the expected scale.
370 if (
371 config.maxTotalBytes !== null &&
372 totalBytes + result.size > config.maxTotalBytes
373 ) {
374 await safeUnlink(path);
375 set.status = 507; // Insufficient Storage
376 return "Server storage is full, try again later";
377 }
378
379 db.exec(
380 "INSERT INTO files (uuid, filename, filetype, encrypted, size, media_mime, delete_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
381 [
382 uuid,
383 result.filename,
384 result.filetype,
385 result.encrypted,
386 result.size,
387 result.mediaMime,
388 result.deleteAt,
389 ],
390 );
391 totalBytes += result.size;
392 if (config.uploadCooldownSeconds > 0) lastUpload.set(ip, now);
393 set.status = StatusMap["See Other"];
394 set.headers.location = `/show/${uuid}`;
395 return `Created with id: ${uuid}`;
396 } catch (e) {
397 // Let any in-flight write finish so it can't recreate the blob after
398 // we unlink it below.
399 if (writing) await writing.catch(() => {});
400 await safeUnlink(path);
401 if (e instanceof UploadError) {
402 set.status = e.status;
403 return e.message;
404 }
405 throw e;
406 }
407 },
408 {
409 // Body parsing is handled manually from the raw stream, so disable
410 // Elysia's parser (which would otherwise buffer the whole upload).
411 parse: "none",
412 },
413 )
414 .get(
415 "/show/:uuid",
416 async ({ set, params, cookie, server, request, headers }) => {
417 const row =
418 (db
419 .prepare(
420 "SELECT filename, filetype, encrypted, size, media_mime, delete_at FROM files WHERE uuid = ?",
421 )
422 .get(params.uuid) as {
423 filename: string;
424 filetype: string;
425 encrypted: number;
426 size: number;
427 media_mime: string | null;
428 delete_at: number | null;
429 }) || null;
430 if (!row) {
431 set.status = StatusMap["Not Found"];
432 return NotFound();
433 }
434
435 const path = blobPath(params.uuid);
436 let preview: Preview;
437 let shownSize: number | null = row.size;
438
439 if (row.encrypted) {
440 const password = cookie.password.value;
441 if (!password) {
442 // No password yet: let the client-side flow handle decryption.
443 preview = { kind: "await" };
444 shownSize = null;
445 } else if (row.filetype === "blob") {
446 // Binary/media: don't decrypt here — preview points at /raw, which
447 // performs the single decryption for this view.
448 preview = row.media_mime
449 ? { kind: "media", mime: row.media_mime }
450 : { kind: "none" };
451 } else {
452 // Text: this is the one server-side decryption for the view.
453 const ip = clientIp(server, request, headers);
454 if (decryptBlocked(ip)) {
455 set.status = 429;
456 return "Decryption cooldown active, please wait and reload";
457 }
458 try {
459 const content = await readDecrypted(path, password, row.size);
460 recordDecrypt(ip);
461 const out = new Uint8Array(content);
462 shownSize = out.byteLength;
463 const text = textPreviewHtml(out, row.filetype);
464 preview =
465 text !== null ? { kind: "text", html: text } : { kind: "none" };
466 } catch (_e) {
467 recordDecrypt(ip);
468 const secure = config.behindProxy ? "; Secure" : "";
469 set.status = StatusMap.Forbidden;
470 set.headers["set-cookie"] = [
471 `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
472 `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
473 ];
474 return WrongPassword();
475 }
476 }
477 } else if (row.filetype !== "blob") {
478 // Plaintext text: read from disk and render inline.
479 const content = new Uint8Array(await Bun.file(path).bytes());
480 shownSize = content.byteLength;
481 const text = textPreviewHtml(content, row.filetype);
482 preview =
483 text !== null
484 ? { kind: "text", html: text }
485 : row.media_mime
486 ? { kind: "media", mime: row.media_mime }
487 : { kind: "none" };
488 } else {
489 // Plaintext binary/media: preview via /raw using the sniffed MIME.
490 preview = row.media_mime
491 ? { kind: "media", mime: row.media_mime }
492 : { kind: "none" };
493 }
494
495 return ShowFile({
496 filename: row.filename,
497 uuid: params.uuid,
498 filetype: row.filetype,
499 deleteAt: row.delete_at,
500 size: shownSize,
501 preview,
502 });
503 },
504 {
505 params: t.Object({
506 uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
507 }),
508 cookie: t.Object({ password: t.Optional(t.String()) }),
509 },
510 )
511 .post(
512 "/set-cookie/:uuid",
513 ({ set, body, params }) => {
514 // encodeURIComponent keeps ';', CR/LF and other separators out of the
515 // cookie value; Elysia URL-decodes the value again when it reads it back.
516 const value = encodeURIComponent(body.password);
517 const secure = config.behindProxy ? "; Secure" : "";
518 set.headers["set-cookie"] = [
519 `password=${value}; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
520 `password=${value}; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}`,
521 ];
522 set.headers.location = `/show/${params.uuid}`;
523 set.status = StatusMap["See Other"];
524 return SetCookie(params.uuid);
525 },
526 {
527 body: t.Object({
528 password: t.String(),
529 }),
530 params: t.Object({
531 uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
532 }),
533 },
534 )
535 .get(
536 "/raw/:uuid",
537 async ({ set, params, cookie, query, server, request, headers }) => {
538 const row =
539 (db
540 .prepare(
541 "SELECT filename, encrypted, filetype, size FROM files WHERE uuid = ?",
542 )
543 .get(params.uuid) as {
544 filename: string;
545 encrypted: number;
546 filetype: string;
547 size: number;
548 }) || null;
549 if (!row) {
550 set.status = StatusMap["Not Found"];
551 return "File not found";
552 }
553
554 const path = blobPath(params.uuid);
555 const servingEncrypted =
556 row.encrypted && query.ignore_password === "true";
557
558 let body: ReadableStream<Uint8Array> | ReturnType<typeof Bun.file>;
559 let sniff: Uint8Array | null = null;
560
561 if (row.encrypted && !servingEncrypted) {
562 const password = cookie.password.value;
563 if (!password) {
564 set.status = StatusMap.Unauthorized;
565 return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
566 }
567 const ip = clientIp(server, request, headers);
568 if (decryptBlocked(ip)) {
569 set.status = 429;
570 return "Decryption cooldown active, please wait and retry";
571 }
572 try {
573 const { firstChunk, stream } = await openDecrypted(
574 path,
575 password,
576 row.size,
577 );
578 recordDecrypt(ip);
579 body = stream;
580 sniff = firstChunk.subarray(0, SNIFF_BYTES);
581 } catch (_e) {
582 recordDecrypt(ip);
583 set.status = StatusMap.Forbidden;
584 return "Incorrect password";
585 }
586 } else {
587 // Serve the file as-is: plaintext, or (with ignore_password) the still
588 // encrypted bytes. BunFile streams and supports range requests.
589 body = Bun.file(path);
590 if (!servingEncrypted) {
591 sniff = new Uint8Array(
592 await Bun.file(path).slice(0, SNIFF_BYTES).arrayBuffer(),
593 );
594 }
595 }
596
597 // Never let the browser sniff stored content into an executable type
598 // (e.g. HTML/SVG running as same-origin script). Only whitelisted,
599 // non-scriptable media is served inline with its real type; everything
600 // else (incl. still-encrypted bytes) is an octet-stream attachment.
601 let mime = "application/octet-stream";
602 let disposition = "attachment";
603 if (sniff) {
604 const detected = await fileTypeFromBuffer(sniff);
605 if (
606 detected &&
607 detected.mime !== "image/svg+xml" &&
608 (detected.mime.startsWith("image/") ||
609 detected.mime.startsWith("audio/") ||
610 detected.mime.startsWith("video/"))
611 ) {
612 mime = detected.mime;
613 disposition = "inline";
614 }
615 }
616
617 const safeName = encodeURIComponent(row.filename);
618 set.headers["x-content-type-options"] = "nosniff";
619 set.headers["content-type"] = mime;
620 set.headers.encrypted = row.encrypted ? "true" : "false";
621 set.headers.filetype = row.filetype;
622 set.headers.filename = safeName;
623 set.headers["content-disposition"] =
624 `${disposition}; filename*=UTF-8''${safeName}`;
625 return body;
626 },
627 {
628 params: t.Object({
629 uuid: t.String({ format: "regex", pattern: UUID_PATTERN }),
630 }),
631 cookie: t.Object({ password: t.Optional(t.String()) }),
632 query: t.Object({ ignore_password: t.Optional(t.String()) }),
633 },
634 )
635 .listen(3000);
636
637// --- server-side decryption cooldown ----------------------------------------
638
639function decryptBlocked(ip: string): boolean {
640 if (config.decryptCooldownSeconds <= 0) return false;
641 const last = lastDecrypt.get(ip) ?? 0;
642 return Date.now() - last < config.decryptCooldownSeconds * 1000;
643}
644function recordDecrypt(ip: string): void {
645 if (config.decryptCooldownSeconds > 0) lastDecrypt.set(ip, Date.now());
646}
647
648// Fully decrypt an on-disk blob to memory (used for text previews in /show).
649async function readDecrypted(
650 path: string,
651 password: string,
652 size: number,
653): Promise<Uint8Array> {
654 const { stream } = await openDecrypted(path, password, size);
655 const parts: Uint8Array[] = [];
656 await pump(stream, (c) => {
657 parts.push(c);
658 });
659 return Buffer.concat(parts);
660}
661
662// Open an on-disk blob for streaming decryption, exposing the first plaintext
663// chunk (for MIME sniffing) and the full plaintext stream from one derivation.
664async function openDecrypted(
665 path: string,
666 password: string,
667 size: number,
668): Promise<{ firstChunk: Uint8Array; stream: ReadableStream<Uint8Array> }> {
669 const { firstChunk, body } = await decryptToStream(
670 Bun.file(path).stream(),
671 password,
672 size,
673 );
674 return { firstChunk, stream: body };
675}
676
677console.log(
678 `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
679);
680