feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text

- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
This commit is contained in:
2026-10-12 15:28:31 +02:00
parent 6707550513
commit fb8fb980e5
26 changed files with 1090 additions and 48 deletions
@@ -36,3 +36,33 @@ describe("display-name bound", () => {
for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX");
});
});
// The viewer reads what the server decides (backend downloads/preview.py): the preview kinds it may
// be told and the header that says a text was cut. Each side names them in its own language, so the
// gate reads both: a kind the server adds that the viewer does not know would fall to "no preview".
describe("file preview", () => {
it("knows the kinds the server hands out", () => {
const backend = src("../../../../backend/app/downloads/preview.py").match(
/^PreviewKind = Literal\[(.*)\]$/m,
);
const frontend = src("./downloads.ts").match(/^export type FilePreviewKind = (.*);$/m);
expect(backend, "downloads/preview.py no longer defines PreviewKind").toBeTruthy();
expect(frontend, "lib/api/downloads.ts no longer exports FilePreviewKind").toBeTruthy();
const kinds = (s: string, sep: string) =>
s
.split(sep)
.map((k) => k.trim().replace(/"/g, ""))
.sort();
expect(kinds(frontend![1]!, "|")).toEqual(kinds(backend![1]!, ","));
});
it("reads the header the server cuts a text with", () => {
const backend = src("../../../../backend/app/routes/files.py").match(
/^TRUNCATED_HEADER = "(.*)"$/m,
);
const frontend = src("./files.ts").match(/^const PREVIEW_TRUNCATED_HEADER = "(.*)";$/m);
expect(backend, "routes/files.py no longer defines TRUNCATED_HEADER").toBeTruthy();
expect(frontend, "lib/api/files.ts no longer defines PREVIEW_TRUNCATED_HEADER").toBeTruthy();
expect(frontend![1]).toBe(backend![1]);
});
});