From 771e27d777b4b8290703ab98883d3049042fa5c8 Mon Sep 17 00:00:00 2001 From: zyansheep Date: Sat, 7 Nov 2026 15:39:15 -0800 Subject: [PATCH] Files: the hub reads what each kept file is from its bytes (type, pages, lines, entries, size, length), and text, code, Markdown, JSON, CSV, PDF and archives open in viewers of their own in the app Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + bun.lock | 11 + docs/ROADMAP.md | 1 + docs/deploy.md | 4 +- docs/desktop.md | 6 +- docs/files.md | 114 +++ docs/menus.md | 5 +- package.json | 3 + packages/hub/src/demo.ts | 169 ++++- packages/hub/src/fileinfo.ts | 840 +++++++++++++++++++++ packages/hub/src/mediaarchive.ts | 27 +- packages/hub/src/mediainfo.ts | 220 ++++++ packages/hub/src/schema.sql | 21 + packages/hub/src/server.ts | 42 +- packages/hub/src/store.ts | 3 + packages/hub/test/fileinfo.test.ts | 446 +++++++++++ packages/protocol/src/api.ts | 3 + packages/protocol/src/files.ts | 150 ++++ packages/protocol/src/index.ts | 1 + packages/web/package.json | 2 + packages/web/src/files.ts | 141 ++++ packages/web/src/styles.css | 401 ++++++++++ packages/web/src/ui/Timeline.tsx | 32 +- packages/web/src/ui/Viewer.tsx | 154 +++- packages/web/src/ui/fileopen.tsx | 56 ++ packages/web/src/ui/files/ArchiveView.tsx | 44 ++ packages/web/src/ui/files/JsonView.tsx | 107 +++ packages/web/src/ui/files/MarkdownView.tsx | 233 ++++++ packages/web/src/ui/files/PdfView.tsx | 141 ++++ packages/web/src/ui/files/TableView.tsx | 67 ++ packages/web/src/ui/files/TextView.tsx | 67 ++ packages/web/src/ui/files/csv.ts | 54 ++ packages/web/src/ui/files/highlight.ts | 207 +++++ packages/web/src/ui/files/load.tsx | 112 +++ packages/web/src/ui/icons.tsx | 3 + packages/web/src/ui/menus.tsx | 40 +- packages/web/stubs/no-canvas/package.json | 6 + packages/web/test/files.test.ts | 92 +++ scripts/verify-desk.ts | 28 +- scripts/verify-viewers.ts | 228 ++++++ 40 files changed, 4216 insertions(+), 66 deletions(-) create mode 100644 docs/files.md create mode 100644 packages/hub/src/fileinfo.ts create mode 100644 packages/hub/src/mediainfo.ts create mode 100644 packages/hub/test/fileinfo.test.ts create mode 100644 packages/protocol/src/files.ts create mode 100644 packages/web/src/files.ts create mode 100644 packages/web/src/ui/fileopen.tsx create mode 100644 packages/web/src/ui/files/ArchiveView.tsx create mode 100644 packages/web/src/ui/files/JsonView.tsx create mode 100644 packages/web/src/ui/files/MarkdownView.tsx create mode 100644 packages/web/src/ui/files/PdfView.tsx create mode 100644 packages/web/src/ui/files/TableView.tsx create mode 100644 packages/web/src/ui/files/TextView.tsx create mode 100644 packages/web/src/ui/files/csv.ts create mode 100644 packages/web/src/ui/files/highlight.ts create mode 100644 packages/web/src/ui/files/load.tsx create mode 100644 packages/web/stubs/no-canvas/package.json create mode 100644 packages/web/test/files.test.ts create mode 100644 scripts/verify-viewers.ts diff --git a/README.md b/README.md index cb8cf8f..d149168 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ clients over Tailscale (bind is `0.0.0.0`; the server only answers loopback + ta | Mark all read | done: everywhere, or per server, folder or person, through a paced queue on the hub. Verified on the demo adapter only | | Quiet hours | done: a daily schedule that holds pushes, with one summary when it ends. Verified on the demo adapter only | | Sending files | done: paste, drop or pick, with progress; uploads resume and survive a hub restart ([docs/uploads.md](docs/uploads.md)). Live on Discord, on a local homeserver for Matrix (encrypted rooms too, once encryption is set up), not yet live on Signal | +| Files | done: a file's card says what it really is (by its bytes, not the network's label) with pages, lines or entries; text and code, Markdown, JSON, CSV, PDF and archives open in viewers in the app, and other types in your own apps ([docs/files.md](docs/files.md)). Verified on the demo world in headless Chromium | | Accounts in dither-accounts | built: the adapters moved to [dither-accounts](../dither-accounts), whose manager can run any account for this hub while the hub runs the rest, one account at a time, with a lock so none runs twice and a one-line rollback ([docs/accounts.md](docs/accounts.md)). Verified with the Matrix testbed and a fake chat account, not yet on a live account | | Discord adapter (sandboxed real web client) | works on a live account: login through the live view, snapshot, history, send, reply, react. DMs and group DMs arrive live. Server channels do not: while the client sits idle, Discord pushes nothing for them, so they update when opened | diff --git a/bun.lock b/bun.lock index cd354de..18b0596 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,8 @@ "@emoji-mart/data": "^1.2.1", "@novnc/novnc": "^1.7.0", "@preact/signals": "^2.11.4", + "marked": "^18.1.3", + "pdfjs-dist": "^6.5.438", "preact": "^11.1.0", "uqr": "^0.1.3", }, @@ -65,6 +67,9 @@ }, }, }, + "overrides": { + "@napi-rs/canvas": "file:./packages/web/stubs/no-canvas", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -282,6 +287,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.1.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-9ChMH8EVVgjgID49nsL6IWxWk5c6GtAsq/xw7U1ggkjkU5AGuv68oLTVlFRtvcUYZtpbNyjyvGo3YDu0fGapiw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], @@ -292,6 +299,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "pdfjs-dist": ["pdfjs-dist@6.5.438", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.13" } }, "sha512-vg8Pw1x+AFKy4AMDvhuWl5cOYV8d698UoEANoFFhc79hqXDnZQqnOXoxw3HG907WHOg1JYOvLhP756boddFxpA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -334,6 +343,8 @@ "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "pdfjs-dist/@napi-rs/canvas": ["@napi-rs/canvas@file:./packages/web/stubs/no-canvas", {}], + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6b0956f..e41e5b5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,6 +54,7 @@ Status is *done*, *next*, *later*, or *blocked* (by a network or its protocol). | Edit and delete your own messages | H | L | done | Verified on the demo adapter, not yet on a live network | | Drafts per conversation | M | L | done | On each device, not synced | | Send attachments and images | H | M | done | Paste, drop or pick. Each file uploads as it is attached, with progress and a cancel, in pieces that carry on after a dropped connection; uploads survive a hub restart; a file that cannot go says why ([uploads.md](uploads.md)). Sent live on Discord, against a local homeserver on Matrix, on the demo adapter. Signal's live send is unverified: only a fake signal-cli has seen it. In encrypted Matrix rooms only once encryption is set up | +| See what a file is, and view it in the app | H | M | done | The hub reads each kept file's type, pages, lines, entries, size and length from its bytes, in the background; text and code, Markdown, JSON, CSV, PDF (pdf.js) and archive viewers in chunks of their own, HTML, SVG and XML as source ([files.md](files.md)). Verified in headless Chromium against the demo's files, not on the live hub's or in the Android app | | Mark all read | M | L | done | Everywhere, or per server, folder or person. A paced queue on the hub tells the networks and survives restarts | | Typing indicator, outgoing | L | L | done | Both ways on every network, with a privacy switch ([typing.md](typing.md)) | | Pinned messages, polls, stickers from the picker | M | M | later | | diff --git a/docs/deploy.md b/docs/deploy.md index 63663a7..fcc7fe9 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -92,7 +92,9 @@ one transaction. Every six hours it clears partial downloads from `media-cache/`. The cache itself is kept indefinitely: the media archive (`packages/hub/src/mediaarchive.ts`) fills it with every remote attachment soon after it arrives, because Discord's links -expire. Setting `DITHER_MEDIA_CACHE_MB` caps it again, least recently shown first. Adapter logs rotate at 10 MB. +expire. Each kept file is then looked at once for what it really is (`mediainfo.ts`, [files.md](files.md)), in the +background, a few seconds of work every half minute. Setting `DITHER_MEDIA_CACHE_MB` caps the cache again, least +recently shown first. Adapter logs rotate at 10 MB. Triage rows older than `DITHER_TRIAGE_KEEP_DAYS` (90) go when nothing can be learned from them: in conversations you never wrote in, never voted on, never notified and never explored. diff --git a/docs/desktop.md b/docs/desktop.md index 63e272b..a5b6b68 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -76,6 +76,10 @@ says), files to the app registered for their type, or one you pick with **Open W Without the helper, or with it switched off or not answering, everything opens as it did before. +A file the app has a viewer for (text and code, Markdown, JSON, CSV, PDF, archives: [files.md](files.md)) opens in +that viewer on a click even with the helper on; the viewer's header has Open File and Open With…, which go to the +helper. A file with no viewer goes straight to the helper, as before. + ### Running it It needs Bun and, for Open With…, `gio` (GLib). As a systemd user service, @@ -158,4 +162,4 @@ file, as they could run `xdg-open` themselves. `bun test` in `packages/desk` runs against a fake hub, with `test/fixtures/record.ts` standing in for `xdg-open` and `gio`: it records its arguments and opens nothing. `bun scripts/verify-desk.ts ` does the same end to end in headless Chromium: pairs through Settings with the code `pair` prints, clicks a link, uses Open -Link, Open With… and a click on the demo's PDF, and unpairs. Neither ever opens a browser, file or app on the desktop. +Link, Open With…, Open File from the demo PDF's viewer and a click on the demo spreadsheet (no viewer), and unpairs. Neither ever opens a browser, file or app on the desktop. diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 0000000..d75b556 --- /dev/null +++ b/docs/files.md @@ -0,0 +1,114 @@ +# Files: what they are, and viewers for them + +A file in a message says what it is on its card (the type by its bytes, then pages, lines or entries, then size), and a +click opens it in a viewer in the app: text and code, Markdown, JSON, CSV and TSV, PDF, and archives. HTML, SVG and XML +are shown as their source, never rendered. A file with no viewer here (a spreadsheet, a program, a disc image) opens as +before: in the desktop's app through the desktop helper or the Android app when they are on, else as a download. + +## What the hub records + +A network's type is often wrong (Discord calls a `.rs` file `application/rls-services+xml`) or says nothing +(`application/octet-stream`). So once a file's bytes are kept in the media cache (the media archive, +`packages/hub/src/mediaarchive.ts`, keeps every attachment), the hub looks at them (`packages/hub/src/fileinfo.ts`) and +records in `media_info`, keyed like the cache by the SHA-256 of the attachment's URL: + +| | | +| --- | --- | +| `mime`, `kind` | The type by magic numbers and content; the kind is the viewer that shows it (`FileKind` in `packages/protocol/src/files.ts`), `binary` for none | +| `size` | In bytes, of the file on disk | +| `pages` | PDF: a linearized file's `/N`, else the largest `/Count` of a page-tree node, also inside compressed object streams | +| `encoding`, `lines`, `language` | Text: UTF-8, UTF-16 (with or without a byte-order mark) or Windows-1252; lines; a highlighting language from the name, else from a `#!` line or the content (JSON that parses, a patch, Markdown's marks) | +| `entries`, `list` | ZIP (from the central directory; Office, OpenDocument, EPUB, JAR and APK told apart), tar and gzip (a tar inside is listed): the count and the first 50 names and sizes | +| `width`, `height` | PNG, JPEG, GIF, WebP, BMP, ICO, AVIF; a video's frame size from MP4 and WebM | +| `duration` | MP4, M4A, QuickTime, WebM, Matroska, WAV, FLAC, Ogg (Opus, Vorbis), MP3 (Xing/VBRI, else from the bitrate) | +| `partial` | Lines or entries were counted in the first part only: there are at least this many | + +The name and the network's type only break ties between kinds of text. Markup a browser would run (HTML, SVG, XML) is +told by its content whatever it is called, and is only ever `text`. A name's control and direction-changing characters +are dropped before its extension is read, so `invoice` + U+202E + `fdp.exe` (which shows as invoiceexe.pdf) is an +`.exe`, and archive entry names are cleaned the same way. + +Reads are bounded: the first 64 KiB for the type, the first 2 MiB of text, the last 64 KiB of a ZIP and up to 2 MiB +of its directory, a PDF's first 4 MiB and last 1 MiB, one 512-byte header at a time through a tar (5,000 at most), and +at most 8 MiB out of a gzip, so a gzip bomb stops there. Nothing is extracted or written, nothing is run, and there are +no new native dependencies (gzip through the runtime's `DecompressionStream`, object streams through `node:zlib`). + +### When + +`packages/hub/src/mediainfo.ts` walks attachments by rowid from a cursor in `meta` (`media_info_cursor`), so a restart +carries on and the first run goes through everything the hub already has. On a hub that archives it stays behind the +archive's cursor, and the archive hands it each file it keeps (first time, a retry, or a Discord link signed again) to +look at straight away. One file at a time, a short pause between, at most four seconds of work every 30 seconds. A +demo hub keeps nothing ahead of time, so the walk fetches its own (local) demo files first. Inline `data:` media is +left alone. A message from the last two days is sent again to the apps once its file is looked at, so an open card +changes by itself; an older one shows it when next loaded. A new `SNIFF_VERSION` starts the walk over. + +The viewer asks `GET /api/attachments/:id/info` when a file it opens has not been looked at yet: the hub fetches the +file (as opening it would) and looks at it then. + +### Serving + +`/media` keeps its protections (`packages/hub/src/mediaheaders.ts`): `nosniff`, a `sandbox` CSP, and anything but a +picture, video, audio, PDF or plain text as a download. Once the hub knows what a file is, one the network called +`application/octet-stream` (or a type just as empty) is served as what it is; a network's own specific type is left as +it said. A PNG sent as octet-stream then shows inline, and an HTML page sent as octet-stream is served as `text/html`, +which is still a download under the sandbox. + +## The viewers + +The header always has the name, what the card says, and Download. With the desktop helper paired and "open files in my +apps" on, or in the Android app, it has Open File too (the desktop's or the phone's app for the type), and with the +helper paired, Open With…, which lists the desktop's apps for the type the bytes say (and text editors for a kind of +text no app claims). The file menu has View File for these kinds as well. + +Each viewer is a chunk of its own (`packages/web/src/ui/files/`), loaded the first time such a file is opened, and the +shell is in `packages/web/src/ui/Viewer.tsx` with the image viewer. The viewers import nothing from the app's own +modules but Preact (what they need comes in their props), because the bundler splits the startup code by which lazy +chunks share each module: as it is, the startup code gains two small chunks (Preact's hooks and JSX runtime), and the +page two `modulepreload` links. + +| Kind | Viewer | +| --- | --- | +| Text and code | Monospace, line numbers, wrap on or off, light highlighting (`highlight.ts`: comments, strings, numbers, keywords, tags; one regular expression per family of languages, no library). 3,000 lines at a time | +| HTML, SVG, XML | The same, as source. Never rendered, never in a frame | +| Markdown | `marked` parses; the viewer turns its tokens into elements itself, so nothing in the file reaches the page as markup. Raw HTML is shown as text. Links are web and mail links only, opening as any link in the app does (through the desktop helper when it is on). A picture from elsewhere waits for a click (loading it would tell its host), then loads without a referrer; inline `data:` pictures show. Rendered or Source | +| JSON | A tree, two levels open, Expand all and Collapse all, 200 children at a time. Not valid, or cut off at the cap: shown as text | +| CSV and TSV | A table (RFC 4180 quoting; the delimiter from the type or name, else whichever of `,` `;` tab `\|` the first line uses most), 1,000 rows at a time. Rendered or Source | +| PDF | pdf.js from npm, page by page: a page is drawn when it comes near and dropped when far, at the pane's width, zoom 50 to 300% | +| Archives | The hub's list: names and sizes of the first 50 entries, and how many there are | +| Images, video, audio | As before: the image viewer, and the players in the timeline. A file the network called just a file whose bytes are a picture, a video or a sound now shows as one | + +Text, Markdown, JSON and tables fetch the first 2 MB only and say so ("Showing the first 2 MB of 14 MB. Download the +file for the rest."). + +### Why pdf.js, not the browser's own viewer + +Chromium's own viewer does show a `/media` PDF in a frame, `sandbox` CSP and all (`scripts/verify-viewers.ts +--probe-pdf` photographs it in a tab and in a frame). But the Android app's WebView has no PDF viewer at all, and +Chromium's runs a PDF's own script. pdf.js is one viewer everywhere, under the app's own header: it parses in a worker, +paints to canvases, and runs none of a PDF's own script (the scripting build is not loaded). It costs a 446 KB chunk and a 1.3 MB worker, both loaded only when a PDF is opened. pdf.js lists a native canvas for Node +as an optional dependency; the root `package.json` overrides it with an empty stub (`packages/web/stubs/no-canvas`), so +installing installs no native binary. + +## Testing + +- `packages/hub/test/fileinfo.test.ts`: types a network got wrong, files cut short, hostile names, bounded reads (a big + text file, a gzip bomb, a big PDF), the walk and its cursor, and `/media` serving the found type with the same + protections. +- `packages/web/test/files.test.ts`: the guess before the hub has looked, the card's words, CSV parsing, highlighting. +- `bun scripts/verify-viewers.ts `: the demo's files (`packages/hub/src/demo.ts`: a text log, a Rust + file Discord mislabels, Markdown and JSON sent as octet-stream, a CSV, a ZIP, an SVG with a script in it, the PDF, + and a spreadsheet with no viewer), each opened from its card at desktop width and at 390 px, with screenshots; that a + card updates by itself; that no viewer loads before a file is opened; that nothing in a Markdown file or an SVG runs + or loads by itself; no page errors and nothing wider than a phone's screen. +- `scripts/verify-desk.ts` covers Open File from the viewer and a click on a file with no viewer. + +## Not done + +- PDF: no text layer, so no selecting or searching text, and no outline. A password-protected PDF says so and offers + Download. +- Archives: 7z, RAR, xz, bzip2 and zstd are recognised but not listed. A tar inside gzip is listed from its first + 8 MiB. +- No preview for Office and OpenDocument files, TIFF, HEIC or fonts; they say what they are and open in an app. +- A WebM from a browser's recorder has no duration in its header, so none is shown. +- The Android app was not tried; the viewers are web pages and should work in its WebView as in Chromium. diff --git a/docs/menus.md b/docs/menus.md index 57fe975..9fcafba 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -92,8 +92,9 @@ Mark Everything As Read. Discord's home button has no menu, but this inbox spans ### Images, files and links Open Image (the viewer) · Open Original · Copy Image (as PNG) · Save Image · Copy Link, then the message's -menu. Files, video and audio say Open/Save File (Video, Audio); a file the hub only serves as a download (HTML, SVG, -anything but a picture, video, audio, PDF or plain text) has Save alone. With the desktop helper paired +menu. A file the app has a viewer for (text and code, Markdown, JSON, CSV, PDF, archives: [files.md](files.md)) has +View File first. Files, video and audio say Open/Save File (Video, Audio); a file the hub only serves as a download +(HTML, SVG, anything but a picture, video, audio, PDF or plain text) has Save alone. With the desktop helper paired ([desktop.md](desktop.md#opening-links-and-files-in-your-own-apps)), every file has Open, which opens it in the desktop's app for its type, and Open With… lists the others; in the Android app, Open offers it with the system's chooser. A link: Open Link · Copy Link, then the message's menu; Open Link goes to the default browser through the diff --git a/package.json b/package.json index 1a85d2e..c5a74da 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,8 @@ "@types/bun": "latest", "acorn": "^8.19.0", "typescript": "^5.9.0" + }, + "overrides": { + "@napi-rs/canvas": "file:./packages/web/stubs/no-canvas" } } diff --git a/packages/hub/src/demo.ts b/packages/hub/src/demo.ts index 7ae7899..3cde47c 100644 --- a/packages/hub/src/demo.ts +++ b/packages/hub/src/demo.ts @@ -120,10 +120,169 @@ function tinyPdf(text: string): Uint8Array { return new TextEncoder().encode(out); } -/** Demo attachments fetched through fetchMedia, so they are served at /media/ as a network's files are. */ +/** A zip of stored (uncompressed) entries, as a phone's "share as zip" makes. For the file viewers (docs/files.md). */ +export function storedZip(entries: [name: string, text: string][]): Uint8Array { + const enc = new TextEncoder(); + const local: Uint8Array[] = []; + const central: Uint8Array[] = []; + let at = 0; + const u16 = (n: number) => [n & 0xff, (n >> 8) & 0xff]; + const u32 = (n: number) => [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]; + for (const [name, text] of entries) { + const n = enc.encode(name); + const data = enc.encode(text); + const crc = Bun.hash.crc32(data); + const common = [...u16(20), ...u16(0x800), ...u16(0), ...u16(0), ...u16(0x21), ...u32(crc), ...u32(data.length), ...u32(data.length), ...u16(n.length), ...u16(0)]; + local.push(new Uint8Array([0x50, 0x4b, 3, 4, ...common, ...n, ...data])); + central.push(new Uint8Array([0x50, 0x4b, 1, 2, ...u16(20), ...common, ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(at), ...n])); + at += 30 + n.length + data.length; + } + const cdSize = central.reduce((k, c) => k + c.length, 0); + const end = new Uint8Array([0x50, 0x4b, 5, 6, ...u16(0), ...u16(0), ...u16(entries.length), ...u16(entries.length), ...u32(cdSize), ...u32(at), ...u16(0)]); + return new Uint8Array(Buffer.concat([...local, ...central, end])); +} + +const text = (s: string) => new TextEncoder().encode(s); + +const ROUTING_NOTES = `# Greedy routing notes + +Embedding peers into **hyperbolic space** keeps greedy forwarding loop-free, as long as every node knows its +neighbours' coordinates. These are the numbers from the simulator so far. + +## Stretch by topology + +| topology | nodes | stretch | p99 | +| --- | ---: | ---: | ---: | +| ring | 1k | 1.08 | 1.31 | +| grid | 4k | 1.12 | 1.40 | +| scale-free | 10k | 1.21 | 1.77 | + +## Open questions + +- [x] ring and grid +- [ ] churn: what happens when a coordinate owner leaves? +- [ ] can a node lie about its coordinate? + +> Greedy routing succeeds whenever the embedding is *greedy*: every node has a neighbour closer to the target. + +\`\`\`rust +fn next_hop(me: &Coord, peers: &[Peer], to: &Coord) -> Option { + peers + .iter() + .filter(|p| p.coord.dist(to) < me.dist(to)) + .min_by(|a, b| a.coord.dist(to).total_cmp(&b.coord.dist(to))) + .map(|p| p.id) +} +\`\`\` + +Background: [Kleinberg, geographic routing in hyperbolic space](https://example.org/greedy-embedding). +Figure from the paper, not loaded until asked: ![the embedding](https://example.org/figure.png) + +Raw HTML is shown, not run: +`; + +const STRETCH_CSV = `topology,nodes,stretch,p99,notes +ring,1000,1.08,1.31, +grid,4000,1.12,1.40,"4-neighbour, wraps at the edges" +scale-free,10000,1.21,1.77,"Barabási–Albert, m=3" +small-world,10000,1.17,1.62,"Watts–Strogatz, p=0.1" +`; + +const BUILD_LOG = Array.from({ length: 60 }, (_, i) => { + const t = `2026-11-0${(i % 3) + 4}T12:${String(10 + Math.floor(i / 2)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}Z`; + if (i === 41) return `${t} error[E0308]: mismatched types in doctest \`insert_get\``; + if (i === 42) return `${t} --> hashdb/src/lib.rs:88:13`; + return `${t} ${['Compiling hashdb v0.9.2', 'Compiling dither-core v0.4.0', 'Running unittests src/lib.rs', 'test store::tests::round_trip ... ok', 'Doc-tests hashdb'][i % 5]}`; +}).join('\n') + '\nbuild #482 failed\n'; + +const INSERT_GET = `//! The doctest that broke #482, fixed. +use hashdb::{HashDb, Key}; + +/// Inserts a value and reads it back. +/// +/// \`\`\` +/// let mut db = hashdb::HashDb::default(); +/// let k = db.insert(b"hello".to_vec()); +/// assert_eq!(db.get(&k), Some(&b"hello".to_vec())); +/// \`\`\` +pub fn insert_get(db: &mut HashDb, value: Vec) -> Option> { + let key: Key = db.insert(value); + // 0.9 returns a reference now; clone for the old signature. + db.get(&key).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let mut db = HashDb::default(); + assert_eq!(insert_get(&mut db, vec![1, 2, 3]), Some(vec![1, 2, 3])); + } +} +`; + +const BENCH = JSON.stringify( + { + build: 483, + commit: 'a3f9c21', + machine: { cpu: 'Ryzen 7 7840U', cores: 16, memoryGiB: 32 }, + results: [ + { name: 'route/greedy/1k', ns: 412, stddev: 9.1 }, + { name: 'route/greedy/10k', ns: 1870, stddev: 33.4 }, + { name: 'dht/bootstrap', ns: 92_400, stddev: 1_210, notes: null }, + { name: 'hashdb/insert', ns: 88, stddev: 1.2 }, + ], + regressions: [], + ok: true, + }, + null, + 2, +); + +const TOPOLOGY_SVG = ` + + + + + + + +`; + +const SIM_ZIP = storedZip([ + ['dither-sim-0.4/', ''], + ['dither-sim-0.4/README.md', '# dither-sim\n\n10k-node simulations in the browser.\n'], + ['dither-sim-0.4/index.html', 'dither-sim\n'], + ['dither-sim-0.4/sim.js', 'export function step(world) { return world; }\n'], + ['dither-sim-0.4/topologies/ring.json', '{"nodes":1000}\n'], + ['dither-sim-0.4/LICENSE', 'MIT\n'], +]); + +const RENT_XLSX = storedZip([ + ['[Content_Types].xml', ''], + ['xl/workbook.xml', ''], + ['xl/worksheets/sheet1.xml', ''], +]); + +/** + * Demo attachments fetched through fetchMedia, so they are served at /media/ as a network's files are. The types are + * what a network might say, wrong ones included (Discord calls a .rs file application/rls-services+xml). + */ const DEMO_FILES: Record = { 'demo:local-first-chat-sync.pdf': { mime: 'application/pdf', bytes: tinyPdf('Local-first chat sync (demo)') }, + 'demo:routing-notes.md': { mime: 'application/octet-stream', bytes: text(ROUTING_NOTES) }, + 'demo:stretch.csv': { mime: 'text/csv', bytes: text(STRETCH_CSV) }, + 'demo:topology.svg': { mime: 'image/svg+xml', bytes: text(TOPOLOGY_SVG) }, + 'demo:build-482.txt': { mime: 'text/plain', bytes: text(BUILD_LOG) }, + 'demo:insert_get.rs': { mime: 'application/rls-services+xml', bytes: text(INSERT_GET) }, + 'demo:dither-sim-0.4.zip': { mime: 'application/zip', bytes: SIM_ZIP }, + 'demo:bench-483.json': { mime: 'application/octet-stream', bytes: text(BENCH) }, + 'demo:rent-split.xlsx': { mime: 'application/octet-stream', bytes: RENT_XLSX }, }; +/** A demo file as an attachment. */ +const file = (key: string, name: string): P.AttachmentFact => ({ kind: 'file', mime: DEMO_FILES[key].mime, name, size: DEMO_FILES[key].bytes.length, url: key }); // ------------------------------------------------------------------ pictures @@ -442,9 +601,13 @@ const CONVS: DemoConv[] = [ msgs: [ [DAY + 120, D.lev, 'CI is red on main, the hashdb bump broke a doctest'], [DAY + 118, D.ci, '❌ `main` · build #482 failed · `hashdb`: doctest `insert_get`'], + [DAY + 117, D.ci, 'full log', { att: [file('demo:build-482.txt', 'build-482.txt')] }], [DAY + 40, D.lev, 'fixed in #211'], + [DAY + 39, D.lev, 'the fixed test, for anyone on the old branch', { att: [file('demo:insert_get.rs', 'insert_get.rs')] }], [DAY + 35, D.lev, '**dither-sim v0.4** is out: 10k-node simulations in the browser. https://github.com/libdither/dither-sim', { fwd: { conversation: ch('dither-ann'), message: `${ch('dither-ann')}~0`, space: g.dither } }], + [DAY + 34, D.lev, 'release build, if you want to run it offline', { att: [file('demo:dither-sim-0.4.zip', 'dither-sim-0.4.zip')] }], [DAY + 30, D.ci, '✅ `main` · build #483 passed'], + [DAY + 29, D.ci, 'benchmarks for #483', { att: [file('demo:bench-483.json', 'bench-483.json')] }], [3 * H, D.quinn, 'starting a thread for the bootstrap refactor', { thread: T.ditherBoot }], [58, D.quinn, `${at(D.zyan)} can you review the bootstrap PR before Friday? it touches the handshake`], ], @@ -465,6 +628,9 @@ const CONVS: DemoConv[] = [ fact: dch(['dmain', 'dalt'], 'dither-routing', 'routing-research', 'dither-dev', 1), msgs: [ [4 * DAY, D.zyan, 'notes on greedy embedding into hyperbolic space: https://github.com/zyansheep/routing-research'], + [4 * DAY - 5, D.zyan, 'the write-up so far', { att: [file('demo:routing-notes.md', 'routing-notes.md')] }], + [4 * DAY - 8, D.zyan, 'and the raw numbers', { att: [file('demo:stretch.csv', 'stretch.csv')] }], + [4 * DAY - 10, D.zyan, 'the three-node picture', { att: [file('demo:topology.svg', 'topology.svg')] }], [4 * DAY - 30, D.lev, 'the stretch numbers look great'], [DAY + 60, D.pixelfox, 'read the notes. how do you handle churn?'], ], @@ -667,6 +833,7 @@ const CONVS: DemoConv[] = [ fact: { nativeId: room('house', 'matrix.org'), shape: 'group', name: 'Housemates', participants: [M.self.nativeId, M.jules.nativeId, M.noor.nativeId, M.ben.nativeId], encrypted: true }, msgs: [ [2 * DAY, M.jules, 'rent split for november is in the sheet'], + [2 * DAY - 1, M.jules, '', { kind: 'media', att: [file('demo:rent-split.xlsx', 'rent-split.xlsx')] }], [DAY + 300, M.noor, 'who left the oven on 😭'], [DAY + 290, M.ben, 'guilty', { react: { '😂': [M.noor, M.jules] } }], [DAY + 100, M.noor, '', { kind: 'encrypted' }], diff --git a/packages/hub/src/fileinfo.ts b/packages/hub/src/fileinfo.ts new file mode 100644 index 0000000..47ae0a7 --- /dev/null +++ b/packages/hub/src/fileinfo.ts @@ -0,0 +1,840 @@ +/** + * What a kept file is, by its bytes (docs/files.md). A network's type is often wrong or just + * application/octet-stream, so the type comes from magic numbers and content, and the name and the network's type + * only break ties between kinds of text. + * + * Reads are bounded: the first 64 KiB for the type, the first 2 MiB of text, the end of a zip for its directory, a + * PDF's first 4 MiB and last 1 MiB, archive headers one by one, and at most 8 MiB out of a gzip. Nothing is extracted + * or written anywhere, and nothing here runs anything. Pure apart from the reads, so tests feed it bytes. + */ +import { inflateSync } from 'node:zlib'; +import type * as P from '@dither-chat/protocol'; +import { cleanName, extOf, languageOf, MIME_BY_LANGUAGE } from '@dither-chat/protocol'; + +/** Bump when the sniffer learns something new: files looked at by an older one are looked at again. */ +export const SNIFF_VERSION = 1; + +export const HEAD = 64 * 1024; +export const TEXT_MAX = 2 * 1024 * 1024; +const PDF_HEAD = 4 * 1024 * 1024; +const PDF_TAIL = 1024 * 1024; +const INFLATE_MAX = 8 * 1024 * 1024; +/** Entries kept in an archive's list. */ +export const LIST_MAX = 50; +/** Archive headers walked before the count is called partial. */ +const WALK_MAX = 5000; + +export interface Source { + size: number; + /** Up to `len` bytes from `at`; fewer at the end. */ + read(at: number, len: number): Promise; +} + +export const bytesSource = (b: Uint8Array): Source => ({ size: b.length, read: async (at, len) => b.subarray(Math.max(0, at), Math.max(0, Math.min(b.length, at + len))) }); + +export function fileSource(path: string): Source { + const f = Bun.file(path); + return { size: f.size, read: async (at, len) => new Uint8Array(await f.slice(at, at + len).arrayBuffer()) }; +} + +const latin1 = new TextDecoder('latin1'); +const ascii = (b: Uint8Array, from: number, to: number) => latin1.decode(b.subarray(from, Math.min(to, b.length))); +const u16be = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1]; +const u16le = (b: Uint8Array, i: number) => b[i] | (b[i + 1] << 8); +const u32be = (b: Uint8Array, i: number) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3]; +const u32le = (b: Uint8Array, i: number) => b[i] + (b[i + 1] << 8) + (b[i + 2] << 16) + ((b[i + 3] << 24) >>> 0); +const u64le = (b: Uint8Array, i: number) => u32le(b, i) + u32le(b, i + 4) * 2 ** 32; +const u64be = (b: Uint8Array, i: number) => u32be(b, i) * 2 ** 32 + u32be(b, i + 4); +const has = (b: Uint8Array, at: number, s: string) => b.length >= at + s.length && ascii(b, at, at + s.length) === s; +const essence = (m: string | null | undefined) => (m ?? '').split(';')[0].trim().toLowerCase(); +const plainType = (m: string) => /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$/.test(m); +const GENERIC = new Set(['', 'application/octet-stream', 'binary/octet-stream', 'application/binary', 'application/unknown', 'application/x-download', 'application/force-download']); +/** A type that says nothing about the bytes. */ +export const genericType = (m: string | null | undefined) => GENERIC.has(essence(m)); +const finite = (n: number) => (Number.isFinite(n) && n > 0 ? n : undefined); +const dims = (w: number, h: number) => (w > 0 && h > 0 && w < 1e6 && h < 1e6 ? { width: w, height: h } : {}); + +type Partial_ = Omit; + +/** What `src` is. `hint` is what the network said: a name and a type, both untrusted. */ +export async function analyze(src: Source, hint: { name?: string | null; mime?: string | null } = {}): Promise { + const size = src.size; + const head = await src.read(0, Math.min(size, HEAD)); + const found = (await magic(src, head, hint)) ?? (await text(src, head, hint)); + const out: P.FileInfo = { ...found, size }; + // Nothing undefined on the wire or in the table. + for (const k of Object.keys(out) as (keyof P.FileInfo)[]) if (out[k] === undefined) delete out[k]; + return out; +} + +// ------------------------------------------------------------------ binary kinds + +async function magic(src: Source, b: Uint8Array, hint: { name?: string | null; mime?: string | null }): Promise { + const ext = extOf(hint.name); + const pdfAt = ascii(b, 0, 1024).indexOf('%PDF-'); + if (pdfAt === 0 || (pdfAt > 0 && b.subarray(0, pdfAt).some((c) => c < 9 || c > 126))) return { mime: 'application/pdf', kind: 'pdf', ...(await pdfPages(src, b)) }; + if (b[0] === 0x89 && has(b, 1, 'PNG\r\n\x1a\n')) return { mime: 'image/png', kind: 'image', ...(b.length >= 24 ? dims(u32be(b, 16), u32be(b, 20)) : {}) }; + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return { mime: 'image/jpeg', kind: 'image', ...(await jpegDims(src)) }; + if (has(b, 0, 'GIF87a') || has(b, 0, 'GIF89a')) return { mime: 'image/gif', kind: 'image', ...(b.length >= 10 ? dims(u16le(b, 6), u16le(b, 8)) : {}) }; + if (has(b, 0, 'RIFF') && has(b, 8, 'WEBP')) return { mime: 'image/webp', kind: 'image', ...webpDims(b) }; + if (has(b, 0, 'BM') && b.length >= 26 && u32le(b, 2) >= 26) { + const dib = u32le(b, 14); + const w = dib === 12 ? u16le(b, 18) : u32le(b, 18) | 0; + const h = dib === 12 ? u16le(b, 20) : u32le(b, 22) | 0; + if (dib >= 12 && dib <= 124) return { mime: 'image/bmp', kind: 'image', ...dims(Math.abs(w), Math.abs(h)) }; + } + if (b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0 && b.length >= 22 && u16le(b, 4) > 0 && u16le(b, 4) < 256 && (b[9] === 0 || b[9] === 255) && (ext === 'ico' || ext === 'cur' || ext === '')) { + return { mime: 'image/x-icon', kind: 'image', ...dims(b[6] || 256, b[7] || 256) }; + } + if ((has(b, 0, 'II*\0') || has(b, 0, 'MM\0*')) && !['cr2', 'nef', 'dng', 'arw'].includes(ext)) return { mime: 'image/tiff', kind: 'binary' }; + if (has(b, 0, '8BPS')) return { mime: 'image/vnd.adobe.photoshop', kind: 'binary', ...(b.length >= 22 ? dims(u32be(b, 18), u32be(b, 14)) : {}) }; + if (has(b, 4, 'ftyp')) return isoMedia(src, b); + if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return matroska(src, ext); + if (has(b, 0, 'OggS')) return ogg(src, b); + if (has(b, 0, 'RIFF') && has(b, 8, 'WAVE')) return { mime: 'audio/wav', kind: 'audio', ...(await wavDuration(src)) }; + if (has(b, 0, 'RIFF') && has(b, 8, 'AVI ')) return { mime: 'video/x-msvideo', kind: 'binary' }; + if (has(b, 0, 'fLaC')) return { mime: 'audio/flac', kind: 'audio', ...flacDuration(b) }; + if (has(b, 0, 'FORM') && (has(b, 8, 'AIFF') || has(b, 8, 'AIFC'))) return { mime: 'audio/aiff', kind: 'binary' }; + if (has(b, 0, 'MThd')) return { mime: 'audio/midi', kind: 'binary' }; + if (has(b, 0, 'PK\x03\x04') || has(b, 0, 'PK\x05\x06')) return zip(src, b, ext); + if (b[0] === 0x1f && b[1] === 0x8b && b[2] === 8) return gzip(src, b, hint.name); + if (b.length >= 263 && has(b, 257, 'ustar')) return { mime: 'application/x-tar', kind: 'archive', ...(await tar(windowed(src), src.size)) }; + if (has(b, 0, 'BZh') && /[1-9]/.test(String.fromCharCode(b[3]))) return { mime: 'application/x-bzip2', kind: 'binary' }; + if (has(b, 0, '\xfd7zXZ\0')) return { mime: 'application/x-xz', kind: 'binary' }; + if (b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd) return { mime: 'application/zstd', kind: 'binary' }; + if (has(b, 0, "7z\xbc\xaf'\x1c")) return { mime: 'application/x-7z-compressed', kind: 'binary' }; + if (has(b, 0, 'Rar!\x1a\x07')) return { mime: 'application/vnd.rar', kind: 'binary' }; + if (has(b, 0, 'MSCF')) return { mime: 'application/vnd.ms-cab-compressed', kind: 'binary' }; + if (has(b, 0, '!\ndebian')) return { mime: 'application/vnd.debian.binary-package', kind: 'binary' }; + if (b[0] === 0xed && b[1] === 0xab && b[2] === 0xee && b[3] === 0xdb) return { mime: 'application/x-rpm', kind: 'binary' }; + if (has(b, 0, '\x7fELF')) return { mime: 'application/x-executable', kind: 'binary' }; + if (has(b, 0, 'MZ') && b.length >= 64) { + const pe = u32le(b, 60); + if (pe < b.length - 4 && has(b, pe, 'PE\0\0')) return { mime: 'application/vnd.microsoft.portable-executable', kind: 'binary' }; + } + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(u32be(b, 0))) return { mime: 'application/x-mach-binary', kind: 'binary' }; + if (u32be(b, 0) === 0xcafebabe) return { mime: ext === 'class' ? 'application/java-vm' : 'application/x-mach-binary', kind: 'binary' }; + if (has(b, 0, '\0asm')) return { mime: 'application/wasm', kind: 'binary' }; + if (has(b, 0, 'dex\n')) return { mime: 'application/vnd.android.dex', kind: 'binary' }; + if (has(b, 0, 'SQLite format 3\0')) return { mime: 'application/vnd.sqlite3', kind: 'binary' }; + if (has(b, 0, 'wOFF')) return { mime: 'font/woff', kind: 'binary' }; + if (has(b, 0, 'wOF2')) return { mime: 'font/woff2', kind: 'binary' }; + if (has(b, 0, 'OTTO')) return { mime: 'font/otf', kind: 'binary' }; + if (u32be(b, 0) === 0x00010000 && b.length >= 12 && u16be(b, 4) > 0 && u16be(b, 4) < 64 && /^(ttf|ttc|otf|)$/.test(ext)) return { mime: 'font/ttf', kind: 'binary' }; + if (b[0] === 0xd0 && b[1] === 0xcf && b[2] === 0x11 && b[3] === 0xe0 && b[4] === 0xa1 && b[5] === 0xb1 && b[6] === 0x1a && b[7] === 0xe1) { + const ole: Record = { doc: 'application/msword', xls: 'application/vnd.ms-excel', ppt: 'application/vnd.ms-powerpoint', msg: 'application/vnd.ms-outlook', msi: 'application/x-msi' }; + return { mime: ole[ext] ?? 'application/x-ole-storage', kind: 'binary' }; + } + if (b.length > 0x8006 && has(b, 0x8001, 'CD001')) return { mime: 'application/x-iso9660-image', kind: 'binary' }; + // Last: MPEG audio frames start with a sync that random bytes (or a UTF-16 byte-order mark) can have too, so only + // two frames in a row count. + if (has(b, 0, 'ID3')) return mp3(src, b); + if ((b[0] === 0xff && b[1] === 0xfe) || (b[0] === 0xfe && b[1] === 0xff)) return null; + if (b[0] === 0xff && (b[1] & 0xf6) === 0xf0 && adtsFrames(b)) return { mime: 'audio/aac', kind: 'audio' }; + const f = mpegFrame(b, 0); + if (f && mpegFrame(b, f.length)) return mp3(src, b); + return null; +} + +// ------------------------------------------------------------------ text + +/** The encoding of text, or null for binary. */ +export function textEncoding(b: Uint8Array): string | null { + if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) return 'utf-8'; + if (b[0] === 0xff && b[1] === 0xfe) return 'utf-16le'; + if (b[0] === 0xfe && b[1] === 0xff) return 'utf-16be'; + const n = Math.min(b.length, HEAD); + let zeroEven = 0; + let zeroOdd = 0; + let control = 0; + for (let i = 0; i < n; i++) { + const c = b[i]; + if (c === 0) (i & 1 ? zeroOdd++ : zeroEven++); + else if (c < 9 || (c > 13 && c < 32 && c !== 27) || c === 127) control++; + } + if (zeroEven + zeroOdd) { + // ASCII-range text in UTF-16 without a byte-order mark: every other byte is zero. + if (n >= 4 && zeroOdd > n * 0.4 && zeroEven < n * 0.02) return 'utf-16le'; + if (n >= 4 && zeroEven > n * 0.4 && zeroOdd < n * 0.02) return 'utf-16be'; + return null; + } + if (control > Math.max(1, n * 0.01)) return null; + // A multi-byte character cut by the end of what was read is not an error. + let end = n; + for (let k = 1; k <= 3 && end - k >= 0; k++) { + const c = b[end - k]; + if ((c & 0xc0) === 0x80) continue; + if (c >= 0xc0) end -= k; + break; + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(b.subarray(0, end)); + return 'utf-8'; + } catch { + // Not UTF-8 but no control bytes either: an 8-bit code page, most likely Windows' own. + return 'windows-1252'; + } +} + +async function text(src: Source, head: Uint8Array, hint: { name?: string | null; mime?: string | null }): Promise { + const net = essence(hint.mime); + const encoding = src.size === 0 ? 'utf-8' : textEncoding(head); + if (!encoding) { + // Binary nobody here knows: the network's type when it is a specific one. + const keep = plainType(net) && !GENERIC.has(net) && !/^(text|image|video|audio)\//.test(net) && !/json|xml|csv|markdown|javascript|pdf|zip|tar|gzip/.test(net); + return { mime: keep ? net : 'application/octet-stream', kind: 'binary' }; + } + const partial = src.size > TEXT_MAX; + const bytes = src.size <= head.length ? head : await src.read(0, Math.min(src.size, TEXT_MAX)); + const s = new TextDecoder(encoding).decode(bytes).replace(/^\ufeff/, ''); + let lines = 0; + for (let i = s.indexOf('\n'); i !== -1; i = s.indexOf('\n', i + 1)) lines++; + if (s.length && !s.endsWith('\n') && !partial) lines++; + const base = { encoding, lines, ...(partial ? { partial: true } : {}) }; + const as = (language: string | null, kind: P.FileKind = 'text', mime?: string): Partial_ => ({ + mime: mime ?? (language ? (MIME_BY_LANGUAGE[language] ?? 'text/plain') : 'text/plain'), + kind, + ...base, + ...(language ? { language } : {}), + }); + + const start = s.slice(0, 4096).trimStart().toLowerCase(); + // Markup that a browser would run is told by its content, whatever it is called: it is only ever shown as text. + if (/^]/.test(start)) return as('html', 'text', 'text/html'); + if (/^(<\?xml[^>]*>\s*)?(\s*)*(]*>\s*)?]/.test(start)) return as('xml', 'text', 'image/svg+xml'); + if (start.startsWith('= 2 ? 'markdown' : null; +} + +// ------------------------------------------------------------------ images + +async function jpegDims(src: Source): Promise<{ width?: number; height?: number }> { + // Markers one after another; a large EXIF block can push the frame header past the first read. + let at = 2; + for (let n = 0; n < 200 && at + 9 < src.size; n++) { + const h = await src.read(at, 10); + if (h.length < 4 || h[0] !== 0xff) return {}; + const m = h[1]; + if (m === 0xff) { + at++; + continue; + } + if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { + at += 2; + continue; + } + if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return h.length >= 9 ? dims(u16be(h, 7), u16be(h, 5)) : {}; + if (m === 0xd9 || m === 0xda) return {}; + const len = u16be(h, 2); + if (len < 2) return {}; + at += 2 + len; + } + return {}; +} + +function webpDims(b: Uint8Array): { width?: number; height?: number } { + if (has(b, 12, 'VP8 ') && b.length >= 30) return dims(u16le(b, 26) & 0x3fff, u16le(b, 28) & 0x3fff); + if (has(b, 12, 'VP8L') && b.length >= 25) return dims(1 + (((b[22] & 0x3f) << 8) | b[21]), 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6))); + if (has(b, 12, 'VP8X') && b.length >= 30) return dims(1 + (b[24] | (b[25] << 8) | (b[26] << 16)), 1 + (b[27] | (b[28] << 8) | (b[29] << 16))); + return {}; +} + +// ------------------------------------------------------------------ audio and video + +/** MP4, QuickTime, M4A, 3GP, AVIF, HEIC: the brand, then the movie header for duration and the tracks for size. */ +async function isoMedia(src: Source, b: Uint8Array): Promise { + const brand = ascii(b, 8, 12); + if (/^(avif|avis)$/.test(brand)) return { mime: 'image/avif', kind: 'image', ...ispe(b) }; + if (/^(heic|heix|hevc|heim|heis|mif1|msf1)$/.test(brand)) return { mime: 'image/heic', kind: 'binary', ...ispe(b) }; + let at = 0; + for (let n = 0; n < 64 && at + 8 <= src.size; n++) { + const h = await src.read(at, 16); + if (h.length < 8) break; + let size = u32be(h, 0); + const type = ascii(h, 4, 8); + if (size === 1 && h.length >= 16) size = u64be(h, 8); + else if (size === 0) size = src.size - at; + if (size < 8) break; + if (type === 'moov') { + const moov = await src.read(at, Math.min(size, 1024 * 1024)); + const m = movie(moov); + const audioOnly = brand === 'M4A ' || brand === 'M4B ' || (m.tracks > 0 && !m.width); + const mime = audioOnly ? 'audio/mp4' : brand === 'qt ' ? 'video/quicktime' : /^3g/.test(brand) ? 'video/3gpp' : 'video/mp4'; + return { mime, kind: audioOnly ? 'audio' : 'video', ...(m.duration ? { duration: m.duration } : {}), ...(audioOnly ? {} : dims(m.width, m.height)) }; + } + at += size; + } + return { mime: brand === 'M4A ' ? 'audio/mp4' : brand === 'qt ' ? 'video/quicktime' : 'video/mp4', kind: brand === 'M4A ' ? 'audio' : 'video' }; +} + +function ispe(b: Uint8Array) { + const i = ascii(b, 0, b.length).indexOf('ispe'); + return i > 0 && i + 16 <= b.length ? dims(u32be(b, i + 8), u32be(b, i + 12)) : {}; +} + +/** Inside a moov box: mvhd's duration, and the size of the first track that has one. */ +function movie(moov: Uint8Array): { duration?: number; width: number; height: number; tracks: number } { + const out = { duration: undefined as number | undefined, width: 0, height: 0, tracks: 0 }; + const walk = (from: number, to: number, depth: number) => { + let at = from; + while (at + 8 <= to && depth < 4) { + let size = u32be(moov, at); + const type = ascii(moov, at + 4, at + 8); + let body = at + 8; + if (size === 1) { + size = u64be(moov, at + 8); + body += 8; + } else if (size === 0) size = to - at; + if (size < 8) return; + const end = Math.min(to, at + size); + if (type === 'mvhd' && body + 32 <= moov.length) { + const v = moov[body]; + const scale = v === 1 ? u32be(moov, body + 20) : u32be(moov, body + 12); + const dur = v === 1 ? u64be(moov, body + 24) : u32be(moov, body + 16); + if (scale && dur !== 0xffffffff) out.duration = finite(dur / scale); + } else if (type === 'trak') { + out.tracks++; + walk(body, end, depth + 1); + } else if (type === 'tkhd' && body + 84 <= moov.length && !out.width) { + const off = moov[body] === 1 ? 88 : 76; + if (body + off + 8 <= moov.length) { + out.width = Math.round(u32be(moov, body + off) / 65536); + out.height = Math.round(u32be(moov, body + off + 4) / 65536); + } + } + at = end; + } + }; + walk(8, moov.length, 0); + return out; +} + +/** WebM and Matroska: the segment's Info (duration) and Tracks (a video track's size), from the first 512 KiB. */ +async function matroska(src: Source, ext: string): Promise { + const b = await src.read(0, Math.min(src.size, 512 * 1024)); + let doc = ''; + let scale = 1_000_000; + let duration: number | undefined; + let width = 0; + let height = 0; + let video = false; + let tracks = false; + const vint = (at: number, keepMarker: boolean): [value: number, len: number] | null => { + const first = b[at]; + if (first === undefined || first === 0) return null; + const len = Math.clz32(first) - 23; + if (len < 1 || len > 8 || at + len > b.length) return null; + let v = keepMarker ? first : first & (0xff >> len); + let allOnes = v === (0xff >> len); + for (let i = 1; i < len; i++) { + v = v * 256 + b[at + i]; + if (b[at + i] !== 0xff) allOnes = false; + } + return [!keepMarker && allOnes ? -1 : v, len]; + }; + const uint = (at: number, len: number) => { + let v = 0; + for (let i = 0; i < len; i++) v = v * 256 + b[at + i]; + return v; + }; + const MASTER = new Set([0x18538067, 0x1549a966, 0x1654ae6b, 0xae, 0xe0]); + const walk = (from: number, to: number, depth: number) => { + let at = from; + while (at < to && depth < 6) { + const id = vint(at, true); + if (!id) return; + const sz = vint(at + id[1], false); + if (!sz) return; + const body = at + id[1] + sz[1]; + const end = sz[0] < 0 ? to : Math.min(to, body + sz[0]); + const len = end - body; + const tag = id[0]; + if (tag === 0x1f43b675) return; // the first cluster: the headers are over + if (tag === 0x1a45dfa3) walk(body, end, depth + 1); + else if (tag === 0x4282) doc = ascii(b, body, end); + else if (tag === 0x1654ae6b) { + tracks = true; + walk(body, end, depth + 1); + } else if (MASTER.has(tag)) walk(body, end, depth + 1); + else if (tag === 0x83 && uint(body, len) === 1) video = true; + else if (tag === 0x2ad7b1 && len <= 8) scale = uint(body, len) || scale; + else if (tag === 0x4489 && (len === 4 || len === 8)) { + const dv = new DataView(b.buffer, b.byteOffset + body, len); + duration = len === 4 ? dv.getFloat32(0) : dv.getFloat64(0); + } else if (tag === 0xb0 && !width) width = uint(body, len); + else if (tag === 0xba && !height) height = uint(body, len); + at = end; + } + }; + walk(0, b.length, 0); + const audioOnly = tracks && !video; + const mime = doc === 'webm' || ext === 'webm' || ext === 'weba' ? (audioOnly ? 'audio/webm' : 'video/webm') : audioOnly ? 'audio/x-matroska' : 'video/x-matroska'; + const seconds = duration !== undefined ? finite((duration * scale) / 1e9) : undefined; + return { mime, kind: audioOnly ? 'audio' : 'video', ...(seconds ? { duration: seconds } : {}), ...(audioOnly ? {} : dims(width, height)) }; +} + +/** Ogg: the codec from the first packet, the duration from the last page's granule position. */ +async function ogg(src: Source, b: Uint8Array): Promise { + const segs = b[26] ?? 0; + const p = 27 + segs; + let rate = 0; + let skip = 0; + let mime = 'audio/ogg'; + let kind: P.FileKind = 'audio'; + if (has(b, p, 'OpusHead')) { + rate = 48000; + skip = u16le(b, p + 10); + } else if (has(b, p, '\x01vorbis')) rate = u32le(b, p + 12); + else if (has(b, p, '\x80theora')) { + mime = 'video/ogg'; + kind = 'video'; + } else if (has(b, p, '\x7fFLAC')) rate = b.length > p + 30 ? (b[p + 27] << 12) | (b[p + 28] << 4) | (b[p + 29] >> 4) : 0; + if (!rate) return { mime, kind }; + const tailLen = Math.min(src.size, 64 * 1024); + const tail = await src.read(src.size - tailLen, tailLen); + const s = ascii(tail, 0, tail.length); + const serial = u32le(b, 14); + for (let i = s.lastIndexOf('OggS'); i >= 0; i = i > 0 ? s.lastIndexOf('OggS', i - 1) : -1) { + if (i + 18 > tail.length || u32le(tail, i + 14) !== serial) continue; + const granule = u64le(tail, i + 6); + if (granule >= 2 ** 53 || granule <= skip) continue; + const seconds = finite((granule - skip) / rate); + return seconds ? { mime, kind, duration: seconds } : { mime, kind }; + } + return { mime, kind }; +} + +async function wavDuration(src: Source): Promise<{ duration?: number }> { + let at = 12; + let rate = 0; + for (let n = 0; n < 32 && at + 8 <= src.size; n++) { + const h = await src.read(at, 24); + if (h.length < 8) break; + const id = ascii(h, 0, 4); + const size = u32le(h, 4); + if (id === 'fmt ' && h.length >= 20) rate = u32le(h, 16); + if (id === 'data') { + const bytes = size === 0 || size === 0xffffffff ? src.size - at - 8 : Math.min(size, src.size - at - 8); + const seconds = rate ? finite(bytes / rate) : undefined; + return seconds ? { duration: seconds } : {}; + } + at += 8 + size + (size & 1); + } + return {}; +} + +function flacDuration(b: Uint8Array): { duration?: number } { + if ((b[4] & 0x7f) !== 0 || b.length < 26) return {}; + const d = 8; + const rate = (b[d + 10] << 12) | (b[d + 11] << 4) | (b[d + 12] >> 4); + const samples = (b[d + 13] & 0x0f) * 2 ** 32 + u32be(b, d + 14); + const seconds = rate ? finite(samples / rate) : undefined; + return seconds ? { duration: seconds } : {}; +} + +const MPEG_RATES = [ + [11025, 12000, 8000], // 2.5 + [0, 0, 0], + [22050, 24000, 16000], // 2 + [44100, 48000, 32000], // 1 +]; +const MPEG_KBPS: Record = { + '1-1': [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448], + '1-2': [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384], + '1-3': [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], + '2-1': [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256], + '2-2': [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160], +}; + +/** An MPEG audio frame header at `i`: its version, layer, rate and bitrate; null when it isn't one. */ +function mpegFrame(b: Uint8Array, i: number) { + if (i + 4 > b.length || b[i] !== 0xff || (b[i + 1] & 0xe0) !== 0xe0) return null; + const ver = (b[i + 1] >> 3) & 3; + const layer = 4 - ((b[i + 1] >> 1) & 3); + const bri = b[i + 2] >> 4; + const sri = (b[i + 2] >> 2) & 3; + if (ver === 1 || layer === 4 || bri === 0 || bri === 15 || sri === 3) return null; + const kbps = MPEG_KBPS[`${ver === 3 ? 1 : 2}-${ver === 3 ? layer : layer === 1 ? 1 : 2}`][bri]; + const rate = MPEG_RATES[ver][sri]; + const spf = layer === 1 ? 384 : layer === 2 || ver === 3 ? 1152 : 576; + const pad = (b[i + 2] >> 1) & 1; + const length = layer === 1 ? (Math.floor((12 * kbps * 1000) / rate) + pad) * 4 : Math.floor(((spf / 8) * kbps * 1000) / rate) + pad; + return { ver, layer, kbps, rate, spf, length, mono: b[i + 3] >> 6 === 3 }; +} + +/** Two AAC ADTS frames in a row: the frame length is 13 bits from the fourth byte. */ +function adtsFrames(b: Uint8Array): boolean { + const len = ((b[3] & 0x03) << 11) | (b[4] << 3) | (b[5] >> 5); + return len > 7 && len + 2 <= b.length && b[len] === 0xff && (b[len + 1] & 0xf6) === 0xf0; +} + +async function mp3(src: Source, b: Uint8Array): Promise { + let start = 0; + if (has(b, 0, 'ID3') && b.length >= 10) start = 10 + ((b[6] & 0x7f) << 21) + ((b[7] & 0x7f) << 14) + ((b[8] & 0x7f) << 7) + (b[9] & 0x7f) + (b[5] & 0x10 ? 10 : 0); + const h = await src.read(start, 256); + // A tag can be padded: the first frame within the next few bytes. + let i = 0; + while (i < h.length - 4 && !mpegFrame(h, i)) i++; + const f = mpegFrame(h, i); + if (!f) return { mime: 'audio/mpeg', kind: 'audio' }; + const side = f.ver === 3 ? (f.mono ? 17 : 32) : f.mono ? 9 : 17; + const x = i + 4 + side; + let frames = 0; + if ((has(h, x, 'Xing') || has(h, x, 'Info')) && u32be(h, x + 4) & 1) frames = u32be(h, x + 8); + else if (has(h, i + 36, 'VBRI')) frames = u32be(h, i + 36 + 14); + const seconds = frames ? frames * (f.spf / f.rate) : f.kbps ? ((src.size - start - i) * 8) / (f.kbps * 1000) : 0; + return { mime: 'audio/mpeg', kind: 'audio', ...(finite(seconds) ? { duration: finite(seconds) } : {}) }; +} + +// ------------------------------------------------------------------ PDF + +/** + * Page count: a linearized file says it at the top (/N); otherwise the largest /Count of a /Pages node, in the plain + * text or inside compressed object streams, looked for in the first 4 MiB and the last 1 MiB. + */ +async function pdfPages(src: Source, head: Uint8Array): Promise<{ pages?: number }> { + const top = ascii(head, 0, 2048); + const lin = top.match(/\/Linearized[\s\S]{0,400}?\/N\s+(\d+)/); + if (lin) return { pages: Number(lin[1]) }; + const whole = src.size <= PDF_HEAD + PDF_TAIL; + const parts = whole ? [await src.read(0, src.size)] : [await src.read(0, PDF_HEAD), await src.read(src.size - PDF_TAIL, PDF_TAIL)]; + let best = 0; + let budget = INFLATE_MAX * 2; + for (const part of parts) { + const s = latin1.decode(part); + best = Math.max(best, maxCount(s)); + // Object streams (PDF 1.5): page tree nodes may be in there, compressed. + for (const m of s.matchAll(/\/Type\s*\/ObjStm\b/g)) { + if (budget <= 0) break; + const dict = dictAround(s, m.index!); + if (!dict || !/\/FlateDecode/.test(dict.text) || /\/DecodeParms|\/Encrypt/.test(dict.text)) continue; + const st = s.slice(dict.end, dict.end + 64).match(/^\s*stream\r?\n/); + if (!st) continue; + const from = dict.end + st[0].length; + const lenM = dict.text.match(/\/Length\s+(\d+)(?!\s+\d+\s+R)/); + const to = lenM ? from + Number(lenM[1]) : s.indexOf('endstream', from); + if (to <= from || to > part.length) continue; + try { + const out = inflateSync(part.subarray(from, to), { maxOutputLength: Math.min(budget, INFLATE_MAX) }); + budget -= out.length; + best = Math.max(best, maxCount(latin1.decode(out))); + } catch {} + } + } + if (!best && whole) { + // An odd file with a page tree we could not find: count the pages themselves. + const s = latin1.decode(parts[0]); + best = (s.match(/\/Type\s*\/Page(?![\w])/g) ?? []).length; + } + return best > 0 && best < 1e6 ? { pages: best } : {}; +} + +function maxCount(s: string): number { + let best = 0; + for (const m of s.matchAll(/\/Type\s*\/Pages\b/g)) { + const d = dictAround(s, m.index!); + const c = d?.text.match(/\/Count\s+(\d+)/); + if (c) best = Math.max(best, Number(c[1])); + } + return best; +} + +/** The innermost `<< … >>` around `at`: its text and where it ends. */ +function dictAround(s: string, at: number): { text: string; end: number } | null { + let open = s.lastIndexOf('<<', at); + // Step out of dictionaries that close before `at` (a /Resources << … >> before /Type). + for (let guard = 0; open >= 0 && guard < 32; guard++) { + const close = s.indexOf('>>', open); + if (close === -1 || close > at) break; + open = s.lastIndexOf('<<', open - 1); + } + if (open < 0) return null; + let depth = 0; + for (let i = open; i < Math.min(s.length, open + 1024 * 1024); i++) { + if (s[i] === '<' && s[i + 1] === '<') { + depth++; + i++; + } else if (s[i] === '>' && s[i + 1] === '>') { + depth--; + i++; + if (!depth) return { text: s.slice(open, i + 1), end: i + 1 }; + } + } + return null; +} + +// ------------------------------------------------------------------ archives + +const entryName = (b: Uint8Array, utf8: boolean) => cleanName(utf8 ? new TextDecoder().decode(b) : latin1.decode(b)); + +/** ZIP and what is built on it (Office, OpenDocument, EPUB, JAR, APK): the central directory at the end. */ +async function zip(src: Source, head: Uint8Array, ext: string): Promise { + const tailLen = Math.min(src.size, 65_557); + const tail = await src.read(src.size - tailLen, tailLen); + const t = ascii(tail, 0, tail.length); + const e = t.lastIndexOf('PK\x05\x06'); + const names: P.ArchiveEntry[] = []; + const all: string[] = []; + let total = 0; + let partial = false; + if (e >= 0 && e + 22 <= tail.length) { + total = u16le(tail, e + 10); + let cdSize = u32le(tail, e + 12); + let cdAt = u32le(tail, e + 16); + if ((total === 0xffff || cdAt === 0xffffffff) && e >= 20 && has(tail, e - 20, 'PK\x06\x07')) { + const z = await src.read(u64le(tail, e - 20 + 8), 56); + if (has(z, 0, 'PK\x06\x06')) { + total = u64le(z, 32); + cdSize = u64le(z, 40); + cdAt = u64le(z, 48); + } + } + const cd = await src.read(cdAt, Math.min(cdSize, 2 * 1024 * 1024)); + let i = 0; + while (i + 46 <= cd.length && has(cd, i, 'PK\x01\x02') && all.length < WALK_MAX) { + const flags = u16le(cd, i + 8); + let size = u32le(cd, i + 24); + const nl = u16le(cd, i + 28); + const xl = u16le(cd, i + 30); + const cl = u16le(cd, i + 32); + const name = entryName(cd.subarray(i + 46, i + 46 + nl), !!(flags & 0x800)); + if (size === 0xffffffff) { + // The real size is in the zip64 extra field. + const x = cd.subarray(i + 46 + nl, i + 46 + nl + xl); + for (let j = 0; j + 4 <= x.length; j += 4 + u16le(x, j + 2)) if (u16le(x, j) === 1 && j + 12 <= x.length) size = u64le(x, j + 4); + } + all.push(name); + if (names.length < LIST_MAX) names.push({ name, size, ...(name.endsWith('/') ? { dir: true } : {}) }); + i += 46 + nl + xl + cl; + } + if (all.length < total) partial = true; + } else { + // No directory (cut short): walk the entries from the front while their sizes are known. + partial = true; + let at = 0; + while (all.length < WALK_MAX && at + 30 <= src.size) { + const h = await src.read(at, 30 + 1024); + if (!has(h, 0, 'PK\x03\x04')) break; + const flags = u16le(h, 6); + const csize = u32le(h, 18); + const size = u32le(h, 22); + const nl = u16le(h, 26); + const xl = u16le(h, 28); + const name = entryName(h.subarray(30, 30 + nl), !!(flags & 0x800)); + all.push(name); + if (names.length < LIST_MAX) names.push({ name, size, ...(name.endsWith('/') ? { dir: true } : {}) }); + if (flags & 8) break; // sizes come after the data + at += 30 + nl + xl + csize; + } + total = all.length; + } + let mime = 'application/zip'; + let kind: P.FileKind = 'archive'; + const set = new Set(all); + const any = (prefix: string) => all.some((n) => n.startsWith(prefix)); + if (set.has('[Content_Types].xml')) { + kind = 'binary'; + if (any('word/')) mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + else if (any('xl/')) mime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + else if (any('ppt/')) mime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + else kind = 'archive'; + } else if (has(head, 30, 'mimetype') && u16le(head, 26) === 8 && u16le(head, 8) === 0) { + // OpenDocument and EPUB: an uncompressed first entry holding the type. + const len = u32le(head, 18); + const m = ascii(head, 38 + u16le(head, 28), 38 + u16le(head, 28) + Math.min(len, 100)).trim(); + if (plainType(m)) { + mime = m; + kind = 'binary'; + } + } else if (set.has('AndroidManifest.xml') && set.has('classes.dex')) mime = 'application/vnd.android.package-archive'; + else if (set.has('META-INF/MANIFEST.MF') && (ext === 'jar' || all.some((n) => n.endsWith('.class')))) mime = 'application/java-archive'; + return { mime, kind, entries: total, list: names, ...(partial ? { partial: true } : {}) }; +} + +/** Reads through a window, so walking many small headers in a row is a few reads, not one each. */ +function windowed(src: Source, win = 256 * 1024): Source & { reads: number } { + let base = -1; + let buf: Uint8Array = new Uint8Array(0); + const w = { + size: src.size, + reads: 0, + async read(at: number, len: number) { + if (len > win) return src.read(at, len); + if (base < 0 || at < base || at + len > base + buf.length) { + base = at; + buf = await src.read(at, win); + w.reads++; + } + return buf.subarray(at - base, at - base + len); + }, + }; + return w; +} + +const octal = (b: Uint8Array, from: number, len: number) => { + // GNU base-256 for sizes past 8 GiB. + if (b[from] & 0x80) { + let v = b[from] & 0x7f; + for (let i = 1; i < len; i++) v = v * 256 + b[from + i]; + return v; + } + const s = ascii(b, from, from + len).replace(/\0.*$/, '').trim(); + return /^[0-7]+$/.test(s) ? parseInt(s, 8) : NaN; +}; + +/** A tar's entries, header by header; `size` is how much of it there is. */ +async function tar(src: Source, size: number): Promise<{ entries: number; list: P.ArchiveEntry[]; partial?: boolean }> { + const list: P.ArchiveEntry[] = []; + let entries = 0; + let at = 0; + let longName: string | null = null; + let ended = false; + while (entries < WALK_MAX && at + 512 <= size) { + const h = await src.read(at, 512); + if (h.length < 512) break; + if (h.every((c) => c === 0)) { + ended = true; + break; + } + // The checksum, so a run into junk stops instead of listing it. + let sum = 0; + for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : h[i]; + if (octal(h, 148, 8) !== sum) break; + const len = octal(h, 124, 12); + if (!Number.isFinite(len) || len < 0) break; + const type = String.fromCharCode(h[156] || 48); + const field = (from: number, len: number) => { + const f = h.subarray(from, from + len); + const nul = f.indexOf(0); + return new TextDecoder().decode(nul === -1 ? f : f.subarray(0, nul)); + }; + let name = field(0, 100); + if (has(h, 257, 'ustar')) { + const prefix = field(345, 155); + if (prefix) name = `${prefix}/${name}`; + } + const data = at + 512; + if (type === 'L' || type === 'x') { + // GNU long name, or a pax header that may carry path= + const body = new TextDecoder().decode(await src.read(data, Math.min(len, 8192))); + if (type === 'L') longName = body.replace(/\0.*$/s, ''); + else longName = body.match(/(?:^|\n)\d+ path=([^\n]*)\n/)?.[1] ?? longName; + } else if (type !== 'g') { + const shown = cleanName(longName ?? name); + longName = null; + entries++; + if (list.length < LIST_MAX) list.push({ name: shown, size: type === '5' ? 0 : len, ...(type === '5' || shown.endsWith('/') ? { dir: true } : {}) }); + } + at = data + Math.ceil(len / 512) * 512; + } + // No end marker: cut short, or more entries than were walked. + return { entries, list, ...(ended ? {} : { partial: true }) }; +} + +/** Up to `maxOut` bytes out of a gzip; complete when the whole stream was read and ended cleanly. */ +export async function gunzipHead(src: Source, maxOut = INFLATE_MAX): Promise<{ out: Uint8Array; complete: boolean }> { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + let stop = false; + const CHUNK = 256 * 1024; + const pump = (async () => { + for (let at = 0; at < src.size && !stop; at += CHUNK) await writer.write(new Uint8Array(await src.read(at, CHUNK))); + if (!stop) await writer.close(); + })().catch(() => {}); + const parts: Uint8Array[] = []; + let n = 0; + let complete = false; + try { + while (n < maxOut) { + const r = await reader.read(); + if (r.done) { + complete = true; + break; + } + parts.push(r.value); + n += r.value.length; + } + } catch { + // Cut short or not gzip after all: what came out so far. + } + stop = true; + await reader.cancel().catch(() => {}); + await writer.abort().catch(() => {}); + await pump; + const out = new Uint8Array(Math.min(n, maxOut)); + let at = 0; + for (const p of parts) { + if (at >= out.length) break; + out.set(p.subarray(0, out.length - at), at); + at += p.length; + } + return { out, complete: complete && n <= maxOut }; +} + +async function gzip(src: Source, b: Uint8Array, name: string | null | undefined): Promise { + const flags = b[3]; + let i = 10; + if (flags & 4) i += 2 + u16le(b, 10); + let inner = ''; + if (flags & 8) { + const end = b.indexOf(0, i); + if (end > i) inner = cleanName(latin1.decode(b.subarray(i, Math.min(end, i + 1024)))); + } + if (!inner) inner = cleanName(name).replace(/\.t?gz$/i, (m) => (m.toLowerCase() === '.tgz' ? '.tar' : '')) || 'file'; + const isize = src.size >= 18 ? u32le(await src.read(src.size - 4, 4), 0) : 0; + const { out, complete } = await gunzipHead(src); + if (out.length >= 263 && has(out, 257, 'ustar')) { + const t = await tar(bytesSource(out), out.length); + return { mime: 'application/gzip', kind: 'archive', entries: t.entries, list: t.list, ...(t.partial || !complete ? { partial: true } : {}) }; + } + return { mime: 'application/gzip', kind: 'archive', entries: 1, list: [{ name: inner, size: complete ? out.length : isize }] }; +} diff --git a/packages/hub/src/mediaarchive.ts b/packages/hub/src/mediaarchive.ts index c451f3c..1857463 100644 --- a/packages/hub/src/mediaarchive.ts +++ b/packages/hub/src/mediaarchive.ts @@ -26,6 +26,8 @@ const REFRESH_PAUSE_MS = 10 * 60_000; /** New links for old ones, keyed by the old link; empty when the account knows none. Throws when it can't ask. */ export type Refresh = (accountId: string, urls: string[]) => Promise>; +/** A file now in the cache, with what its attachment says about it (mediainfo.ts looks at it). */ +export type Kept = (a: { url: string; name: string | null; mime: string | null; messageId: string }) => Promise; /** The part of a Discord link its signature leaves alone: where the file is. */ export const linkPath = (url: string) => { @@ -56,6 +58,7 @@ export class MediaArchiver { private fetchOne: (url: string, accountId: string | null, from?: string) => Promise, private log: (msg: string) => void = () => {}, private refresh?: Refresh, + private onKept?: Kept, ) {} start(everyMs = 60_000) { @@ -98,14 +101,14 @@ export class MediaArchiver { for (;;) { const rows = this.store.db .query( - `SELECT a.rowid AS rid, a.url, (SELECT account_id FROM message_seen s WHERE s.message_id = a.message_id LIMIT 1) AS account + `SELECT a.rowid AS rid, a.url, a.name, a.mime, a.message_id, (SELECT account_id FROM message_seen s WHERE s.message_id = a.message_id LIMIT 1) AS account FROM attachment a WHERE a.rowid > ? ORDER BY a.rowid LIMIT ?`, ) .all(this.cursor, BATCH) as Row[]; if (!rows.length) break; for (const r of rows) { if (this.stopped) return; - if (MediaArchiver.remote(r.url)) await this.keep(r.url, r.account); + if (MediaArchiver.remote(r.url) && (await this.keep(r.url, r.account))) await this.onKept?.({ url: r.url, name: r.name, mime: r.mime, messageId: r.message_id }); this.cursor = r.rid; } } @@ -118,17 +121,18 @@ export class MediaArchiver { } } - private async keep(url: string, account: string | null) { + /** True when it fetched the file just now. */ + private async keep(url: string, account: string | null): Promise { const key = new Bun.CryptoHasher('sha256').update(url).digest('hex'); - if (existsSync(join(this.cacheDir, key))) return; + if (existsSync(join(this.cacheDir, key))) return false; const fail = this.store.db.query('SELECT tries FROM media_archive_fail WHERE key = ?').get(key) as Row | null; - if (fail) return; // retry() takes these + if (fail) return false; // retry() takes these let ok = false; let error: string | null = null; const markExpired = () => this.store.db.query('INSERT INTO media_archive_fail (key, tries, last_at, error) VALUES (?, ?, ?, ?) ON CONFLICT (key) DO UPDATE SET tries = excluded.tries, last_at = excluded.last_at, error = excluded.error').run(key, MAX_TRIES, Date.now(), 'expired'); // Signed again with others in a batch (refreshExpired), not one request each. - if (this.refresh && MediaArchiver.expired(url)) return markExpired(); + if (this.refresh && MediaArchiver.expired(url)) return (markExpired(), false); try { ok = await this.fetchOne(url, account); } catch (e: any) { @@ -139,6 +143,7 @@ export class MediaArchiver { // A signed link past its expiry won't work on a retry; only a refreshed link could (error 'expired' marks them). markExpired(); else this.store.db.query('INSERT INTO media_archive_fail (key, tries, last_at, error) VALUES (?, 1, ?, ?) ON CONFLICT (key) DO UPDATE SET tries = tries + 1, last_at = excluded.last_at, error = excluded.error').run(key, Date.now(), error); + return ok; } /** @@ -151,10 +156,10 @@ export class MediaArchiver { if (!want.size) return; const now = Date.now(); const paused = (a: string) => (this.accountPausedUntil.get(a) ?? 0) > now; - const byAccount = new Map(); + const byAccount = new Map(); let n = 0; let waiting = 0; - for (const r of this.store.db.query(`SELECT a.url, (SELECT group_concat(account_id, ' ') FROM message_seen s WHERE s.message_id = a.message_id) AS accounts FROM attachment a`).iterate() as Iterable) { + for (const r of this.store.db.query(`SELECT a.url, a.name, a.mime, a.message_id, (SELECT group_concat(account_id, ' ') FROM message_seen s WHERE s.message_id = a.message_id) AS accounts FROM attachment a`).iterate() as Iterable) { if (!want.size || n >= maxBatches * REFRESH_BATCH) break; if (!r.accounts || !MediaArchiver.expired(r.url)) continue; const key = new Bun.CryptoHasher('sha256').update(r.url).digest('hex'); @@ -166,7 +171,7 @@ export class MediaArchiver { continue; } const list = byAccount.get(account) ?? []; - list.push({ key, url: r.url }); + list.push({ key, url: r.url, name: r.name, mime: r.mime, messageId: r.message_id }); byAccount.set(account, list); n++; } @@ -199,6 +204,7 @@ export class MediaArchiver { this.refreshed++; this.kept++; this.store.db.query('DELETE FROM media_archive_fail WHERE key = ?').run(b.key); + await this.onKept?.({ url: b.url, name: b.name, mime: b.mime, messageId: b.messageId }); } else mark.run(MAX_TRIES, Date.now(), 'refreshed, still failed', b.key); } } @@ -214,7 +220,7 @@ export class MediaArchiver { if (!due.length) return; const keys = new Set(due.map((d) => d.key as string)); // Find their URLs again: a failure keeps only the key. - for (const r of this.store.db.query(`SELECT a.url, (SELECT account_id FROM message_seen s WHERE s.message_id = a.message_id LIMIT 1) AS account FROM attachment a`).iterate() as Iterable) { + for (const r of this.store.db.query(`SELECT a.url, a.name, a.mime, a.message_id, (SELECT account_id FROM message_seen s WHERE s.message_id = a.message_id LIMIT 1) AS account FROM attachment a`).iterate() as Iterable) { if (!keys.size || this.stopped) break; if (!MediaArchiver.remote(r.url)) continue; const key = new Bun.CryptoHasher('sha256').update(r.url).digest('hex'); @@ -230,6 +236,7 @@ export class MediaArchiver { if (ok) { this.kept++; this.store.db.query('DELETE FROM media_archive_fail WHERE key = ?').run(key); + await this.onKept?.({ url: r.url, name: r.name, mime: r.mime, messageId: r.message_id }); } else this.store.db.query('UPDATE media_archive_fail SET tries = tries + 1, last_at = ? WHERE key = ?').run(Date.now(), key); } } diff --git a/packages/hub/src/mediainfo.ts b/packages/hub/src/mediainfo.ts new file mode 100644 index 0000000..dd9868d --- /dev/null +++ b/packages/hub/src/mediainfo.ts @@ -0,0 +1,220 @@ +/** + * Looks at every kept file once (fileinfo.ts) and records what it is, so the app can show the true type, size, pages + * or lines on a file's card and pick a viewer (docs/files.md). + * + * Attachments are walked by rowid from a cursor kept in meta, like the media archive (mediaarchive.ts), so a restart + * carries on. On a hub that archives media the walk stays behind the archive's cursor, so a file is looked at once its + * bytes are kept; a demo hub, which keeps nothing ahead of time, fetches its files (all local) first. One file at a + * time with a pause between, at most a few seconds' work a tick. Inline media (data:) is left alone. + * + * What it found is keyed like the media cache, by the SHA-256 of the stored URL, so it follows the bytes: an edited + * message's attachments are new rows with the same URLs. A newer sniffer (SNIFF_VERSION) starts the walk over. + */ +import { existsSync, statSync } from 'node:fs'; +import { join, normalize, resolve } from 'node:path'; +import type { Database } from 'bun:sqlite'; +import type * as P from '@dither-chat/protocol'; +import { analyze, fileSource, SNIFF_VERSION } from './fileinfo.ts'; +import type { Store } from './store.ts'; + +type Row = Record; +const BATCH = 200; +/** Work in one tick before it yields to the next. */ +const TICK_MS = 4000; +/** Between two files. */ +const PAUSE_MS = 5; +/** Messages newer than this get their new info sent to the apps that show them. */ +const FRESH_MS = 2 * 86_400_000; + +export const mediaKey = (url: string) => new Bun.CryptoHasher('sha256').update(url).digest('hex'); + +/** What the hub has found for these URLs, by URL. */ +export function infoFor(db: Database, urls: string[]): Map { + const keyed = new Map(); + for (const u of urls) if (u && !u.startsWith('data:') && !keyed.has(u)) keyed.set(u, mediaKey(u)); + const out = new Map(); + if (!keyed.size) return out; + const keys = [...new Set(keyed.values())]; + const rows = db.query(`SELECT * FROM media_info WHERE key IN (${keys.map(() => '?').join(',')})`).all(...keys) as Row[]; + const byKey = new Map(rows.map((r) => [r.key as string, view(r)])); + for (const [u, k] of keyed) { + const v = byKey.get(k); + if (v) out.set(u, v); + } + return out; +} + +function view(r: Row): P.FileInfo { + const list = r.list ? (JSON.parse(r.list) as P.ArchiveEntry[]) : undefined; + return { + mime: r.mime, + kind: r.kind, + size: r.size, + ...(r.pages != null ? { pages: r.pages } : {}), + ...(r.encoding != null ? { encoding: r.encoding } : {}), + ...(r.lines != null ? { lines: r.lines } : {}), + ...(r.language != null ? { language: r.language } : {}), + ...(r.entries != null ? { entries: r.entries } : {}), + ...(list ? { list } : {}), + ...(r.width != null ? { width: r.width } : {}), + ...(r.height != null ? { height: r.height } : {}), + ...(r.duration != null ? { duration: r.duration } : {}), + ...(r.partial ? { partial: true } : {}), + }; +} + +export class MediaInfo { + private timer?: Timer; + private busy = false; + stopped = false; + /** Files looked at since start, for the log and tests. */ + looked = 0; + + constructor( + private store: Store, + private dataDir: string, + private opts: { + /** Walk only as far as the media archive has got (a hub that archives). */ + behindArchive: boolean; + /** Put a file's bytes in the cache when they aren't there (a demo hub): true when they are afterwards. */ + fetch?: (url: string, accountId: string | null) => Promise; + log?: (msg: string) => void; + }, + ) { + if (this.meta('media_info_version') !== String(SNIFF_VERSION)) { + this.setMeta('media_info_cursor', '0'); + this.setMeta('media_info_version', String(SNIFF_VERSION)); + } + } + + private get cacheDir() { + return join(this.dataDir, 'media-cache'); + } + private meta(k: string): string | null { + return ((this.store.db.query(`SELECT value FROM meta WHERE key = ?`).get(k) as Row | null)?.value as string) ?? null; + } + private setMeta(k: string, v: string) { + this.store.db.query(`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(k, v); + } + + start(everyMs = 30_000, firstAfterMs = 15_000) { + this.timer = setInterval(() => void this.tick(), everyMs); + setTimeout(() => void this.tick(), firstAfterMs); + } + + stop() { + this.stopped = true; + clearInterval(this.timer); + } + + /** Where the bytes behind a stored URL are on disk, if they are. */ + pathOf(url: string): string | null { + if (url.startsWith('data:')) return null; + if (url.startsWith('file://')) { + const p = normalize(decodeURIComponent(url.slice('file://'.length))); + const root = resolve(this.dataDir); + return resolve(p).startsWith(root + '/') && existsSync(p) && statSync(p).isFile() ? p : null; + } + const p = join(this.cacheDir, mediaKey(url)); + return existsSync(p) ? p : null; + } + + /** + * Looks at one attachment's file now, if its bytes are here, and records it. `fetch`: put them here first if they + * aren't (the app asked for this one). Null when there are no bytes to look at. + */ + async lookAt(a: { url: string; name: string | null; mime: string | null; account: string | null }, fetch?: (url: string, accountId: string | null) => Promise): Promise { + if (a.url.startsWith('data:')) return null; + let path = this.pathOf(a.url); + if (!path && fetch && !a.url.startsWith('file://')) { + if (await fetch(a.url, a.account).catch(() => false)) path = this.pathOf(a.url); + } + if (!path) return null; + const info = await analyze(fileSource(path), { name: a.name, mime: a.mime }); + this.save(mediaKey(a.url), info); + this.looked++; + return info; + } + + private save(key: string, i: P.FileInfo) { + this.store.db + .query( + `INSERT INTO media_info (key, mime, kind, size, pages, encoding, lines, language, entries, list, width, height, duration, partial, version, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (key) DO UPDATE SET mime = excluded.mime, kind = excluded.kind, size = excluded.size, pages = excluded.pages, encoding = excluded.encoding, + lines = excluded.lines, language = excluded.language, entries = excluded.entries, list = excluded.list, width = excluded.width, height = excluded.height, + duration = excluded.duration, partial = excluded.partial, version = excluded.version, at = excluded.at`, + ) + .run(key, i.mime, i.kind, i.size, i.pages ?? null, i.encoding ?? null, i.lines ?? null, i.language ?? null, i.entries ?? null, i.list ? JSON.stringify(i.list) : null, i.width ?? null, i.height ?? null, i.duration ?? null, i.partial ? 1 : 0, SNIFF_VERSION, Date.now()); + } + + private known(key: string): boolean { + return !!this.store.db.query('SELECT 1 FROM media_info WHERE key = ? AND version = ?').get(key, SNIFF_VERSION); + } + + async tick(): Promise { + if (this.busy || this.stopped) return; + this.busy = true; + const until = Date.now() + TICK_MS; + try { + for (;;) { + const cursor = Number(this.meta('media_info_cursor') ?? 0); + const limit = this.opts.behindArchive ? Number(this.meta('media_archive_cursor') ?? 0) : Number.MAX_SAFE_INTEGER; + // Inline media is skipped in the query, so a hub full of it (a demo world) passes it in one quick range scan. + const rows = this.store.db + .query(`SELECT a.rowid AS rid, a.url, a.name, a.mime, a.message_id FROM attachment a WHERE a.rowid > ? AND a.rowid <= ? AND substr(a.url, 1, 5) != 'data:' ORDER BY a.rowid LIMIT ?`) + .all(cursor, limit, BATCH) as Row[]; + if (!rows.length) { + // Only inline media (or nothing) is left: the cursor goes past it. + const last = Number((this.store.db.query('SELECT MAX(rowid) AS m FROM attachment WHERE rowid <= ?').get(limit) as Row | null)?.m ?? 0); + if (last > cursor) this.setMeta('media_info_cursor', String(last)); + break; + } + // The cursor is written after each file looked at and at the end of the batch, not for every row passed. + let at = cursor; + try { + for (const r of rows) { + if (this.stopped || Date.now() > until) return; + if (!this.known(mediaKey(r.url))) { + try { + const account = this.opts.fetch ? ((this.store.db.query('SELECT account_id FROM message_seen WHERE message_id = ? LIMIT 1').get(r.message_id) as Row | null)?.account_id ?? null) : null; + const info = await this.lookAt({ url: r.url, name: r.name, mime: r.mime, account }, this.opts.fetch); + // A recent message's card shows it straight away; older ones get it when next loaded. + const m = info && (this.store.db.query('SELECT ts FROM message WHERE id = ?').get(r.message_id) as Row | null); + if (m && m.ts > Date.now() - FRESH_MS) this.store.dirty.messages.set(r.message_id, this.store.dirty.messages.get(r.message_id) ?? false); + } catch (e: any) { + this.opts.log?.(`media info: ${e?.message ?? e}`); + } + this.setMeta('media_info_cursor', String((at = r.rid))); + await Bun.sleep(PAUSE_MS); + } + at = r.rid; + } + } finally { + if (at !== cursor) this.setMeta('media_info_cursor', String(at)); + } + // Between batches, the hub answers whatever came in meanwhile. + await Bun.sleep(PAUSE_MS); + } + } catch (e: any) { + this.opts.log?.(`media info: ${e?.message ?? e}`); + } finally { + this.busy = false; + } + } + + /** + * A file the archive just kept (mediaarchive.ts): looked at now rather than when the walk gets to it, and a recent + * message's card shows it straight away. + */ + async kept(a: { url: string; name: string | null; mime: string | null; messageId: string }) { + if (this.stopped || a.url.startsWith('data:') || this.known(mediaKey(a.url))) return; + try { + const info = await this.lookAt({ ...a, account: null }); + const m = info && (this.store.db.query('SELECT ts FROM message WHERE id = ?').get(a.messageId) as Row | null); + if (m && m.ts > Date.now() - FRESH_MS) this.store.dirty.messages.set(a.messageId, this.store.dirty.messages.get(a.messageId) ?? false); + } catch (e: any) { + this.opts.log?.(`media info: ${e?.message ?? e}`); + } + } +} diff --git a/packages/hub/src/schema.sql b/packages/hub/src/schema.sql index 4c6e718..8351e8a 100644 --- a/packages/hub/src/schema.sql +++ b/packages/hub/src/schema.sql @@ -613,6 +613,27 @@ CREATE TABLE IF NOT EXISTS media_archive_fail ( error TEXT ) WITHOUT ROWID; +-- What each kept file is by its bytes (mediainfo.ts, fileinfo.ts, docs/files.md), keyed like the media cache: the +-- sha256 of the attachment's URL. Names and sizes of an archive's first entries in `list`; nothing is extracted. +CREATE TABLE IF NOT EXISTS media_info ( + key TEXT PRIMARY KEY, + mime TEXT NOT NULL, + kind TEXT NOT NULL, -- protocol FileKind: the viewer that shows it + size INTEGER NOT NULL, + pages INTEGER, + encoding TEXT, + lines INTEGER, + language TEXT, + entries INTEGER, + list TEXT, -- JSON [{name, size, dir?}] + width INTEGER, + height INTEGER, + duration REAL, -- seconds + partial INTEGER NOT NULL DEFAULT 0, -- lines or entries counted in the first part only + version INTEGER NOT NULL, -- fileinfo.ts SNIFF_VERSION + at INTEGER NOT NULL +) WITHOUT ROWID; + -- Who your accounts have seen as a member of which space, from what their clients loaded anyway (a `members` event, -- source 'seen'). Adversarial: written only with the members setting on (docs/history.md). Never the whole list. CREATE TABLE IF NOT EXISTS membership ( diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index 83fe333..494ff82 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -15,6 +15,8 @@ import { cleanFileName, Uploads } from './uploads.ts'; import { ImportRunner } from './imports/engine.ts'; import { GapFill } from './imports/gapfill.ts'; import { linkPath, MediaArchiver } from './mediaarchive.ts'; +import { infoFor, MediaInfo } from './mediainfo.ts'; +import { genericType } from './fileinfo.ts'; import { mediaHeaders, SAFE_HEADERS, sniffMime } from './mediaheaders.ts'; import { ImportSources } from './imports/sources.ts'; import { Calls, type CallSocketData, type TurnConfig, type VoiceSocketData } from './calls.ts'; @@ -135,6 +137,8 @@ export class HubServer { readonly importer: ImportRunner; readonly gapfill: GapFill; private archiver?: MediaArchiver; + /** What each kept file is (mediainfo.ts, docs/files.md). */ + readonly mediaInfo: MediaInfo; private typingGate = new TypingGate(); private usage: UsageStore; readonly calls: Calls; @@ -155,8 +159,14 @@ export class HubServer { this.imports = new ImportSources(opts.dataDir, (msg) => console.warn(msg)); this.importer = new ImportRunner(store, this.imports, opts.dataDir, (msg) => console.warn(msg)); this.gapfill = new GapFill(store, (acc, cmd) => this.sup.command(acc, cmd, 180_000), (msg) => console.warn(msg)); + // A demo hub keeps nothing ahead of time: its own files (local) are fetched to be looked at. + this.mediaInfo = new MediaInfo(store, opts.dataDir, { + behindArchive: !!opts.archiveMedia, + fetch: opts.archiveMedia ? undefined : (url, acc) => (/^https?:/i.test(url) ? Promise.resolve(false) : this.archiveOne(url, acc)), + log: (msg) => console.warn(msg), + }); if (opts.archiveMedia) - this.archiver = new MediaArchiver(store, join(opts.dataDir, 'media-cache'), (url, acc, from) => this.archiveOne(url, acc, from), (msg) => console.warn(msg), (acc, urls) => this.refreshUrls(acc, urls)); + this.archiver = new MediaArchiver(store, join(opts.dataDir, 'media-cache'), (url, acc, from) => this.archiveOne(url, acc, from), (msg) => console.warn(msg), (acc, urls) => this.refreshUrls(acc, urls), (a) => this.mediaInfo.kept(a)); this.calls = new Calls(store, sup, { turn: opts.turn, pickAccount: (c, a) => this.pickAccount(c, a) }); this.abouts = new Abouts({ store, @@ -254,6 +264,7 @@ export class HubServer { this.imports.resume(); this.gapfill.resume(); this.archiver?.start(); + this.mediaInfo.start(); this.store.journal.prune(); this.pruneTimer = setInterval(() => this.store.journal.prune(), 3_600_000); return this.server; @@ -265,6 +276,7 @@ export class HubServer { this.importer.stop(); this.gapfill.stopAll(); this.archiver?.stop(); + this.mediaInfo.stop(); this.imports.stop(); this.flush(); this.server?.stop(true); @@ -527,6 +539,20 @@ export class HubServer { const named = new Set(changes.flatMap((c) => [c.identityId, c.actorId, c.target.kind === 'identity' ? c.target.id : undefined].filter((x): x is string => !!x))); return ok({ changes, identities: s.identitiesByIds(named) }); } + // What a file is (docs/files.md): looked at now if the walk hasn't got to it, fetching it first if need be, as + // opening it would. + if ((m = path.match(/^\/api\/attachments\/([\w-]+)\/info$/)) && method === 'GET') { + const a = s.db.query(`SELECT a.url, a.name, a.mime, a.message_id, (SELECT account_id FROM message_seen x WHERE x.message_id = a.message_id LIMIT 1) AS account FROM attachment a WHERE a.id = ?`).get(m[1]) as + | { url: string; name: string | null; mime: string | null; message_id: string; account: string | null } + | null; + if (!a) return bad('no such attachment', 404); + const known = infoFor(s.db, [a.url]).get(a.url); + if (known) return ok(known); + const info = await this.mediaInfo.lookAt({ url: a.url, name: a.name, mime: a.mime, account: a.account }, (u, acc) => this.archiveOne(u, acc)).catch(() => null); + if (!info) return bad(a.url.startsWith('data:') ? 'inline media' : 'the file could not be fetched', 404); + s.dirty.messages.set(a.message_id, s.dirty.messages.get(a.message_id) ?? false); + return ok(info); + } if ((m = path.match(/^\/api\/messages\/([\w-]+)\/history$/))) { const h = s.messageHistory(m[1]); return h ? ok(h) : bad('no such message', 404); @@ -1304,6 +1330,16 @@ export class HubServer { return this.fetchMedia(raw, accountId); } + /** + * The type to serve a kept file as: the network's, unless it said nothing useful (application/octet-stream) and the + * bytes say what it is (mediainfo.ts). mediaHeaders decides what may show inline either way. + */ + private trueType(key: string, stored: string): string { + if (!genericType(stored)) return stored; + const r = this.store.db.query('SELECT mime FROM media_info WHERE key = ?').get(key) as { mime: string } | null; + return r?.mime ?? stored; + } + /** Fetch one URL into the media cache, as showing it would (mediaarchive.ts); true when it is there. */ private async archiveOne(url: string, accountId: string | null, from?: string): Promise { const res = await this.fetchMedia(url, accountId, from); @@ -1347,7 +1383,7 @@ export class HubServer { const t = new Date(); utimesSync(file, t, t); } catch {} - return new Response(Bun.file(file), { headers: headers(readFileSync(file + '.mime', 'utf8')) }); + return new Response(Bun.file(file), { headers: headers(this.trueType(key, readFileSync(file + '.mime', 'utf8'))) }); } let bytes: Uint8Array; @@ -1370,7 +1406,7 @@ export class HubServer { // A file kept on disk (an import, Signal's own copy) has no type recorded: by its name, else by its first bytes. const f = Bun.file(p); const named = f.type.split(';')[0]; - const type = named !== 'application/octet-stream' ? f.type : (sniffMime(new Uint8Array(await f.slice(0, 16).arrayBuffer())) ?? named); + const type = named !== 'application/octet-stream' ? f.type : (sniffMime(new Uint8Array(await f.slice(0, 16).arrayBuffer())) ?? this.trueType(key, named)); return new Response(f, { headers: mediaHeaders(raw, key, type, 'private, max-age=604800') }); } else { if (!accountId) return new Response('no account to resolve media', { status: 404 }); diff --git a/packages/hub/src/store.ts b/packages/hub/src/store.ts index ac3f316..b6b0ffe 100644 --- a/packages/hub/src/store.ts +++ b/packages/hub/src/store.ts @@ -7,6 +7,7 @@ import type * as P from '@dither-chat/protocol'; import { ADVERSARIAL_KEEPS, canonicalReactionKey, DEFAULT_RETENTION, parseSearch, PROTOCOL_VERSION, REACTION_RULES } from '@dither-chat/protocol'; import { binsFor, estimateMessages, snowflakeTime, type ChannelInput } from './msgcount.ts'; import { Journal } from './journal.ts'; +import { infoFor } from './mediainfo.ts'; import { TypingRelay, typingTtl } from './typing.ts'; type Row = Record; @@ -2090,6 +2091,7 @@ export class Store { const ids = rows.map((r) => r.id); const ph = ids.map(() => '?').join(','); const atts = this.db.query(`SELECT * FROM attachment WHERE message_id IN (${ph}) ORDER BY position`).all(...ids) as Row[]; + const infos = atts.length ? infoFor(this.db, atts.map((a) => a.url)) : null; const reacts = this.db.query(`SELECT * FROM reaction WHERE message_id IN (${ph})`).all(...ids) as Row[]; const tallies = this.db.query(`SELECT * FROM reaction_tally WHERE message_id IN (${ph}) ORDER BY seq`).all(...ids) as Row[]; const emoji = new Map( @@ -2166,6 +2168,7 @@ export class Store { width: a.width, height: a.height, url: this.media(a.url, seenVia[0]) ?? '', + ...(infos?.get(a.url) ? { info: infos.get(a.url) } : {}), })), embeds: json<{ embeds?: P.EmbedView[] }>(r.extra, {}).embeds?.map((e) => ({ ...e, image: this.media(e.image, seenVia[0]) ?? undefined })) ?? [], reactions: (RT.get(r.id) ?? []) diff --git a/packages/hub/test/fileinfo.test.ts b/packages/hub/test/fileinfo.test.ts new file mode 100644 index 0000000..263ae92 --- /dev/null +++ b/packages/hub/test/fileinfo.test.ts @@ -0,0 +1,446 @@ +/** + * What a kept file is, by its bytes (fileinfo.ts, mediainfo.ts, docs/files.md): types a network got wrong, files cut + * short, hostile names, bounded reads, the walk over attachments, and how the hub serves a file once it knows. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deflateSync } from 'node:zlib'; +import { analyze, bytesSource, HEAD, LIST_MAX, textEncoding, TEXT_MAX, type Source } from '../src/fileinfo.ts'; +import { infoFor, mediaKey, MediaInfo } from '../src/mediainfo.ts'; +import { storedZip } from '../src/demo.ts'; +import { HubServer, mediaSigner } from '../src/server.ts'; +import { Store, type AccountRow } from '../src/store.ts'; +import { Triage } from '../src/triage.ts'; + +const enc = (s: string) => new TextEncoder().encode(s); +const cat = (...parts: (Uint8Array | number[] | string)[]) => new Uint8Array(Buffer.concat(parts.map((p) => (typeof p === 'string' ? Buffer.from(p, 'latin1') : Buffer.from(p as Uint8Array))))); +const u16le = (n: number) => [n & 0xff, (n >> 8) & 0xff]; +const u32le = (n: number) => [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]; +const u16be = (n: number) => [(n >> 8) & 0xff, n & 0xff]; +const u32be = (n: number) => [(n >>> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +const of = (b: Uint8Array, name?: string | null, mime?: string | null) => analyze(bytesSource(b), { name, mime }); + +// ------------------------------------------------------------------ builders + +const png = (w: number, h: number) => cat([0x89], 'PNG\r\n\x1a\n', u32be(13), 'IHDR', u32be(w), u32be(h), [8, 2, 0, 0, 0], u32be(0), u32be(0), 'IEND', u32be(0)); +const gif = (w: number, h: number) => cat('GIF89a', u16le(w), u16le(h), [0, 0, 0], ';'); +const bmp = (w: number, h: number) => cat('BM', u32le(54), u32le(0), u32le(54), u32le(40), u32le(w), u32le(-h >>> 0), u16le(1), u16le(24), new Array(24).fill(0)); +const webp = (w: number, h: number) => cat('RIFF', u32le(30), 'WEBPVP8X', u32le(10), [0, 0, 0, 0], [(w - 1) & 0xff, ((w - 1) >> 8) & 0xff, 0], [(h - 1) & 0xff, ((h - 1) >> 8) & 0xff, 0]); +/** A JPEG whose frame header comes after 80 KB of metadata, past the first read. */ +const jpeg = (w: number, h: number) => { + const app = (n: number) => cat([0xff, 0xe1], u16be(n + 2), new Uint8Array(n)); + return cat([0xff, 0xd8], app(40000), app(40000), [0xff, 0xc0], u16be(17), [8], u16be(h), u16be(w), [3], new Array(9).fill(0), [0xff, 0xd9]); +}; + +const box = (type: string, ...payload: (Uint8Array | number[] | string)[]) => { + const body = cat(...payload); + return cat(u32be(8 + body.length), type, body); +}; +/** An MP4 with the movie header at the end, after the media, as a phone writes one. */ +const mp4 = (seconds: number, w: number, h: number, audioOnly = false) => { + const mvhd = box('mvhd', [0, 0, 0, 0], u32be(0), u32be(0), u32be(1000), u32be(seconds * 1000), new Array(80).fill(0)); + const tkhd = (tw: number, th: number) => box('tkhd', [0, 0, 0, 3], new Array(20).fill(0), new Array(52).fill(0), u32be(tw * 65536), u32be(th * 65536)); + const traks = audioOnly ? [box('trak', tkhd(0, 0))] : [box('trak', tkhd(0, 0)), box('trak', tkhd(w, h))]; + return cat(box('ftyp', 'isom', u32be(512), 'isomiso2mp41'), box('mdat', new Uint8Array(100_000)), box('moov', mvhd, ...traks)); +}; + +const ebml = (id: number[], ...payload: (Uint8Array | number[] | string)[]) => { + const body = cat(...payload); + const size = body.length < 127 ? [0x80 | body.length] : [0x40 | (body.length >> 8), body.length & 0xff]; + return cat(id, size, body); +}; +const webm = (seconds: number, w: number, h: number) => { + const dur = Buffer.alloc(8); + dur.writeDoubleBE(seconds * 1000); + const header = ebml([0x1a, 0x45, 0xdf, 0xa3], ebml([0x42, 0x82], 'webm')); + const info = ebml([0x15, 0x49, 0xa9, 0x66], ebml([0x2a, 0xd7, 0xb1], [0x0f, 0x42, 0x40]), ebml([0x44, 0x89], dur)); + const tracks = ebml([0x16, 0x54, 0xae, 0x6b], ebml([0xae], ebml([0x83], [1]), ebml([0xe0], ebml([0xb0], u16be(w)), ebml([0xba], u16be(h))))); + // A segment of unknown size, as a recorder writes it. + return cat(header, [0x18, 0x53, 0x80, 0x67, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], info, tracks, ebml([0x1f, 0x43, 0xb6, 0x75], new Uint8Array(50))); +}; + +const wav = (seconds: number, rate = 8000) => { + const data = rate * 2 * seconds; + return cat('RIFF', u32le(36 + data), 'WAVE', 'fmt ', u32le(16), u16le(1), u16le(1), u32le(rate), u32le(rate * 2), u16le(2), u16le(16), 'data', u32le(data), new Uint8Array(data)); +}; +const flac = (seconds: number, rate = 44100) => { + const bits = (BigInt(rate) << 44n) | (1n << 41n) | (15n << 36n) | BigInt(rate * seconds); + const packed = Array.from({ length: 8 }, (_, i) => Number((bits >> BigInt(8 * (7 - i))) & 0xffn)); + return cat('fLaC', [0x80, 0, 0, 34], u16be(4096), u16be(4096), [0, 0, 0, 0, 0, 0], packed, new Uint8Array(16)); +}; +const mp3 = (frames: number) => cat('ID3', [4, 0, 0], [0, 0, 0, 10], new Uint8Array(10), [0xff, 0xfb, 0x90, 0x64], new Uint8Array(32), 'Xing', u32be(1), u32be(frames), new Uint8Array(400)); +const oggPage = (granule: number, serial: number, data: Uint8Array) => { + const g = Buffer.alloc(8); + g.writeBigUInt64LE(BigInt(granule)); + return cat('OggS', [0, 0], g, u32le(serial), u32le(0), u32le(0), [1, data.length], data); +}; +const opus = (seconds: number) => { + const head = cat('OpusHead', [1, 2], u16le(312), u32le(48000), [0, 0, 0]); + return cat(oggPage(0, 7, head), oggPage(0, 7, cat('OpusTags', new Uint8Array(8))), new Uint8Array(3000), oggPage(48000 * seconds + 312, 7, new Uint8Array(40))); +}; + +/** A ustar archive; entries over 100 bytes of name get a GNU long-name entry first. */ +function tar(entries: [name: string, body: string, dir?: boolean][], end = true): Uint8Array { + const blocks: Uint8Array[] = []; + const header = (name: string, size: number, type: string) => { + const h = new Uint8Array(512); + h.set(enc(name).subarray(0, 100), 0); + h.set(enc('0000644\0'), 100); + h.set(enc('0000000\0'), 108); + h.set(enc('0000000\0'), 116); + h.set(enc(size.toString(8).padStart(11, '0') + '\0'), 124); + h.set(enc('00000000000\0'), 136); + h.set(enc(' '), 148); + h[156] = type.charCodeAt(0); + h.set(enc('ustar\0'), 257); + h.set(enc('00'), 263); + const sum = h.reduce((a, b) => a + b, 0); + h.set(enc(sum.toString(8).padStart(6, '0') + '\0 '), 148); + return h; + }; + const pad = (b: Uint8Array) => cat(b, new Uint8Array((512 - (b.length % 512)) % 512)); + for (const [name, body, dir] of entries) { + if (enc(name).length > 100) blocks.push(header('././@LongLink', enc(name).length + 1, 'L'), pad(cat(enc(name), [0]))); + blocks.push(header(name, dir ? 0 : enc(body).length, dir ? '5' : '0')); + if (!dir) blocks.push(pad(enc(body))); + } + if (end) blocks.push(new Uint8Array(1024)); + return cat(...blocks); +} + +/** A PDF whose page tree has `pages` pages. */ +function pdf(pages: number, opts: { compressed?: boolean; linearized?: number } = {}): Uint8Array { + const kids = Array.from({ length: pages }, (_, i) => `${i + 3} 0 R`).join(' '); + const tree = `<< /Type /Pages /Kids [${kids}] /Count ${pages} >>`; + let out = '%PDF-1.7\n%\xe2\xe3\xcf\xd3\n'; + if (opts.linearized) out += `1 0 obj\n<< /Linearized 1 /L 9999 /H [ 600 200 ] /O 3 /E 900 /N ${opts.linearized} /T 9000 >>\nendobj\n`; + out += `2 0 obj\n<< /Type /Catalog /Pages 9 0 R >>\nendobj\n`; + for (let i = 0; i < pages; i++) out += `${i + 3} 0 obj\n<< /Type /Page /Parent 9 0 R /MediaBox [0 0 612 792] >>\nendobj\n`; + if (!opts.compressed) return cat(out, `9 0 obj\n${tree}\nendobj\ntrailer\n<< /Root 2 0 R >>\n%%EOF\n`); + const z = deflateSync(Buffer.from(`9 0 ${tree}`)); + return cat(out, `20 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length ${z.length} >>\nstream\n`, z, '\nendstream\nendobj\n%%EOF\n'); +} + +/** Counts what a source hands out, to show reads stay bounded. */ +function counted(b: Uint8Array): Source & { bytes: number } { + const s = { size: b.length, bytes: 0, read: async (at: number, len: number) => { + const out = b.subarray(at, Math.min(b.length, at + len)); + s.bytes += out.length; + return out; + } }; + return s; +} + +// ------------------------------------------------------------------ the sniffer + +describe('the type comes from the bytes, not the network', () => { + test('a PDF the network calls a PNG', async () => { + const i = await of(pdf(3), 'scan.png', 'image/png'); + expect(i).toMatchObject({ mime: 'application/pdf', kind: 'pdf', pages: 3 }); + }); + test('a PNG called notes.txt and sent as text/plain', async () => { + expect(await of(png(640, 480), 'notes.txt', 'text/plain')).toMatchObject({ mime: 'image/png', kind: 'image', width: 640, height: 480 }); + }); + test('Markdown and JSON sent as application/octet-stream', async () => { + expect(await of(enc('# Notes\n\n- one\n- two\n'), 'notes.md', 'application/octet-stream')).toMatchObject({ mime: 'text/markdown', kind: 'markdown', lines: 4, encoding: 'utf-8' }); + // No name either: valid JSON says so itself. + expect(await of(enc('{"a": [1, 2, {"b": null}]}'), null, 'application/octet-stream')).toMatchObject({ mime: 'application/json', kind: 'json', language: 'json' }); + }); + test("a Rust file that Discord calls application/rls-services+xml", async () => { + expect(await of(enc('fn main() {\n println!("hi");\n}\n'), 'main.rs', 'application/rls-services+xml')).toMatchObject({ mime: 'text/rust', kind: 'text', language: 'rust', lines: 3 }); + }); + test('HTML and SVG are text whatever they are called, and never anything a browser would render', async () => { + expect(await of(enc(''), 'readme.txt', 'text/plain')).toMatchObject({ mime: 'text/html', kind: 'text', language: 'html' }); + expect(await of(enc('\n'), 'logo.png', 'image/png')).toMatchObject({ mime: 'image/svg+xml', kind: 'text' }); + expect(await of(enc(''), 'feed', null)).toMatchObject({ mime: 'application/xml', kind: 'text', language: 'xml' }); + }); + test('a .json file that is not JSON is shown as text', async () => { + expect(await of(enc('{"a": '), 'broken.json')).toMatchObject({ kind: 'text', language: 'json' }); + }); + test('CSV by name or type; a script by its #! line; Markdown by its marks', async () => { + expect(await of(enc('a,b\n1,2\n'), 'data.csv')).toMatchObject({ kind: 'csv', mime: 'text/csv' }); + expect(await of(enc('a\tb\n1\t2\n'), 'export', 'text/tab-separated-values')).toMatchObject({ kind: 'csv', language: 'tsv' }); + expect(await of(enc('#!/usr/bin/env python3\nprint(1)\n'), 'run')).toMatchObject({ kind: 'text', language: 'python' }); + expect(await of(enc('# Title\n\nSee [the docs](https://example.org).\n\n```\ncode\n```\n'), 'README')).toMatchObject({ kind: 'markdown' }); + expect(await of(enc('just some words\non two lines\n'), 'README')).toMatchObject({ kind: 'text', mime: 'text/plain', lines: 2 }); + }); + test('encodings: UTF-16 with and without a byte-order mark, and an 8-bit code page', async () => { + const le = new Uint8Array(Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('hello\nworld\n', 'utf16le')])); + expect(await of(le, 'a.txt')).toMatchObject({ kind: 'text', encoding: 'utf-16le', lines: 2 }); + expect(textEncoding(new Uint8Array(Buffer.from('plain ascii here', 'utf16le')))).toBe('utf-16le'); + expect(await of(new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x0a]), 'menu.txt')).toMatchObject({ kind: 'text', encoding: 'windows-1252' }); + // A multi-byte character cut by the end of the first read is still UTF-8. + const long = enc('é'.repeat(HEAD / 2 + 1)); + expect(textEncoding(long.subarray(0, HEAD - 1))).toBe('utf-8'); + }); + test('binary nobody knows keeps a specific network type, else octet-stream', async () => { + const junk = new Uint8Array(4096).map((_, i) => (i * 7919) & 0xff); + expect(await of(junk, 'x.bin', 'application/x-thing')).toMatchObject({ mime: 'application/x-thing', kind: 'binary' }); + expect(await of(junk, 'x.bin', 'text/plain')).toMatchObject({ mime: 'application/octet-stream', kind: 'binary' }); + expect(await of(cat('\x7fELF', new Uint8Array(60)), 'run.txt', 'text/plain')).toMatchObject({ mime: 'application/x-executable', kind: 'binary' }); + }); + test('an empty file is empty text', async () => { + expect(await of(new Uint8Array(0), 'empty.txt')).toMatchObject({ kind: 'text', lines: 0, size: 0 }); + }); +}); + +describe('structure', () => { + test('image sizes', async () => { + expect(await of(gif(32, 16))).toMatchObject({ mime: 'image/gif', width: 32, height: 16 }); + expect(await of(bmp(100, 50))).toMatchObject({ mime: 'image/bmp', width: 100, height: 50 }); + expect(await of(webp(1878, 983))).toMatchObject({ mime: 'image/webp', width: 1878, height: 983 }); + expect(await of(jpeg(1200, 800))).toMatchObject({ mime: 'image/jpeg', width: 1200, height: 800 }); + }); + test('PDF pages: from the page tree, a compressed object stream, or a linearized header', async () => { + expect((await of(pdf(12))).pages).toBe(12); + expect((await of(pdf(7, { compressed: true }))).pages).toBe(7); + expect((await of(pdf(2, { linearized: 40 }))).pages).toBe(40); + }); + test('audio and video length, and a video frame size', async () => { + expect(await of(mp4(12, 640, 360))).toMatchObject({ mime: 'video/mp4', kind: 'video', duration: 12, width: 640, height: 360 }); + expect(await of(mp4(30, 0, 0, true))).toMatchObject({ mime: 'audio/mp4', kind: 'audio', duration: 30 }); + expect(await of(webm(5, 320, 240))).toMatchObject({ mime: 'video/webm', kind: 'video', duration: 5, width: 320, height: 240 }); + expect(await of(wav(2))).toMatchObject({ mime: 'audio/wav', kind: 'audio', duration: 2 }); + expect(await of(flac(3))).toMatchObject({ mime: 'audio/flac', duration: 3 }); + expect((await of(mp3(100))).duration).toBeCloseTo((100 * 1152) / 44100, 3); + expect(await of(opus(3))).toMatchObject({ mime: 'audio/ogg', kind: 'audio', duration: 3 }); + }); + test('a ZIP: its entries from the central directory, and what is built on ZIP', async () => { + const z = storedZip([['src/', ''], ['src/main.rs', 'fn main() {}'], ['README.md', '# hi']]); + expect(await of(z, 'code.zip')).toMatchObject({ mime: 'application/zip', kind: 'archive', entries: 3, list: [{ name: 'src/', size: 0, dir: true }, { name: 'src/main.rs', size: 12 }, { name: 'README.md', size: 4 }] }); + const xlsx = storedZip([['[Content_Types].xml', ''], ['xl/workbook.xml', '']]); + expect(await of(xlsx, 'budget.xlsx', 'application/octet-stream')).toMatchObject({ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', kind: 'binary' }); + const epub = storedZip([['mimetype', 'application/epub+zip'], ['META-INF/container.xml', '']]); + expect(await of(epub, 'book.epub')).toMatchObject({ mime: 'application/epub+zip', kind: 'binary' }); + }); + test('a ZIP with more entries than the list keeps', async () => { + const z = storedZip(Array.from({ length: 120 }, (_, i) => [`f${i}.txt`, 'x'] as [string, string])); + const i = await of(z); + expect(i.entries).toBe(120); + expect(i.list).toHaveLength(LIST_MAX); + }); + test('tar, tar.gz and a single gzipped file', async () => { + const long = `deep/${'d'.repeat(120)}/file.txt`; + const t = tar([['pkg/', '', true], ['pkg/a.txt', 'hello'], [long, 'x']]); + expect(await of(t, 'pkg.tar')).toMatchObject({ mime: 'application/x-tar', kind: 'archive', entries: 3, list: [{ name: 'pkg/', dir: true }, { name: 'pkg/a.txt', size: 5 }, { name: long, size: 1 }] }); + expect(await of(new Uint8Array(Bun.gzipSync(t)), 'pkg.tar.gz')).toMatchObject({ mime: 'application/gzip', kind: 'archive', entries: 3 }); + expect(await of(new Uint8Array(Bun.gzipSync(enc('a'.repeat(5000)))), 'log.txt.gz')).toMatchObject({ kind: 'archive', entries: 1, list: [{ name: 'log.txt', size: 5000 }] }); + }); +}); + +describe('files cut short', () => { + test('nothing throws, and what can still be read is', async () => { + expect(await of(png(10, 10).subarray(0, 12))).toMatchObject({ mime: 'image/png', kind: 'image' }); + expect((await of(png(10, 10).subarray(0, 12))).width).toBeUndefined(); + expect(await of(jpeg(10, 10).subarray(0, 50000))).toMatchObject({ mime: 'image/jpeg' }); + expect(await of(gif(1, 1).subarray(0, 7))).toMatchObject({ mime: 'image/gif' }); + expect(await of(mp4(3, 10, 10).subarray(0, 2000))).toMatchObject({ mime: 'video/mp4', kind: 'video' }); + expect(await of(webm(3, 10, 10).subarray(0, 30))).toMatchObject({ kind: 'video' }); + expect((await of(wav(2).subarray(0, 8044))).duration).toBeCloseTo(0.5, 2); + expect(await of(opus(2).subarray(0, 60))).toMatchObject({ mime: 'audio/ogg' }); + expect(await of(pdf(4).subarray(0, 60))).toMatchObject({ mime: 'application/pdf', kind: 'pdf' }); + }); + test('a ZIP without its directory: the entries before the cut, marked partial', async () => { + const z = storedZip([['a.txt', 'aaa'], ['b.txt', 'bbbb'], ['c.txt', 'cc']]); + const i = await of(z.subarray(0, 75)); + expect(i).toMatchObject({ kind: 'archive', partial: true }); + expect(i.list!.map((e) => e.name)).toEqual(['a.txt', 'b.txt']); + }); + test('a tar cut in the middle, and a gzip cut short', async () => { + const t = tar([['a', 'x'.repeat(2000)], ['b', 'y']]); + expect(await of(t.subarray(0, 1500), 'x.tar')).toMatchObject({ kind: 'archive', entries: 1, partial: true }); + const gz = new Uint8Array(Bun.gzipSync(t)); + const cut = await of(gz.subarray(0, gz.length - 30), 'x.tar.gz'); + expect(cut.kind).toBe('archive'); + expect(cut.partial).toBe(true); + }); +}); + +describe('hostile names', () => { + test("a direction override can't make a name end in another extension", async () => { + // Shown, "invoicefdp.exe" reads as invoiceexe.pdf; the bytes are text, and stay text. + expect(await of(enc('hello\n'), 'invoice\u202efdp.exe', 'application/pdf')).toMatchObject({ kind: 'text', mime: 'text/plain' }); + expect(await of(enc('just words\n'), 'song\u202e3pm.txt', 'audio/mpeg')).toMatchObject({ kind: 'text', mime: 'text/plain' }); + }); + test('paths, dots, control characters and absurd lengths are only names', async () => { + expect(await of(enc('# Up\n\n- a\n- b\n'), '../../etc/passwd.md')).toMatchObject({ kind: 'markdown' }); + expect(await of(enc('x\n'), '.md')).toMatchObject({ kind: 'text', mime: 'text/plain' }); + expect(await of(enc('x\n'), 'a\u0000.md\u0007')).toMatchObject({ kind: 'markdown' }); + expect(await of(enc('x\n'), `${'a'.repeat(5000)}.md`)).toMatchObject({ kind: 'text' }); + }); + test('entry names inside an archive are cleaned of control and direction characters', async () => { + const z = storedZip([['evil\u202egnp.exe', 'x'], ['../../etc/passwd', 'y'], ['a\u0007b\u001bc', 'z'], ['x'.repeat(400), 'w']]); + const names = (await of(z)).list!.map((e) => e.name); + expect(names.slice(0, 3)).toEqual(['evilgnp.exe', '../../etc/passwd', 'abc']); + expect(names[3].length).toBe(200); + }); +}); + +describe('reads are bounded', () => { + test('a big text file: the first part only, counted as partial', async () => { + const big = enc('line of text that goes on a bit\n'.repeat(400_000)); + const src = counted(big); + const i = await analyze(src, { name: 'big.log' }); + expect(i).toMatchObject({ kind: 'text', partial: true, size: big.length }); + expect(i.lines).toBe(TEXT_MAX / 32); + expect(src.bytes).toBeLessThanOrEqual(HEAD + TEXT_MAX); + }); + test('a gzip bomb stops at the cap, quickly', async () => { + const bomb = new Uint8Array(Bun.gzipSync(new Uint8Array(96 * 1024 * 1024))); + const t = performance.now(); + const i = await of(bomb, 'zeros.bin.gz'); + expect(performance.now() - t).toBeLessThan(5000); + expect(i).toMatchObject({ kind: 'archive', entries: 1, list: [{ name: 'zeros.bin', size: 96 * 1024 * 1024 }] }); + }); + test('a big PDF is read at its two ends only', async () => { + const body = pdf(5); + const big = cat(body.subarray(0, 20), new Uint8Array(12 * 1024 * 1024), body.subarray(20)); + const src = counted(big); + expect((await analyze(src)).pages).toBe(5); + expect(src.bytes).toBeLessThan(6 * 1024 * 1024); + }); +}); + +// ------------------------------------------------------------------ the walk and the hub + +describe('looking at every kept file', () => { + const dir = mkdtempSync(join(tmpdir(), 'dither-fileinfo-')); + let store: Store; + let acc: AccountRow; + const cache = join(dir, 'media-cache'); + const remote = 'https://cdn.example.org/attachments/1/bench.json'; + const missing = 'https://cdn.example.org/attachments/2/gone.zip'; + const local = join(dir, 'imports', 'notes.md'); + + beforeAll(() => { + store = new Store(join(dir, 'hub.sqlite'), mediaSigner('s')); + acc = store.createAccount({ network: 'discord', label: 'main', color: '#000', adapter: 'discord' }); + store.ingest(acc, { t: 'hello', network: 'discord', version: '1', capabilities: [] }); + store.ingest(acc, { t: 'conversation', conversation: { nativeId: 'c1', shape: 'group', name: 'files' } }); + mkdirSync(cache, { recursive: true }); + mkdirSync(join(dir, 'imports'), { recursive: true }); + writeFileSync(join(cache, mediaKey(remote)), '{"ok": true}'); + writeFileSync(join(cache, mediaKey(remote)) + '.mime', 'application/octet-stream'); + writeFileSync(local, '# Notes\n\n- one\n- two\n'); + store.ingest(acc, { + t: 'message', + message: { + nativeId: 'm1', + conversation: 'c1', + author: 'u1', + ts: Date.now() - 1000, + body: 'files', + attachments: [ + { kind: 'file', mime: 'application/octet-stream', name: 'bench.json', url: remote }, + { kind: 'file', mime: 'application/zip', name: 'gone.zip', url: missing }, + { kind: 'file', name: 'notes.md', url: `file://${local}` }, + { kind: 'image', mime: 'image/png', name: 'inline.png', url: 'data:image/png;base64,iVBORw0KGgo=' }, + ], + }, + }); + store.flush(); + }); + afterAll(() => { + store.db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test('a hub that archives waits for the archive', async () => { + const mi = new MediaInfo(store, dir, { behindArchive: true }); + await mi.tick(); + expect(mi.looked).toBe(0); + }); + + test('the walk looks at what is on disk, skips the rest, and the message carries what it found', async () => { + const mi = new MediaInfo(store, dir, { behindArchive: false }); + await mi.tick(); + expect(mi.looked).toBe(2); + const found = infoFor(store.db, [remote, missing, `file://${local}`]); + expect(found.get(remote)).toMatchObject({ mime: 'application/json', kind: 'json', size: 12 }); + expect(found.get(`file://${local}`)).toMatchObject({ kind: 'markdown', lines: 4 }); + expect(found.has(missing)).toBe(false); + const [m] = store.messageViews(store.db.query(`SELECT * FROM message`).all() as any[]); + expect(m.attachments.map((a) => a.info?.kind ?? null)).toEqual(['json', null, 'markdown', null]); + // A recent message is sent again to the apps, with what was found. + expect(store.flush().some((e) => e.t === 'message')).toBe(true); + }); + + test('a restart carries on from the cursor and looks at nothing twice', async () => { + const mi = new MediaInfo(store, dir, { behindArchive: false }); + await mi.tick(); + expect(mi.looked).toBe(0); + }); + + test('a file the archive keeps later is looked at then', async () => { + writeFileSync(join(cache, mediaKey(missing)), storedZip([['a.txt', 'a']])); + const mi = new MediaInfo(store, dir, { behindArchive: false }); + const m = store.db.query('SELECT message_id FROM attachment WHERE url = ?').get(missing) as { message_id: string }; + await mi.kept({ url: missing, name: 'gone.zip', mime: 'application/zip', messageId: m.message_id }); + expect(infoFor(store.db, [missing]).get(missing)).toMatchObject({ kind: 'archive', entries: 1 }); + }); +}); + +describe('serving and asking for a file once the hub knows what it is', () => { + const dir = mkdtempSync(join(tmpdir(), 'dither-fileinfo-hub-')); + const port = 19960 + Math.floor(Math.random() * 30); + const url = `http://127.0.0.1:${port}`; + const files: Record = { + 'att:pic': { mime: 'application/octet-stream', bytes: png(4, 3) }, + 'att:page': { mime: 'application/octet-stream', bytes: enc('') }, + 'att:sheet': { mime: 'text/csv', bytes: enc('a,b\n1,2\n') }, + }; + let store: Store; + let hub: HubServer; + const ids: Record = {}; + + beforeAll(() => { + store = new Store(join(dir, 'hub.sqlite'), mediaSigner('s')); + const sup = { + authorize: () => null, + online: () => true, + command: async (_a: string, c: any) => (c.t === 'fetchMedia' && files[c.url] ? { mime: files[c.url].mime, base64: Buffer.from(files[c.url].bytes).toString('base64') } : null), + } as any; + hub = new HubServer(store, sup, new Triage(store), { port, hostname: '127.0.0.1', dataDir: dir, webDist: dir, secret: 's' }); + hub.start(); + const a = store.createAccount({ network: 'matrix', label: 'mx', color: '#000', adapter: 'matrix' }); + store.ingest(a, { t: 'hello', network: 'matrix', version: '1', capabilities: [] }); + store.ingest(a, { t: 'conversation', conversation: { nativeId: '!r:x.org', shape: 'group', name: 'Room' } }); + store.ingest(a, { + t: 'message', + message: { nativeId: '$1', conversation: '!r:x.org', author: '@a:x.org', ts: Date.now(), body: '', attachments: Object.entries(files).map(([u, f]) => ({ kind: 'file' as const, mime: f.mime, name: null as any, url: u })) }, + }); + store.flush(); + for (const r of store.db.query('SELECT id, url FROM attachment').all() as { id: string; url: string }[]) ids[r.url] = r.id; + }); + afterAll(() => { + hub.stop(); + store.db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test('GET /api/attachments/:id/info fetches the file, looks at it and says what it is', async () => { + const r = await fetch(`${url}/api/attachments/${ids['att:pic']}/info`); + expect(r.status).toBe(200); + expect(await r.json()).toMatchObject({ mime: 'image/png', kind: 'image', width: 4, height: 3 }); + expect((await fetch(`${url}/api/attachments/att_nope/info`)).status).toBe(404); + }); + + test('a file the network called octet-stream is served as what it is, with the same protections', async () => { + await fetch(`${url}/api/attachments/${ids['att:page']}/info`); + const media = (u: string) => fetch(url + store.mediaUrl(u, store.accounts()[0].id)!); + const pic = await media('att:pic'); + expect(pic.headers.get('content-type')).toBe('image/png'); + expect(pic.headers.get('content-disposition')).toBeNull(); + expect(pic.headers.get('x-content-type-options')).toBe('nosniff'); + // HTML is HTML now, and so a download, under the sandbox. + const page = await media('att:page'); + expect(page.headers.get('content-type')).toBe('text/html'); + expect(page.headers.get('content-disposition')).toStartWith('attachment'); + expect(page.headers.get('content-security-policy')).toStartWith('sandbox;'); + // A network's own specific type stays what it said. + await fetch(`${url}/api/attachments/${ids['att:sheet']}/info`); + expect((await media('att:sheet')).headers.get('content-type')).toBe('text/csv'); + }); +}); diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index f0f16df..4440c2b 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -21,6 +21,7 @@ import type { Variant, } from './ontology.ts'; import type { ArrangeSettings, UsageKind, UsageView } from './usage.ts'; +import type { FileInfo } from './files.ts'; export interface AccountView { id: string; @@ -138,6 +139,8 @@ export interface AttachmentView { width: number | null; height: number | null; url: string; + /** What the file is by its bytes, once the hub has kept and looked at them (files.ts, docs/files.md). */ + info?: FileInfo; } /** A file uploaded to the hub and waiting to be sent. */ diff --git a/packages/protocol/src/files.ts b/packages/protocol/src/files.ts new file mode 100644 index 0000000..f3a48db --- /dev/null +++ b/packages/protocol/src/files.ts @@ -0,0 +1,150 @@ +/** + * What a file is, by its bytes rather than by the type a network gave it (docs/files.md). The hub works it out once + * the bytes are kept (packages/hub/src/fileinfo.ts) and sends it with the attachment; the app picks a viewer by it, + * and by the name and type alone until then. + */ + +/** + * Which of the app's viewers shows the file. 'text' covers code and markup too (HTML, SVG and XML are shown as their + * source, never rendered); 'binary' has no viewer. + */ +export type FileKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'markdown' | 'json' | 'csv' | 'archive' | 'binary'; + +export interface ArchiveEntry { + name: string; + /** Uncompressed, in bytes. */ + size: number; + dir?: boolean; +} + +export interface FileInfo { + /** The type by the bytes: a plain `type/subtype`. */ + mime: string; + kind: FileKind; + /** In bytes. */ + size: number; + /** PDF. */ + pages?: number; + /** Text: 'utf-8', 'utf-16le', 'utf-16be' or 'windows-1252'. */ + encoding?: string; + lines?: number; + /** Text: a language for highlighting, from the name and the content (LANGUAGES). */ + language?: string; + /** Archive: how many entries. */ + entries?: number; + /** Archive: the first entries, names and sizes. Nothing is extracted. */ + list?: ArchiveEntry[]; + width?: number; + height?: number; + /** Audio and video, in seconds. */ + duration?: number; + /** Lines or entries were counted in the first part of a large file only: there are at least this many. */ + partial?: boolean; +} + +/** Highlighting languages by extension (lower case), and the type each is known by on a desktop (shared-mime-info). */ +const LANG: [lang: string, mime: string, exts: string][] = [ + ['javascript', 'text/javascript', 'js mjs cjs jsx'], + ['typescript', 'text/x-typescript', 'ts mts cts tsx'], + ['python', 'text/x-python', 'py pyw pyi'], + ['ruby', 'application/x-ruby', 'rb rake gemspec'], + ['rust', 'text/rust', 'rs'], + ['go', 'text/x-go', 'go'], + ['c', 'text/x-csrc', 'c h'], + ['cpp', 'text/x-c++src', 'cpp cc cxx hpp hh hxx ino'], + ['csharp', 'text/x-csharp', 'cs'], + ['java', 'text/x-java', 'java'], + ['kotlin', 'text/x-kotlin', 'kt kts'], + ['swift', 'text/x-swift', 'swift'], + ['scala', 'text/x-scala', 'scala sc'], + ['dart', 'text/plain', 'dart'], + ['php', 'application/x-php', 'php'], + ['perl', 'application/x-perl', 'pl pm'], + ['lua', 'text/x-lua', 'lua'], + ['r', 'text/x-r', 'r'], + ['haskell', 'text/x-haskell', 'hs'], + ['ocaml', 'text/x-ocaml', 'ml mli'], + ['elixir', 'text/plain', 'ex exs'], + ['erlang', 'text/x-erlang', 'erl hrl'], + ['clojure', 'text/plain', 'clj cljs cljc edn'], + ['lisp', 'text/plain', 'el lisp scm rkt'], + ['julia', 'text/plain', 'jl'], + ['zig', 'text/plain', 'zig'], + ['nix', 'text/x-nix', 'nix'], + ['shell', 'application/x-shellscript', 'sh bash zsh ksh fish'], + ['powershell', 'text/plain', 'ps1 psm1'], + ['batch', 'text/plain', 'bat cmd'], + ['sql', 'application/sql', 'sql'], + ['graphql', 'text/plain', 'graphql gql'], + ['css', 'text/css', 'css scss sass less'], + ['html', 'text/html', 'html htm xhtml vue svelte'], + ['xml', 'application/xml', 'xml xsd xsl xslt plist rss atom csproj svg'], + ['json', 'application/json', 'json jsonc json5 geojson webmanifest ipynb'], + ['yaml', 'application/yaml', 'yaml yml'], + ['toml', 'application/toml', 'toml'], + ['ini', 'text/plain', 'ini cfg conf properties env editorconfig'], + ['markdown', 'text/markdown', 'md markdown mdown mkd'], + ['csv', 'text/csv', 'csv'], + ['tsv', 'text/tab-separated-values', 'tsv tab'], + ['diff', 'text/x-patch', 'diff patch'], + ['latex', 'text/x-tex', 'tex sty cls bib'], + ['protobuf', 'text/plain', 'proto'], + ['hcl', 'text/plain', 'tf hcl'], + ['groovy', 'text/x-groovy', 'gradle groovy'], + ['asm', 'text/plain', 'asm s'], + ['dockerfile', 'text/plain', 'dockerfile containerfile'], + ['makefile', 'text/x-makefile', 'mk mak'], +]; +/** Whole names that say their language. */ +const NAMES: Record = { dockerfile: 'dockerfile', containerfile: 'dockerfile', makefile: 'makefile', gnumakefile: 'makefile', justfile: 'makefile', 'cmakelists.txt': 'makefile', gemfile: 'ruby', rakefile: 'ruby', 'flake.lock': 'json', '.bashrc': 'shell', '.zshrc': 'shell', '.profile': 'shell' }; + +export const LANGUAGE_BY_EXT: Record = Object.fromEntries(LANG.flatMap(([l, , exts]) => exts.split(' ').map((e) => [e, l]))); +export const MIME_BY_LANGUAGE: Record = Object.fromEntries(LANG.map(([l, m]) => [l, m])); +/** Plain text by name. */ +const TEXT_EXTS = new Set(['txt', 'text', 'log', 'nfo', 'rst', 'adoc', 'org', 'srt', 'vtt', 'lst', 'readme']); + +/** + * A file name as it may be shown or looked at: no control or direction-changing characters (a name can use those to + * look like it ends in another extension), at most 200 characters. + */ +export function cleanName(name: string | null | undefined): string { + // eslint-disable-next-line no-control-regex + return Array.from((name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, '')).slice(0, 200).join(''); +} + +/** The extension, lower case, of the last part of a path: '' for none or for one that is no plain extension. */ +export function extOf(name: string | null | undefined): string { + const base = cleanName(name).split(/[\\/]/).pop() ?? ''; + const dot = base.lastIndexOf('.'); + if (dot <= 0 && !/^\.[a-z]+rc$/i.test(base)) return ''; + const ext = base.slice(dot + 1).toLowerCase(); + return /^[a-z0-9+_-]{1,12}$/.test(ext) ? ext : ''; +} + +/** A highlighting language from the name alone; null when it says none. */ +export function languageOf(name: string | null | undefined): string | null { + const base = (cleanName(name).split(/[\\/]/).pop() ?? '').toLowerCase(); + return NAMES[base] ?? LANGUAGE_BY_EXT[extOf(name)] ?? null; +} + +const essence = (mime: string | null | undefined) => (mime ?? '').split(';')[0].trim().toLowerCase(); + +/** + * The viewer for a file by its name and the network's type, before the hub has looked at the bytes. Errs towards + * 'binary': the text viewers decode what they get, but a guess should not open a video as text. + */ +export function guessKind(mime: string | null | undefined, name: string | null | undefined): FileKind { + const m = essence(mime); + const ext = extOf(name); + const lang = languageOf(name); + if (m === 'application/pdf' || ext === 'pdf') return 'pdf'; + if (/^image\/(png|jpeg|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)$/.test(m)) return 'image'; + if (/^video\//.test(m)) return 'video'; + if (/^audio\//.test(m)) return 'audio'; + if (m === 'text/markdown' || lang === 'markdown') return 'markdown'; + if (m === 'application/json' || lang === 'json') return 'json'; + if (m === 'text/csv' || m === 'text/tab-separated-values' || lang === 'csv' || lang === 'tsv') return 'csv'; + if (m === 'application/zip' || m === 'application/x-tar' || m === 'application/gzip' || /^(zip|tar|tgz|gz)$/.test(ext)) return 'archive'; + if (lang || TEXT_EXTS.has(ext) || /^text\//.test(m) || /\+xml$|\/xml$|\/(javascript|x-sh|x-shellscript|toml|yaml|sql)$/.test(m)) return 'text'; + return 'binary'; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 78eec2a..7781be2 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -10,3 +10,4 @@ export * from './typing.ts'; export * from './activity.ts'; export * from './about.ts'; export * from './imports.ts'; +export * from './files.ts'; diff --git a/packages/web/package.json b/packages/web/package.json index 5046537..5f85a7b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,6 +11,8 @@ "@emoji-mart/data": "^1.2.1", "@novnc/novnc": "^1.7.0", "@preact/signals": "^2.11.4", + "marked": "^18.1.3", + "pdfjs-dist": "^6.5.438", "preact": "^11.1.0", "uqr": "^0.1.3" }, diff --git a/packages/web/src/files.ts b/packages/web/src/files.ts new file mode 100644 index 0000000..2f1574c --- /dev/null +++ b/packages/web/src/files.ts @@ -0,0 +1,141 @@ +/** + * Files in messages (docs/files.md): which viewer shows one, what its card says, and opening the viewer. The viewers + * themselves are chunks of their own (ui/files/), loaded when a file is opened. + */ +import { signal } from '@preact/signals'; +import type * as P from '@dither-chat/protocol'; +import { extOf, guessKind } from '@dither-chat/protocol'; +import { bytes } from './format.ts'; + +/** The kinds a viewer in ui/files/ shows. Images, video and audio have their own (Viewer.tsx, the timeline's players). */ +export const VIEWABLE: ReadonlySet = new Set(['text', 'markdown', 'json', 'csv', 'pdf', 'archive']); + +/** What the file is: by its bytes once the hub has looked, else by its name and the network's type. */ +export function fileKind(a: Pick): P.FileKind { + if (a.info) return a.info.kind; + if (a.kind !== 'file') return a.kind; + return guessKind(a.mime, a.name); +} + +/** The type to go by: the one the bytes say, else the network's. */ +export const fileMime = (a: Pick) => a.info?.mime ?? a.mime; + +const LANGUAGE: Record = { + javascript: 'JavaScript', + typescript: 'TypeScript', + python: 'Python', + ruby: 'Ruby', + rust: 'Rust', + go: 'Go', + c: 'C', + cpp: 'C++', + csharp: 'C#', + java: 'Java', + kotlin: 'Kotlin', + swift: 'Swift', + php: 'PHP', + shell: 'Shell script', + sql: 'SQL', + css: 'CSS', + html: 'HTML', + xml: 'XML', + json: 'JSON', + yaml: 'YAML', + toml: 'TOML', + nix: 'Nix', + diff: 'Patch', + latex: 'LaTeX', + lua: 'Lua', + haskell: 'Haskell', +}; +const MIME_LABEL: Record = { + 'application/pdf': 'PDF', + 'application/zip': 'ZIP archive', + 'application/gzip': 'Gzip archive', + 'application/x-tar': 'Tar archive', + 'application/java-archive': 'Java archive', + 'application/vnd.android.package-archive': 'Android app', + 'application/x-7z-compressed': '7-Zip archive', + 'application/vnd.rar': 'RAR archive', + 'application/x-xz': 'XZ archive', + 'application/x-bzip2': 'Bzip2 archive', + 'application/zstd': 'Zstandard archive', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint', + 'application/vnd.oasis.opendocument.text': 'OpenDocument text', + 'application/vnd.oasis.opendocument.spreadsheet': 'OpenDocument spreadsheet', + 'application/vnd.oasis.opendocument.presentation': 'OpenDocument presentation', + 'application/epub+zip': 'EPUB book', + 'application/msword': 'Word document', + 'application/vnd.ms-excel': 'Excel spreadsheet', + 'application/vnd.ms-powerpoint': 'PowerPoint', + 'application/x-executable': 'Program', + 'application/vnd.microsoft.portable-executable': 'Windows program', + 'application/x-mach-binary': 'Mac program', + 'application/vnd.sqlite3': 'SQLite database', + 'application/x-iso9660-image': 'Disc image', + 'image/svg+xml': 'SVG image', + 'text/html': 'HTML page', + 'text/csv': 'CSV table', + 'text/tab-separated-values': 'TSV table', + 'text/markdown': 'Markdown', + 'text/plain': 'Text', +}; + +/** A name for the file's type: "Markdown", "Rust", "ZIP archive", "PNG image". */ +export function typeLabel(a: Pick): string { + const lang = a.info?.language; + const mime = (fileMime(a) ?? '').split(';')[0].trim().toLowerCase(); + if (MIME_LABEL[mime] && !(mime === 'text/plain' && lang)) return MIME_LABEL[mime]; + if (lang && LANGUAGE[lang]) return LANGUAGE[lang]; + const [top, sub = ''] = mime.split('/'); + if (top === 'image' || top === 'video' || top === 'audio') { + const short = sub.replace(/^(x-|vnd\.)/, '').split(/[.+;-]/)[0].toUpperCase(); + return `${short || top} ${top}`.replace(/^(\S+) (\S+)$/, (_, s, t) => `${s} ${t}`); + } + const ext = extOf(a.name); + if (ext) return `${ext.toUpperCase()} file`; + return mime && mime !== 'application/octet-stream' ? mime : 'File'; +} + +const plural = (n: number, one: string, many = one + 's') => `${n.toLocaleString()} ${n === 1 ? one : many}`; + +function duration(s: number): string { + const t = Math.round(s); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const sec = String(t % 60).padStart(2, '0'); + return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`; +} + +/** What a file's card says under its name: type, then pages, lines, entries or length, then size. */ +export function fileFacts(a: Pick): string { + const i = a.info; + const at = i?.partial ? 'at least ' : ''; + const parts = [typeLabel(a)]; + if (i?.pages) parts.push(plural(i.pages, 'page')); + else if (i?.entries !== undefined && i.kind !== 'binary') parts.push(at + plural(i.entries, 'entry', 'entries')); + else if (i?.lines !== undefined && i.kind !== 'binary') parts.push(at + plural(i.lines, 'line')); + else if (i?.duration) parts.push(duration(i.duration)); + else if (i?.width && i.height) parts.push(`${i.width}×${i.height}`); + const size = bytes(i?.size ?? a.size); + if (size) parts.push(size); + return parts.join(' · '); +} + +/** Which icon a file's card has. */ +export function fileIcon(kind: P.FileKind, language?: string): string { + if (kind === 'archive') return 'archive'; + if (kind === 'csv') return 'grid'; + if (kind === 'text' && language) return 'fileCode'; + if (kind === 'text' || kind === 'markdown' || kind === 'json' || kind === 'pdf') return 'fileText'; + return 'file'; +} + +/** The file open in the viewer, and the message it came in. */ +export const fileViewer = signal<{ att: P.AttachmentView; message: P.MessageView | null } | null>(null); + +export function openFileViewer(att: P.AttachmentView, message: P.MessageView | null = null) { + fileViewer.value = { att, message }; +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 1be8b0f..6c6c56c 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -4708,6 +4708,406 @@ code { cursor: zoom-in; } +/* ---- file viewer (Viewer.tsx FileBox, ui/files/, docs/files.md) ---- */ +.att-file small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} +.att-file[data-kind='text'], +.att-file[data-kind='markdown'], +.att-file[data-kind='json'], +.att-file[data-kind='csv'], +.att-file[data-kind='pdf'], +.att-file[data-kind='archive'] { + cursor: zoom-in; +} +.fviewer { + position: fixed; + inset: 0; + z-index: 120; + display: flex; + flex-direction: column; + background: var(--bg-center, var(--bg-raised)); + color: var(--text); + animation: fade-in 0.15s ease-out; +} +.fviewer-bar { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + padding: calc(6px + env(safe-area-inset-top)) 8px 6px 14px; + border-bottom: 1px solid var(--border-soft); + background: var(--bg-raised); +} +.fviewer-bar > .icon { + flex: none; + color: var(--text-dim); + margin-right: 6px; +} +.fviewer-name { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.fviewer-name b, +.fviewer-name small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.fviewer-name small { + color: var(--text-faint); + font-size: 12px; +} +.fviewer-body { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 0 0 calc(16px + env(safe-area-inset-bottom)); +} +.fv-wait, +.fv-none { + padding: 48px 20px; + text-align: center; + color: var(--text-dim); +} +.fv-none .icon { + color: var(--text-faint); +} +.fv-media { + display: block; + max-width: 100%; + max-height: 80vh; + margin: 24px auto; +} +.fv-tools { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: color-mix(in oklab, var(--bg-center, var(--bg-raised)) 92%, transparent); + border-bottom: 1px solid var(--border-soft); + font-size: 12.5px; +} +.fv-modes { + position: static; + border-bottom: none; + padding-bottom: 0; +} +.fv-modes + .fv-note, +.fv-modes + * > .fv-tools { + margin-top: 4px; +} +.fv-tools-info { + flex: 1; + min-width: 0; + color: var(--text-faint); +} +.fv-note { + margin: 10px 14px 0; + padding: 8px 12px; + border-radius: var(--radius-sm); + background: color-mix(in oklab, var(--warn) 12%, transparent); + color: var(--text-dim); + font-size: 12.5px; +} +.fv-more { + display: block; + margin: 10px auto; + padding: 6px 14px; + border-radius: var(--radius-sm); + background: var(--bg-hover); + color: var(--text-dim); + font-size: 12.5px; +} +.fv-more:hover { + color: var(--text); +} + +/* text and code */ +.fv-code { + font: 12.5px/1.55 var(--mono); + padding: 8px 0; + overflow-x: auto; + tab-size: 4; +} +.fv-line { + display: flex; + min-width: max-content; +} +.fv-code.wrap .fv-line { + min-width: 0; +} +.fv-n { + flex: none; + width: calc(var(--ln-w, 4ch) + 18px); + padding: 0 10px 0 8px; + text-align: right; + color: var(--text-faint); + opacity: 0.7; + user-select: none; + -webkit-user-select: none; +} +.fv-src { + flex: 1; + min-width: 0; + padding-right: 14px; + white-space: pre; +} +.fv-code.wrap .fv-src { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.hl-c { + color: var(--text-faint); + font-style: italic; +} +.hl-s { + color: var(--good); +} +.hl-n { + color: var(--warn); +} +.hl-k { + color: var(--accent); +} +.hl-t { + color: var(--link, var(--accent)); +} +.hl-a { + color: color-mix(in oklab, var(--accent) 55%, var(--text)); +} +.hl-h { + color: var(--text); + font-weight: 700; +} +.hl-add { + color: var(--good); + background: color-mix(in oklab, var(--good) 10%, transparent); +} +.hl-del { + color: var(--danger); + background: color-mix(in oklab, var(--danger) 10%, transparent); +} + +/* Markdown */ +.fv-md { + max-width: 760px; + margin: 0 auto; + padding: 12px 18px 24px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.fv-md h1, +.fv-md h2, +.fv-md h3 { + line-height: 1.25; + margin: 1.2em 0 0.5em; +} +.fv-md h1 { + font-size: 1.6em; + border-bottom: 1px solid var(--border-soft); + padding-bottom: 0.25em; +} +.fv-md h2 { + font-size: 1.3em; +} +.fv-md a { + color: var(--link, var(--accent)); +} +.fv-md code, +.fv-md-html { + font: 0.9em var(--mono); + background: var(--bg-hover); + padding: 0.1em 0.35em; + border-radius: 4px; +} +.fv-md-code, +pre.fv-md-html { + font: 12.5px/1.5 var(--mono); + background: var(--bg-raised); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + padding: 10px 12px; + overflow-x: auto; + white-space: pre; +} +pre.fv-md-html { + white-space: pre-wrap; + color: var(--text-dim); +} +.fv-md-code code { + background: none; + padding: 0; + font: inherit; +} +.fv-md blockquote { + margin: 0.8em 0; + padding: 0 0 0 12px; + border-left: 3px solid var(--border); + color: var(--text-dim); +} +.fv-md ul, +.fv-md ol { + padding-left: 1.5em; +} +.fv-md li.task { + list-style: none; + margin-left: -1.3em; +} +.fv-md li.task input { + margin-right: 6px; +} +.fv-md hr { + border: none; + border-top: 1px solid var(--border); +} +.fv-md-table { + overflow-x: auto; + margin: 0.8em 0; +} +.fv-md table, +.fv-table table, +.fv-archive table { + border-collapse: collapse; + font-size: 13px; +} +.fv-md th, +.fv-md td { + border: 1px solid var(--border-soft); + padding: 4px 10px; +} +.fv-md th { + background: var(--bg-raised); +} +.fv-md-img { + max-width: 100%; +} +.fv-md-load { + display: inline-flex; + padding: 4px 10px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-dim); + font-size: 12.5px; + max-width: 100%; + overflow-wrap: anywhere; + text-align: left; +} +.fv-md-nolink { + text-decoration: underline dotted; +} + +/* JSON */ +.fv-json-tree { + font: 12.5px/1.6 var(--mono); + padding: 8px 14px; + overflow-wrap: anywhere; +} +.fv-json-kids { + padding-left: 18px; + border-left: 1px solid var(--border-soft); + margin-left: 5px; +} +.fv-json-row { + padding-left: 16px; +} +.fv-json-toggle { + display: block; + font: inherit; + color: inherit; + text-align: left; + padding: 0; +} +.fv-json-caret { + display: inline-block; + width: 16px; + color: var(--text-faint); +} +.fv-json-sum { + color: var(--text-faint); +} + +/* tables and archives */ +.fv-table-scroll { + overflow-x: auto; +} +.fv-table th, +.fv-table td, +.fv-archive th, +.fv-archive td { + border-bottom: 1px solid var(--border-soft); + padding: 5px 12px; + text-align: left; + white-space: nowrap; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; +} +.fv-table thead th, +.fv-archive thead th { + position: sticky; + top: 0; + background: var(--bg-raised); + font-weight: 600; +} +.fv-table .fv-rn { + color: var(--text-faint); + text-align: right; + user-select: none; +} +.fv-archive table { + width: 100%; + table-layout: fixed; +} +.fv-archive th.num, +.fv-archive td.num { + width: 90px; + text-align: right; + color: var(--text-dim); +} +.fv-entry { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} +.fv-entry.dir { + color: var(--text-dim); +} + +/* PDF */ +.fv-zoom { + min-width: 42px; + text-align: center; + color: var(--text-dim); +} +.fv-pages { + display: flex; + flex-direction: column; + align-items: safe center; + gap: 12px; + padding: 12px 8px; + overflow-x: auto; + background: var(--bg-sidebar, var(--bg-raised)); +} +.fv-page { + flex: none; + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.3); +} +.fv-page canvas { + display: block; +} + /* ---- skeletons ---- */ .skeleton { display: flex; @@ -4818,6 +5218,7 @@ code { .modal-backdrop::before, .modal, .viewer, + .fviewer, .menu { animation: none; } diff --git a/packages/web/src/ui/Timeline.tsx b/packages/web/src/ui/Timeline.tsx index c81c3fb..2b3d0fe 100644 --- a/packages/web/src/ui/Timeline.tsx +++ b/packages/web/src/ui/Timeline.tsx @@ -74,6 +74,7 @@ import { composerDone, composerTyped } from '../typing.ts'; import { onMenu, openMenu, type MenuSpec } from './ContextMenu.tsx'; import { ForwardCard, forwarding } from './Forward.tsx'; import { attachmentMenu, conversationMenu, identityMenu, imageMenu, linkMenu, messageMenu, openAttachment, personMenu } from './menus.tsx'; +import { fileFacts, fileIcon, fileKind, openFileViewer, VIEWABLE } from '../files.ts'; import { desk } from '../desk.ts'; import { android } from '../android.ts'; import { counting, markPainted, rendered } from '../perf.ts'; @@ -416,13 +417,16 @@ function Marks({ m }: { m: P.MessageView }) { function Attachments({ m }: { m: P.MessageView }) { if (!m.attachments.length) return null; - const images = m.attachments.filter((a) => a.kind === 'image'); + // A file the network called just a file whose bytes are a picture, a video or a sound shows as one (files.ts). + const media = (a: P.AttachmentView) => (a.kind === 'file' && a.info && /^(image|video|audio)$/.test(a.info.kind) ? (a.info.kind as P.AttachmentKind) : a.kind); + const images = m.attachments.filter((a) => media(a) === 'image'); return (
{m.attachments.map((a, i) => { - if (a.kind === 'image') { - const w = a.width ?? 400; - const h = a.height ?? 300; + const kind = media(a); + if (kind === 'image') { + const w = a.width ?? a.info?.width ?? 400; + const h = a.height ?? a.info?.height ?? 300; const scale = Math.min(1, 400 / w, 300 / h); return ( openViewer(e, images, images.indexOf(a))} style={{ width: `${Math.round(w * scale)}px`, aspectRatio: `${w} / ${h}` }}> @@ -430,17 +434,21 @@ function Attachments({ m }: { m: P.MessageView }) { ); } - if (a.kind === 'video') return