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 {
width: 100%;
}
#logo {
z-index: 20;
align-self: flex-start;
padding: 0.4rem 1rem;
color: inherit;
font-weight: bold;
/* Override sakura's link styling: normal (white) text, and no underline —
which it draws as a border-bottom on hover, not text-decoration. */
border-bottom: none;
&:hover {
color: inherit;
border-bottom: none;
}
}
body {
box-sizing: border-box;
width: 100%;
@@ -13,8 +29,16 @@ body {
max-width: 100%;
max-height: 100vh;
display: flex;
flex-direction: column;
}
#main {
display: flex;
flex-direction: row;
flex-grow: 1;
min-height: 0;
width: 100%;
#content {
box-sizing: border-box;
@@ -67,6 +91,26 @@ body {
}
}
/* Portrait (taller than wide): stack the sidebar below the content instead of
beside it. */
@media (orientation: portrait) {
#main {
flex-direction: column;
#content {
/* The shared divider is now the bottom edge; restore the right border
the row layout dropped and drop the bottom one. */
border-right: 1px solid #40363a;
border-bottom: unset;
}
#sidebar {
width: 100%;
height: auto;
}
}
}
audio {
max-width: 40rem;
min-width: 0;
@@ -83,17 +127,39 @@ video {
position: fixed;
inset: 0;
z-index: 10;
box-sizing: border-box;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #120c0e;
color: #d9d8dc;
text-align: center;
h1 {
max-width: 100%;
margin-top: 0;
/* Shrink the heading on narrow screens and let a long filename wrap
instead of overflowing. */
font-size: clamp(1.5rem, 6vw, 2.5rem);
overflow-wrap: anywhere;
}
/* biome-ignore lint/style/noDescendingSpecificity: targets the overlay form only, disjoint from the #sidebar form rule */
form {
display: flex;
flex-direction: column;
align-items: center;
/* The radio group: left-align so the two buttons line up vertically
instead of inheriting the overlay's centering. */
div {
text-align: left;
label {
display: block;
}
}
}
}
▾Msrc/client-index.ts
@@ -55,7 +55,6 @@ async function uploadFile() {
fileInput.files = dataTransfer.files;
encryptedInput.checked = true;
console.log("encryption done");
form.submit();
}
▾Msrc/client-show.ts
@@ -90,6 +90,11 @@ async function decryptClientSide(
): Promise<boolean> {
if (!encrypted) {
const response = await fetch(`/raw/${uuid}?ignore_password=true`);
if (!response.ok) {
if (passwordLabel)
passwordLabel.textContent = "Couldn't fetch the file, try reloading";
return false;
}
encrypted = new Uint8Array(await response.arrayBuffer());
filetype = response.headers.get("filetype") || "none";
filename = response.headers.get("filename") || "";
▾Msrc/components.tsx
@@ -285,6 +285,7 @@ export type Preview =
| { kind: "await" } // encrypted, needs client-side decryption (overlay + JS)
| { kind: "text"; html: string } // pre-rendered (escaped/highlighted) text
| { kind: "media"; mime: string } // <img>/<audio>/<video> pointing at /raw
| { kind: "toolarge" } // previewable text, but too big to render inline
| { kind: "none" }; // not previewable
export function ShowFile(opts: {
@@ -302,6 +303,8 @@ export function ShowFile(opts: {
let previewEl: JSX.Element = <>This file can't be previewed</>;
if (preview.kind === "await") {
previewEl = <>Please wait for the file to load</>;
} else if (preview.kind === "toolarge") {
previewEl = <>File is too large to preview inline</>;
} else if (preview.kind === "text") {
// Already escaped/highlighted by textPreviewHtml, injected raw.
previewEl = <pre>{preview.html}</pre>;
@@ -317,6 +320,11 @@ export function ShowFile(opts: {
return (
<Template css="/show.css">
{/* Sits above the decrypt overlay (see #logo z-index) so it stays a way
back home even before the file is unlocked. */}
<a id="logo" href="/">
⚡ZBin⚡
</a>
{awaiting ? (
<div id="decrypt-overlay">
<h1 safe>Encrypted file: {filename}</h1>
@@ -356,31 +364,33 @@ export function ShowFile(opts: {
) : (
""
)}
<div id="content">
<div id="filename" safe>
{filename}
<div id="main">
<div id="content">
<div id="filename" safe>
{filename}
</div>
<div id="mediabox">{previewEl}</div>
</div>
<div id="mediabox">{previewEl}</div>
</div>
<div id="sidebar">
<h3>File info</h3>
<hr />
<p id="filesize">
size: {size !== null ? humanFileSize(size) : "unknown"}
</p>
<p>declared type: {filetype}</p>
{deleteAt ? (
<p>
delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
{humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
)
<div id="sidebar">
<h3>File info</h3>
<hr />
<p id="filesize">
size: {size !== null ? humanFileSize(size) : "unknown"}
</p>
) : (
""
)}
<form id="download-form" action={rawUrl} method="get">
<button type="submit">Download</button>
</form>
<p>declared type: {filetype}</p>
{deleteAt ? (
<p>
delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
{humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
)
</p>
) : (
""
)}
<form id="download-form" action={rawUrl} method="get">
<button type="submit">Download</button>
</form>
</div>
</div>
{awaiting ? <script src="/dist/client-show.js" /> : ""}
</Template>
▾Msrc/crypto.ts
@@ -219,6 +219,14 @@ class ByteStreamReader {
}
return true;
}
// Cancels the source and releases the reader lock. Used when the consuming
// stream is cancelled (e.g. a client aborts a download) so the underlying
// file handle isn't held until GC.
async cancel(): Promise<void> {
this.#done = true;
await this.#reader.cancel().catch(() => {});
}
}
// Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
@@ -274,6 +282,9 @@ export async function encryptStream(
controller.close();
}
},
cancel() {
return reader.cancel();
},
});
}
@@ -322,6 +333,9 @@ export async function decryptToStream(
if (chunk === null) controller.close();
else controller.enqueue(chunk);
},
cancel() {
return reader.cancel();
},
});
return { firstChunk, body };
}
▾Msrc/index.ts
@@ -22,15 +22,22 @@ import {
import { config } from "./config";
import { decryptToStream, encryptStream } from "./crypto";
const BLOB_DIR = "./db/blobs";
// Paths default to ./db; overridable via env so tests can point at a throwaway
// directory instead of the real database/blobs.
const BLOB_DIR = Bun.env.ZBIN_BLOB_DIR ?? "./db/blobs";
mkdirSync(BLOB_DIR, { recursive: true });
const blobPath = (uuid: string) => `${BLOB_DIR}/${uuid}`;
const safeUnlink = (path: string) => unlink(path).catch(() => {});
const filetypeSet = new Set(filetypes);
const SNIFF_BYTES = 4100; // enough for file-type's magic-number detection
const MAX_FILENAME_LEN = 255; // cap on a user-supplied filename (bytes/chars)
// Above this on-disk size we don't read+highlight a text file inline (it would
// buffer the whole file, and its HTML is larger still); /show offers download
// instead. /raw still streams the full file regardless.
const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
const db = new Database("./db/db.sqlite");
const db = new Database(Bun.env.ZBIN_DB_PATH ?? "./db/db.sqlite");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA journal_mode = WAL");
// Content is stored on disk at ./db/blobs/<uuid>; the row keeps only metadata.
@@ -40,7 +47,10 @@ db.run("PRAGMA journal_mode = WAL");
db.run(
"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",
);
db.run("PRAGMA optimize");
// The expiry cron scans by delete_at every few seconds; index it so that stays
// a range lookup instead of a full table scan as the table grows.
db.run("CREATE INDEX IF NOT EXISTS idx_files_delete_at ON files(delete_at)");
db.run("PRAGMA optimize=0x10002");
// Running total of stored content bytes, initialized once from the DB and then
// maintained in memory (incremented on upload, decremented when files expire).
@@ -109,6 +119,19 @@ async function sniffMime(bytes: Uint8Array): Promise<string | null> {
return (await fileTypeFromBuffer(bytes))?.mime ?? null;
}
// Media we preview inline via <img>/<audio>/<video> pointing at /raw. These
// stream from /raw, so /show never reads them into memory and the text-preview
// size cap doesn't apply. SVG is excluded (script-capable; /raw refuses to
// serve it inline) so it falls back to being shown as escaped text instead.
function isInlineMedia(mime: string): boolean {
return (
mime !== "image/svg+xml" &&
(mime.startsWith("image/") ||
mime.startsWith("audio/") ||
mime.startsWith("video/"))
);
}
const app = new Elysia({
serve: {
maxRequestBodySize: config.maxUploadBytes,
@@ -164,7 +187,7 @@ const app = new Elysia({
name: "optimize",
pattern: "0 0 * * * *",
run() {
db.exec("PRAGMA optimize");
db.exec("PRAGMA optimize ");
},
}),
)
@@ -256,12 +279,20 @@ const app = new Elysia({
if (!filetypeSet.has(filetype)) {
throw new UploadError(400, "Invalid or missing filetype");
}
const dim = fields.delete_in_minutes;
if (dim && !/^[0-9]+$/.test(dim)) {
if (
fields.filename &&
fields.filename.length > MAX_FILENAME_LEN
) {
throw new UploadError(400, "Filename too long");
}
const deleteInMinutes = fields.delete_in_minutes;
if (deleteInMinutes && !/^[0-9]+$/.test(deleteInMinutes)) {
throw new UploadError(400, "Invalid delete_in_minutes");
}
let minutes: number | null =
dim && Number(dim) > 0 ? Number(dim) : null;
deleteInMinutes && Number(deleteInMinutes) > 0
? Number(deleteInMinutes)
: null;
if (config.maxAgeMinutes !== null) {
minutes = Math.min(
minutes ?? config.maxAgeMinutes,
@@ -433,6 +464,12 @@ const app = new Elysia({
}
const path = blobPath(params.uuid);
if (!(await Bun.file(path).exists())) {
// Row without its blob (e.g. the file was removed out of band): treat
// as not found rather than failing later while reading/streaming it.
set.status = StatusMap["Not Found"];
return NotFound();
}
let preview: Preview;
let shownSize: number | null = row.size;
@@ -442,12 +479,17 @@ const app = new Elysia({
// No password yet: let the client-side flow handle decryption.
preview = { kind: "await" };
shownSize = null;
} else if (row.media_mime && isInlineMedia(row.media_mime)) {
// Inline media: preview points at /raw, which performs the single
// decryption while streaming — nothing is read here, so the
// text-size cap below doesn't apply.
preview = { kind: "media", mime: row.media_mime };
} else if (row.filetype === "blob") {
// Binary/media: don't decrypt here — preview points at /raw, which
// performs the single decryption for this view.
preview = row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
// Binary that isn't inline media: not previewable.
preview = { kind: "none" };
} else if (row.size > MAX_TEXT_PREVIEW_BYTES) {
// Too large to decrypt + highlight inline; offer download instead.
preview = { kind: "toolarge" };
} else {
// Text: this is the one server-side decryption for the view.
const ip = clientIp(server, request, headers);
@@ -474,22 +516,25 @@ const app = new Elysia({
return WrongPassword();
}
}
} else if (row.media_mime && isInlineMedia(row.media_mime)) {
// Inline media: previews via /raw, so it's never read here and the
// text-size cap doesn't apply.
preview = { kind: "media", mime: row.media_mime };
} else if (row.filetype !== "blob") {
// Plaintext text: read from disk and render inline.
const content = new Uint8Array(await Bun.file(path).bytes());
shownSize = content.byteLength;
const text = textPreviewHtml(content, row.filetype);
preview =
text !== null
? { kind: "text", html: text }
: row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
if (row.size > MAX_TEXT_PREVIEW_BYTES) {
// Too large to read + highlight inline; offer download instead.
preview = { kind: "toolarge" };
} else {
// Plaintext text: read from disk and render inline.
const content = new Uint8Array(await Bun.file(path).bytes());
shownSize = content.byteLength;
const text = textPreviewHtml(content, row.filetype);
preview =
text !== null ? { kind: "text", html: text } : { kind: "none" };
}
} else {
// Plaintext binary/media: preview via /raw using the sniffed MIME.
preview = row.media_mime
? { kind: "media", mime: row.media_mime }
: { kind: "none" };
// Plaintext binary that isn't inline media: not previewable.
preview = { kind: "none" };
}
return ShowFile({
@@ -552,6 +597,11 @@ const app = new Elysia({
}
const path = blobPath(params.uuid);
if (!(await Bun.file(path).exists())) {
// Row without its blob: 404 instead of failing mid-stream / mid-decrypt.
set.status = StatusMap["Not Found"];
return "File not found";
}
const servingEncrypted =
row.encrypted && query.ignore_password === "true";
@@ -602,13 +652,7 @@ const app = new Elysia({
let disposition = "attachment";
if (sniff) {
const detected = await fileTypeFromBuffer(sniff);
if (
detected &&
detected.mime !== "image/svg+xml" &&
(detected.mime.startsWith("image/") ||
detected.mime.startsWith("audio/") ||
detected.mime.startsWith("video/"))
) {
if (detected && isInlineMedia(detected.mime)) {
mime = detected.mime;
disposition = "inline";
}
@@ -631,8 +675,7 @@ const app = new Elysia({
cookie: t.Object({ password: t.Optional(t.String()) }),
query: t.Object({ ignore_password: t.Optional(t.String()) }),
},
)
.listen(3000);
);
// --- server-side decryption cooldown ----------------------------------------
@@ -674,6 +717,14 @@ async function openDecrypted(
return { firstChunk, stream: body };
}
console.log(
`⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
);
export { app };
// Only bind a port (and log) when run directly with `bun src/index.ts`. Under
// `bun test` the module is imported and exercised via app.handle() instead, so
// it must not occupy port 3000.
if (import.meta.main) {
app.listen(3000);
console.log(
`⚡ ZBin is running at ${app.server?.hostname}:${app.server?.port} ⚡`,
);
}
▾Asrc/routes.test.ts
@@ -0,0 +1,296 @@
import { afterAll, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the app at a throwaway DB + blob dir before importing it, so these
// integration tests never touch the real ./db. The dynamic import has to run
// after the env is set, hence the top-level await.
const TMP = mkdtempSync(join(tmpdir(), "zbin-test-"));
const BLOB_DIR = join(TMP, "blobs");
process.env.ZBIN_DB_PATH = join(TMP, "db.sqlite");
process.env.ZBIN_BLOB_DIR = BLOB_DIR;
const { app } = await import("./index");
const { encrypt, decrypt } = await import("./crypto");
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
const utf8 = new TextEncoder();
const text = new TextDecoder();
// A 1x1 PNG header — enough magic for file-type to detect image/png.
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00,
]);
function upload(
fields: Record<string, string>,
fileName: string,
bytes: Uint8Array | string,
): Promise<Response> {
const fd = new FormData();
// Non-file fields first; the "file" part must come LAST (the server relies on
// the other fields being known before the bytes stream in).
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
fd.append("file", new Blob([bytes as BlobPart]), fileName);
return app.handle(
new Request("http://localhost/upload", { method: "POST", body: fd }),
);
}
async function uploadId(
fields: Record<string, string>,
fileName: string,
bytes: Uint8Array | string,
): Promise<string> {
const res = await upload(fields, fileName, bytes);
expect(res.status).toBe(303);
const uuid = (res.headers.get("location") ?? "").replace("/show/", "");
expect(uuid).toMatch(/^[0-9a-fA-F-]{36}$/);
return uuid;
}
function get(path: string, cookie?: string): Promise<Response> {
const headers: Record<string, string> = {};
if (cookie) headers.cookie = cookie;
return app.handle(new Request(`http://localhost${path}`, { headers }));
}
describe("home page", () => {
test("GET / serves the upload form", async () => {
const res = await get("/");
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('id="uploadForm"');
expect(body).toContain("ZBin");
});
});
describe("plaintext round-trip", () => {
test("upload → show renders the content, raw returns the exact bytes", async () => {
const uuid = await uploadId({ filetype: "none" }, "hello.txt", "hello world");
const show = await get(`/show/${uuid}`);
expect(show.status).toBe(200);
const showBody = await show.text();
expect(showBody).toContain("hello world");
expect(showBody).toContain("hello.txt");
const raw = await get(`/raw/${uuid}`);
expect(raw.status).toBe(200);
expect(await raw.text()).toBe("hello world");
// Non-media plaintext is served as a defensive attachment, never inline.
expect(raw.headers.get("encrypted")).toBe("false");
expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
expect(raw.headers.get("content-type")).toBe("application/octet-stream");
expect(raw.headers.get("content-disposition")).toContain("attachment");
});
test("delete_in_minutes is reflected on the show page", async () => {
const uuid = await uploadId(
{ filetype: "none", delete_in_minutes: "60" },
"t.txt",
"bye",
);
expect(await (await get(`/show/${uuid}`)).text()).toContain("delete at");
});
});
describe("media handling (content-type safety)", () => {
test("a sniffed image is served inline with its real type", async () => {
const uuid = await uploadId({ filetype: "blob" }, "pixel.png", PNG);
const raw = await get(`/raw/${uuid}`);
expect(raw.headers.get("content-type")).toBe("image/png");
expect(raw.headers.get("content-disposition")).toContain("inline");
expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
const show = await get(`/show/${uuid}`);
const body = await show.text();
expect(body).toContain("<img");
expect(body).toContain(`/raw/${uuid}`);
});
test("oversized media (non-blob filetype) still previews, not 'too large'", async () => {
// Default filetype is "none", not "blob"; a big media file must still be
// previewed via /raw (which streams) rather than hitting the text cap.
const bigImage = new Uint8Array(1024 * 1024 + 100);
bigImage.set(PNG, 0);
const uuid = await uploadId({ filetype: "none" }, "big.png", bigImage);
const body = await (await get(`/show/${uuid}`)).text();
expect(body).toContain("<img");
expect(body).not.toContain("too large to preview");
});
});
describe("server-side encryption", () => {
const PW = "hunter2";
test("show: overlay without a password, content with the right one, 403 with a wrong one", async () => {
const uuid = await uploadId(
{ filetype: "none", password: PW },
"secret.txt",
"top secret",
);
const noCookie = await get(`/show/${uuid}`);
expect(noCookie.status).toBe(200);
expect(await noCookie.text()).toContain("decrypt-overlay");
const right = await get(`/show/${uuid}`, `password=${PW}`);
expect(right.status).toBe(200);
expect(await right.text()).toContain("top secret");
const wrong = await get(`/show/${uuid}`, "password=nope");
expect(wrong.status).toBe(403);
expect(await wrong.text()).toContain("Incorrect password");
});
test("raw: 401 without a password, plaintext with the right one", async () => {
const uuid = await uploadId(
{ filetype: "none", password: PW },
"secret.txt",
"top secret",
);
expect((await get(`/raw/${uuid}`)).status).toBe(401);
const raw = await get(`/raw/${uuid}`, `password=${PW}`);
expect(raw.status).toBe(200);
expect(raw.headers.get("encrypted")).toBe("true");
expect(await raw.text()).toBe("top secret");
});
test("raw ?ignore_password serves the encrypted bytes, decryptable client-side", async () => {
const uuid = await uploadId(
{ filetype: "none", password: PW },
"secret.txt",
"top secret",
);
const raw = await get(`/raw/${uuid}?ignore_password=true`);
expect(raw.status).toBe(200);
expect(raw.headers.get("encrypted")).toBe("true");
const cipher = new Uint8Array(await raw.arrayBuffer());
expect(text.decode(cipher)).not.toBe("top secret");
// The on-disk format must match the shared crypto module byte-for-byte.
expect(text.decode(await decrypt(cipher, PW))).toBe("top secret");
});
});
describe("already-encrypted (client-side) uploads", () => {
test("opaque bytes are stored as-is and round-trip", async () => {
const cipher = await encrypt(utf8.encode("client side"), "pw");
const uuid = await uploadId(
{ filetype: "none", encrypted: "on" },
"blob.bin",
cipher,
);
// No password is known to the server, so /show defers to the client flow.
expect(await (await get(`/show/${uuid}`)).text()).toContain("decrypt-overlay");
const raw = await get(`/raw/${uuid}?ignore_password=true`);
const stored = new Uint8Array(await raw.arrayBuffer());
expect(stored).toEqual(new Uint8Array(cipher));
expect(text.decode(await decrypt(stored, "pw"))).toBe("client side");
});
});
describe("large text preview cap", () => {
test("oversized text isn't rendered inline but still downloads in full", async () => {
const big = "a".repeat(1024 * 1024 + 100); // just over MAX_TEXT_PREVIEW_BYTES
const uuid = await uploadId({ filetype: "none" }, "big.txt", big);
const show = await get(`/show/${uuid}`);
expect(await show.text()).toContain("too large to preview");
const raw = await get(`/raw/${uuid}`);
expect((await raw.arrayBuffer()).byteLength).toBe(big.length);
});
});
describe("upload validation", () => {
test("rejects a request with no file part", async () => {
const fd = new FormData();
fd.append("filetype", "none");
const res = await app.handle(
new Request("http://localhost/upload", { method: "POST", body: fd }),
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("No file");
});
test("rejects a form field after the file part", async () => {
const fd = new FormData();
fd.append("filetype", "none");
fd.append("file", new Blob(["x"]), "f.txt");
fd.append("late", "boom"); // arrives after the file → must be rejected
const res = await app.handle(
new Request("http://localhost/upload", { method: "POST", body: fd }),
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("last form field");
});
test("rejects an unknown filetype", async () => {
const res = await upload({ filetype: "not-a-language" }, "f.txt", "x");
expect(res.status).toBe(400);
expect(await res.text()).toContain("filetype");
});
test("rejects an over-long filename", async () => {
const res = await upload(
{ filetype: "none", filename: "x".repeat(256) },
"f.txt",
"x",
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("Filename too long");
});
test("rejects a non-numeric delete_in_minutes", async () => {
const res = await upload(
{ filetype: "none", delete_in_minutes: "soon" },
"f.txt",
"x",
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("delete_in_minutes");
});
test("rejects a non-multipart body", async () => {
const res = await app.handle(
new Request("http://localhost/upload", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
);
expect(res.status).toBe(400);
});
});
describe("lookup failures", () => {
test("unknown uuid → 404 on show and raw", async () => {
const missing = randomUUID();
expect((await get(`/show/${missing}`)).status).toBe(404);
expect((await get(`/raw/${missing}`)).status).toBe(404);
});
test("malformed uuid is rejected by validation", async () => {
expect((await get("/show/not-a-uuid")).status).toBe(422);
});
test("a row whose blob is gone → 404 instead of a 500", async () => {
const uuid = await uploadId({ filetype: "none" }, "f.txt", "data");
rmSync(join(BLOB_DIR, uuid)); // delete the blob out from under the row
expect((await get(`/show/${uuid}`)).status).toBe(404);
expect((await get(`/raw/${uuid}`)).status).toBe(404);
});
});