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