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