various small improvements

AuthorKonata <konata@posteo.jp>
Date
Commitaece819c90b64799bdf60a9eb8ee81c54622c5f4
Parent0d1b4e0
7 files changed, 503 insertions(+), 62 deletions(-)
Massets/show.css
@@ -6,6 +6,22 @@ hr {
66 width: 100%;
77 }
88
9+#logo {
10+ z-index: 20;
11+ align-self: flex-start;
12+ padding: 0.4rem 1rem;
13+ color: inherit;
14+ font-weight: bold;
15+ /* Override sakura's link styling: normal (white) text, and no underline —
16+ which it draws as a border-bottom on hover, not text-decoration. */
17+ border-bottom: none;
18+
19+ &:hover {
20+ color: inherit;
21+ border-bottom: none;
22+ }
23+}
24+
925 body {
1026 box-sizing: border-box;
1127 width: 100%;
@@ -13,8 +29,16 @@ body {
1329 max-width: 100%;
1430 max-height: 100vh;
1531
32+ display: flex;
33+ flex-direction: column;
34+}
35+
36+#main {
1637 display: flex;
1738 flex-direction: row;
39+ flex-grow: 1;
40+ min-height: 0;
41+ width: 100%;
1842
1943 #content {
2044 box-sizing: border-box;
@@ -67,6 +91,26 @@ body {
6791 }
6892 }
6993
94+/* Portrait (taller than wide): stack the sidebar below the content instead of
95+ beside it. */
96+@media (orientation: portrait) {
97+ #main {
98+ flex-direction: column;
99+
100+ #content {
101+ /* The shared divider is now the bottom edge; restore the right border
102+ the row layout dropped and drop the bottom one. */
103+ border-right: 1px solid #40363a;
104+ border-bottom: unset;
105+ }
106+
107+ #sidebar {
108+ width: 100%;
109+ height: auto;
110+ }
111+ }
112+}
113+
70114 audio {
71115 max-width: 40rem;
72116 min-width: 0;
@@ -83,17 +127,39 @@ video {
83127 position: fixed;
84128 inset: 0;
85129 z-index: 10;
130+ box-sizing: border-box;
131+ padding: 1rem;
86132 display: flex;
87133 flex-direction: column;
88134 align-items: center;
89135 justify-content: center;
90136 background-color: #120c0e;
91137 color: #d9d8dc;
138+ text-align: center;
139+
140+ h1 {
141+ max-width: 100%;
142+ margin-top: 0;
143+ /* Shrink the heading on narrow screens and let a long filename wrap
144+ instead of overflowing. */
145+ font-size: clamp(1.5rem, 6vw, 2.5rem);
146+ overflow-wrap: anywhere;
147+ }
92148
93149 /* biome-ignore lint/style/noDescendingSpecificity: targets the overlay form only, disjoint from the #sidebar form rule */
94150 form {
95151 display: flex;
96152 flex-direction: column;
97153 align-items: center;
154+
155+ /* The radio group: left-align so the two buttons line up vertically
156+ instead of inheriting the overlay's centering. */
157+ div {
158+ text-align: left;
159+
160+ label {
161+ display: block;
162+ }
163+ }
98164 }
99165 }
Msrc/client-index.ts
@@ -55,7 +55,6 @@ async function uploadFile() {
5555
5656 fileInput.files = dataTransfer.files;
5757 encryptedInput.checked = true;
58- console.log("encryption done");
5958 form.submit();
6059 }
6160
Msrc/client-show.ts
@@ -90,6 +90,11 @@ async function decryptClientSide(
9090 ): Promise<boolean> {
9191 if (!encrypted) {
9292 const response = await fetch(`/raw/${uuid}?ignore_password=true`);
93+ if (!response.ok) {
94+ if (passwordLabel)
95+ passwordLabel.textContent = "Couldn't fetch the file, try reloading";
96+ return false;
97+ }
9398 encrypted = new Uint8Array(await response.arrayBuffer());
9499 filetype = response.headers.get("filetype") || "none";
95100 filename = response.headers.get("filename") || "";
Msrc/components.tsx
@@ -285,6 +285,7 @@ export type Preview =
285285 | { kind: "await" } // encrypted, needs client-side decryption (overlay + JS)
286286 | { kind: "text"; html: string } // pre-rendered (escaped/highlighted) text
287287 | { kind: "media"; mime: string } // <img>/<audio>/<video> pointing at /raw
288+ | { kind: "toolarge" } // previewable text, but too big to render inline
288289 | { kind: "none" }; // not previewable
289290
290291 export function ShowFile(opts: {
@@ -302,6 +303,8 @@ export function ShowFile(opts: {
302303 let previewEl: JSX.Element = <>This file can't be previewed</>;
303304 if (preview.kind === "await") {
304305 previewEl = <>Please wait for the file to load</>;
306+ } else if (preview.kind === "toolarge") {
307+ previewEl = <>File is too large to preview inline</>;
305308 } else if (preview.kind === "text") {
306309 // Already escaped/highlighted by textPreviewHtml, injected raw.
307310 previewEl = <pre>{preview.html}</pre>;
@@ -317,6 +320,11 @@ export function ShowFile(opts: {
317320
318321 return (
319322 <Template css="/show.css">
323+ {/* Sits above the decrypt overlay (see #logo z-index) so it stays a way
324+ back home even before the file is unlocked. */}
325+ <a id="logo" href="/">
326+ ⚡ZBin⚡
327+ </a>
320328 {awaiting ? (
321329 <div id="decrypt-overlay">
322330 <h1 safe>Encrypted file: {filename}</h1>
@@ -356,31 +364,33 @@ export function ShowFile(opts: {
356364 ) : (
357365 ""
358366 )}
359- <div id="content">
360- <div id="filename" safe>
361- {filename}
367+ <div id="main">
368+ <div id="content">
369+ <div id="filename" safe>
370+ {filename}
371+ </div>
372+ <div id="mediabox">{previewEl}</div>
362373 </div>
363- <div id="mediabox">{previewEl}</div>
364- </div>
365- <div id="sidebar">
366- <h3>File info</h3>
367- <hr />
368- <p id="filesize">
369- size: {size !== null ? humanFileSize(size) : "unknown"}
370- </p>
371- <p>declared type: {filetype}</p>
372- {deleteAt ? (
373- <p>
374- delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
375- {humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
376- )
374+ <div id="sidebar">
375+ <h3>File info</h3>
376+ <hr />
377+ <p id="filesize">
378+ size: {size !== null ? humanFileSize(size) : "unknown"}
377379 </p>
378- ) : (
379- ""
380- )}
381- <form id="download-form" action={rawUrl} method="get">
382- <button type="submit">Download</button>
383- </form>
380+ <p>declared type: {filetype}</p>
381+ {deleteAt ? (
382+ <p>
383+ delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
384+ {humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
385+ )
386+ </p>
387+ ) : (
388+ ""
389+ )}
390+ <form id="download-form" action={rawUrl} method="get">
391+ <button type="submit">Download</button>
392+ </form>
393+ </div>
384394 </div>
385395 {awaiting ? <script src="/dist/client-show.js" /> : ""}
386396 </Template>
Msrc/crypto.ts
@@ -219,6 +219,14 @@ class ByteStreamReader {
219219 }
220220 return true;
221221 }
222+
223+ // Cancels the source and releases the reader lock. Used when the consuming
224+ // stream is cancelled (e.g. a client aborts a download) so the underlying
225+ // file handle isn't held until GC.
226+ async cancel(): Promise<void> {
227+ this.#done = true;
228+ await this.#reader.cancel().catch(() => {});
229+ }
222230 }
223231
224232 // Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
@@ -274,6 +282,9 @@ export async function encryptStream(
274282 controller.close();
275283 }
276284 },
285+ cancel() {
286+ return reader.cancel();
287+ },
277288 });
278289 }
279290
@@ -322,6 +333,9 @@ export async function decryptToStream(
322333 if (chunk === null) controller.close();
323334 else controller.enqueue(chunk);
324335 },
336+ cancel() {
337+ return reader.cancel();
338+ },
325339 });
326340 return { firstChunk, body };
327341 }
Msrc/index.ts
@@ -22,15 +22,22 @@ import {
2222 import { config } from "./config";
2323 import { decryptToStream, encryptStream } from "./crypto";
2424
25-const BLOB_DIR = "./db/blobs";
25+// Paths default to ./db; overridable via env so tests can point at a throwaway
26+// directory instead of the real database/blobs.
27+const BLOB_DIR = Bun.env.ZBIN_BLOB_DIR ?? "./db/blobs";
2628 mkdirSync(BLOB_DIR, { recursive: true });
2729 const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`;
2830 const safeUnlink = (path: string) => unlink(path).catch(() => {});
2931
3032 const filetypeSet = new Set(filetypes);
3133 const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection
34+const 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.
38+const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
3239
33-const db = new Database("./db/db.sqlite");
40+const db = new Database(Bun.env.ZBIN_DB_PATH ?? "./db/db.sqlite");
3441 db.run("PRAGMA foreign_keys = ON");
3542 db.run("PRAGMA journal_mode = WAL");
3643 // Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata.
@@ -40,7 +47,10 @@ db.run("PRAGMA journal_mode = WAL");
4047 db.run(
4148 "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",
4249 );
43-db.run("PRAGMA optimize");
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.
52+db.run("CREATE INDEX IF NOT EXISTS idx_files_delete_at ON files(delete_at)");
53+db.run("PRAGMA optimize=0x10002");
4454
4555 // Running total of stored content bytes, initialized once from the DB and then
4656 // maintained in memory (incremented on upload, decremented when files expire).
@@ -109,6 +119,19 @@ async function sniffMime(bytes: Uint8Array): Promise<string | null> {
109119 return (await fileTypeFromBuffer(bytes))?.mime ?? null;
110120 }
111121
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.
126+function 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+
112135 const app = new Elysia({
113136 serve: {
114137 maxRequestBodySize: config.maxUploadBytes,
@@ -164,7 +187,7 @@ const app = new Elysia({
164187 name: "optimize",
165188 pattern: "0 0 * * * *",
166189 run() {
167- db.exec("PRAGMA optimize");
190+ db.exec("PRAGMA optimize ");
168191 },
169192 }),
170193 )
@@ -256,12 +279,20 @@ const app = new Elysia({
256279 if (!filetypeSet.has(filetype)) {
257280 throw new UploadError(400, "Invalid or missing filetype");
258281 }
259- const dim = fields.delete_in_minutes;
260- if (dim && !/^[0-9]+$/.test(dim)) {
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)) {
261290 throw new UploadError(400, "Invalid delete_in_minutes");
262291 }
263292 let minutes: number | null =
264- dim && Number(dim) > 0 ? Number(dim) : null;
293+ deleteInMinutes && Number(deleteInMinutes) > 0
294+ ? Number(deleteInMinutes)
295+ : null;
265296 if (config.maxAgeMinutes !== null) {
266297 minutes = Math.min(
267298 minutes ?? config.maxAgeMinutes,
@@ -433,6 +464,12 @@ const app = new Elysia({
433464 }
434465
435466 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+ }
436473 let preview: Preview;
437474 let shownSize: number | null = row.size;
438475
@@ -442,12 +479,17 @@ const app = new Elysia({
442479 // No password yet: let the client-side flow handle decryption.
443480 preview = { kind: "await" };
444481 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 };
445487 } 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" };
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" };
451493 } else {
452494 // Text: this is the one server-side decryption for the view.
453495 const ip = clientIp(server, request, headers);
@@ -474,22 +516,25 @@ const app = new Elysia({
474516 return WrongPassword();
475517 }
476518 }
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 };
477523 } 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" };
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+ }
488535 } 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" };
536+ // Plaintext binary that isn't inline media: not previewable.
537+ preview = { kind: "none" };
493538 }
494539
495540 return ShowFile({
@@ -552,6 +597,11 @@ const app = new Elysia({
552597 }
553598
554599 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+ }
555605 const servingEncrypted =
556606 row.encrypted && query.ignore_password === "true";
557607
@@ -602,13 +652,7 @@ const app = new Elysia({
602652 let disposition = "attachment";
603653 if (sniff) {
604654 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- ) {
655+ if (detected && isInlineMedia(detected.mime)) {
612656 mime = detected.mime;
613657 disposition = "inline";
614658 }
@@ -631,8 +675,7 @@ const app = new Elysia({
631675 cookie: t.Object({ password: t.Optional(t.String()) }),
632676 query: t.Object({ ignore_password: t.Optional(t.String()) }),
633677 },
634- )
635- .listen(3000);
678+ );
636679
637680 // --- server-side decryption cooldown ----------------------------------------
638681
@@ -674,6 +717,14 @@ async function openDecrypted(
674717 return { firstChunk, stream: body };
675718 }
676719
677-console.log(
678- `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
679-);
720+export { 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.
725+if (import.meta.main) {
726+ app.listen(3000);
727+ console.log(
728+ `⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
729+ );
730+}
Asrc/routes.test.ts
@@ -0,0 +1,296 @@
1+import { afterAll, describe, expect, test } from "bun:test";
2+import { randomUUID } from "node:crypto";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+
7+// Point the app at a throwaway DB + blob dir before importing it, so these
8+// integration tests never touch the real ./db. The dynamic import has to run
9+// after the env is set, hence the top-level await.
10+const TMP = mkdtempSync(join(tmpdir(), "zbin-test-"));
11+const BLOB_DIR = join(TMP, "blobs");
12+process.env.ZBIN_DB_PATH = join(TMP, "db.sqlite");
13+process.env.ZBIN_BLOB_DIR = BLOB_DIR;
14+
15+const { app } = await import("./index");
16+const { encrypt, decrypt } = await import("./crypto");
17+
18+afterAll(() => rmSync(TMP, { recursive: true, force: true }));
19+
20+const utf8 = new TextEncoder();
21+const text = new TextDecoder();
22+
23+// A 1x1 PNG header — enough magic for file-type to detect image/png.
24+const PNG = new Uint8Array([
25+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
26+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
27+ 0x08, 0x06, 0x00, 0x00, 0x00,
28+]);
29+
30+function upload(
31+ fields: Record<string, string>,
32+ fileName: string,
33+ bytes: Uint8Array | string,
34+): Promise<Response> {
35+ const fd = new FormData();
36+ // Non-file fields first; the "file" part must come LAST (the server relies on
37+ // the other fields being known before the bytes stream in).
38+ for (const [k, v] of Object.entries(fields)) fd.append(k, v);
39+ fd.append("file", new Blob([bytes as BlobPart]), fileName);
40+ return app.handle(
41+ new Request("http://localhost/upload", { method: "POST", body: fd }),
42+ );
43+}
44+
45+async function uploadId(
46+ fields: Record<string, string>,
47+ fileName: string,
48+ bytes: Uint8Array | string,
49+): Promise<string> {
50+ const res = await upload(fields, fileName, bytes);
51+ expect(res.status).toBe(303);
52+ const uuid = (res.headers.get("location") ?? "").replace("/show/", "");
53+ expect(uuid).toMatch(/^[0-9a-fA-F-]{36}$/);
54+ return uuid;
55+}
56+
57+function get(path: string, cookie?: string): Promise<Response> {
58+ const headers: Record<string, string> = {};
59+ if (cookie) headers.cookie = cookie;
60+ return app.handle(new Request(`http://localhost${path}`, { headers }));
61+}
62+
63+describe("home page", () => {
64+ test("GET / serves the upload form", async () => {
65+ const res = await get("/");
66+ expect(res.status).toBe(200);
67+ const body = await res.text();
68+ expect(body).toContain('id="uploadForm"');
69+ expect(body).toContain("ZBin");
70+ });
71+});
72+
73+describe("plaintext round-trip", () => {
74+ test("upload → show renders the content, raw returns the exact bytes", async () => {
75+ const uuid = await uploadId({ filetype: "none" }, "hello.txt", "hello world");
76+
77+ const show = await get(`/show/${uuid}`);
78+ expect(show.status).toBe(200);
79+ const showBody = await show.text();
80+ expect(showBody).toContain("hello world");
81+ expect(showBody).toContain("hello.txt");
82+
83+ const raw = await get(`/raw/${uuid}`);
84+ expect(raw.status).toBe(200);
85+ expect(await raw.text()).toBe("hello world");
86+ // Non-media plaintext is served as a defensive attachment, never inline.
87+ expect(raw.headers.get("encrypted")).toBe("false");
88+ expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
89+ expect(raw.headers.get("content-type")).toBe("application/octet-stream");
90+ expect(raw.headers.get("content-disposition")).toContain("attachment");
91+ });
92+
93+ test("delete_in_minutes is reflected on the show page", async () => {
94+ const uuid = await uploadId(
95+ { filetype: "none", delete_in_minutes: "60" },
96+ "t.txt",
97+ "bye",
98+ );
99+ expect(await (await get(`/show/${uuid}`)).text()).toContain("delete at");
100+ });
101+});
102+
103+describe("media handling (content-type safety)", () => {
104+ test("a sniffed image is served inline with its real type", async () => {
105+ const uuid = await uploadId({ filetype: "blob" }, "pixel.png", PNG);
106+
107+ const raw = await get(`/raw/${uuid}`);
108+ expect(raw.headers.get("content-type")).toBe("image/png");
109+ expect(raw.headers.get("content-disposition")).toContain("inline");
110+ expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
111+
112+ const show = await get(`/show/${uuid}`);
113+ const body = await show.text();
114+ expect(body).toContain("<img");
115+ expect(body).toContain(`/raw/${uuid}`);
116+ });
117+
118+ test("oversized media (non-blob filetype) still previews, not 'too large'", async () => {
119+ // Default filetype is "none", not "blob"; a big media file must still be
120+ // previewed via /raw (which streams) rather than hitting the text cap.
121+ const bigImage = new Uint8Array(1024 * 1024 + 100);
122+ bigImage.set(PNG, 0);
123+ const uuid = await uploadId({ filetype: "none" }, "big.png", bigImage);
124+
125+ const body = await (await get(`/show/${uuid}`)).text();
126+ expect(body).toContain("<img");
127+ expect(body).not.toContain("too large to preview");
128+ });
129+});
130+
131+describe("server-side encryption", () => {
132+ const PW = "hunter2";
133+
134+ test("show: overlay without a password, content with the right one, 403 with a wrong one", async () => {
135+ const uuid = await uploadId(
136+ { filetype: "none", password: PW },
137+ "secret.txt",
138+ "top secret",
139+ );
140+
141+ const noCookie = await get(`/show/${uuid}`);
142+ expect(noCookie.status).toBe(200);
143+ expect(await noCookie.text()).toContain("decrypt-overlay");
144+
145+ const right = await get(`/show/${uuid}`, `password=${PW}`);
146+ expect(right.status).toBe(200);
147+ expect(await right.text()).toContain("top secret");
148+
149+ const wrong = await get(`/show/${uuid}`, "password=nope");
150+ expect(wrong.status).toBe(403);
151+ expect(await wrong.text()).toContain("Incorrect password");
152+ });
153+
154+ test("raw: 401 without a password, plaintext with the right one", async () => {
155+ const uuid = await uploadId(
156+ { filetype: "none", password: PW },
157+ "secret.txt",
158+ "top secret",
159+ );
160+
161+ expect((await get(`/raw/${uuid}`)).status).toBe(401);
162+
163+ const raw = await get(`/raw/${uuid}`, `password=${PW}`);
164+ expect(raw.status).toBe(200);
165+ expect(raw.headers.get("encrypted")).toBe("true");
166+ expect(await raw.text()).toBe("top secret");
167+ });
168+
169+ test("raw ?ignore_password serves the encrypted bytes, decryptable client-side", async () => {
170+ const uuid = await uploadId(
171+ { filetype: "none", password: PW },
172+ "secret.txt",
173+ "top secret",
174+ );
175+
176+ const raw = await get(`/raw/${uuid}?ignore_password=true`);
177+ expect(raw.status).toBe(200);
178+ expect(raw.headers.get("encrypted")).toBe("true");
179+ const cipher = new Uint8Array(await raw.arrayBuffer());
180+ expect(text.decode(cipher)).not.toBe("top secret");
181+ // The on-disk format must match the shared crypto module byte-for-byte.
182+ expect(text.decode(await decrypt(cipher, PW))).toBe("top secret");
183+ });
184+});
185+
186+describe("already-encrypted (client-side) uploads", () => {
187+ test("opaque bytes are stored as-is and round-trip", async () => {
188+ const cipher = await encrypt(utf8.encode("client side"), "pw");
189+ const uuid = await uploadId(
190+ { filetype: "none", encrypted: "on" },
191+ "blob.bin",
192+ cipher,
193+ );
194+
195+ // No password is known to the server, so /show defers to the client flow.
196+ expect(await (await get(`/show/${uuid}`)).text()).toContain("decrypt-overlay");
197+
198+ const raw = await get(`/raw/${uuid}?ignore_password=true`);
199+ const stored = new Uint8Array(await raw.arrayBuffer());
200+ expect(stored).toEqual(new Uint8Array(cipher));
201+ expect(text.decode(await decrypt(stored, "pw"))).toBe("client side");
202+ });
203+});
204+
205+describe("large text preview cap", () => {
206+ test("oversized text isn't rendered inline but still downloads in full", async () => {
207+ const big = "a".repeat(1024 * 1024 + 100); // just over MAX_TEXT_PREVIEW_BYTES
208+ const uuid = await uploadId({ filetype: "none" }, "big.txt", big);
209+
210+ const show = await get(`/show/${uuid}`);
211+ expect(await show.text()).toContain("too large to preview");
212+
213+ const raw = await get(`/raw/${uuid}`);
214+ expect((await raw.arrayBuffer()).byteLength).toBe(big.length);
215+ });
216+});
217+
218+describe("upload validation", () => {
219+ test("rejects a request with no file part", async () => {
220+ const fd = new FormData();
221+ fd.append("filetype", "none");
222+ const res = await app.handle(
223+ new Request("http://localhost/upload", { method: "POST", body: fd }),
224+ );
225+ expect(res.status).toBe(400);
226+ expect(await res.text()).toContain("No file");
227+ });
228+
229+ test("rejects a form field after the file part", async () => {
230+ const fd = new FormData();
231+ fd.append("filetype", "none");
232+ fd.append("file", new Blob(["x"]), "f.txt");
233+ fd.append("late", "boom"); // arrives after the file → must be rejected
234+ const res = await app.handle(
235+ new Request("http://localhost/upload", { method: "POST", body: fd }),
236+ );
237+ expect(res.status).toBe(400);
238+ expect(await res.text()).toContain("last form field");
239+ });
240+
241+ test("rejects an unknown filetype", async () => {
242+ const res = await upload({ filetype: "not-a-language" }, "f.txt", "x");
243+ expect(res.status).toBe(400);
244+ expect(await res.text()).toContain("filetype");
245+ });
246+
247+ test("rejects an over-long filename", async () => {
248+ const res = await upload(
249+ { filetype: "none", filename: "x".repeat(256) },
250+ "f.txt",
251+ "x",
252+ );
253+ expect(res.status).toBe(400);
254+ expect(await res.text()).toContain("Filename too long");
255+ });
256+
257+ test("rejects a non-numeric delete_in_minutes", async () => {
258+ const res = await upload(
259+ { filetype: "none", delete_in_minutes: "soon" },
260+ "f.txt",
261+ "x",
262+ );
263+ expect(res.status).toBe(400);
264+ expect(await res.text()).toContain("delete_in_minutes");
265+ });
266+
267+ test("rejects a non-multipart body", async () => {
268+ const res = await app.handle(
269+ new Request("http://localhost/upload", {
270+ method: "POST",
271+ headers: { "content-type": "application/json" },
272+ body: "{}",
273+ }),
274+ );
275+ expect(res.status).toBe(400);
276+ });
277+});
278+
279+describe("lookup failures", () => {
280+ test("unknown uuid → 404 on show and raw", async () => {
281+ const missing = randomUUID();
282+ expect((await get(`/show/${missing}`)).status).toBe(404);
283+ expect((await get(`/raw/${missing}`)).status).toBe(404);
284+ });
285+
286+ test("malformed uuid is rejected by validation", async () => {
287+ expect((await get("/show/not-a-uuid")).status).toBe(422);
288+ });
289+
290+ test("a row whose blob is gone → 404 instead of a 500", async () => {
291+ const uuid = await uploadId({ filetype: "none" }, "f.txt", "data");
292+ rmSync(join(BLOB_DIR, uuid)); // delete the blob out from under the row
293+ expect((await get(`/show/${uuid}`)).status).toBe(404);
294+ expect((await get(`/raw/${uuid}`)).status).toBe(404);
295+ });
296+});