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