diff --git a/.claude/architecture.md b/.claude/architecture.md index 2db11af6..c3ffbd07 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -11,11 +11,12 @@ - `presence/` - who is where: each device announces its open file on every space channel it holds, sealed with that space's key (`people.ts`, `channel.ts`, `announcement.ts`); `here.ts` tracks the active file and idleness; `unseen.ts` the files others changed that this device has not opened. Keys and identity per space come from `plugin/space-access.ts`, shared with live editing - `spaces/` - space records (`settings.spaces`, one object each under `spaces/` in the vault storage), the partition (`spacesOf`, `mountError`), the owner's share (`owner.ts`), invites (`invite.ts`), how a device reaches a share (`access.ts`). No network and no settings: the relay broker client is `storage/adapters/share-broker.ts`, the flows are `ui/shares/share-action.ts` and `ui/shares/invite-action.ts`. `sync/` never imports it; `core/session-factory.ts` opens a share's session from its record - `live/` - live documents: `session/` (one file's Y.Doc in its space's room: the outbox, the room log, who else is in it, the rotation it asked for), `workspace/` (which open notes are live: `sessions.ts` and each note's `noteState`, `room.ts`, `space.ts`, the join patience, renames, and `editors.ts` pairing each model with the view that edits it), `cold/` (the file sync's `LiveNotes` port, declared in `sync/live-notes.ts` so `sync/` never imports `live/`, and the agreed texts), `doc-id.ts` (a room's id), `doc-types.ts` (what goes live, in which view), and a model per kind (`text/`, `drawing/`, each with the binding that attaches its view) +- `links/` - share links, see [share links](share-links.md): the record in `settings.links`, written only through `SharedLinks` (`plugin.sharedLinks`, subscribable), `note-links.ts` (a note's live links and whether the note changed since), the snapshot (Obsidian's renderer into a hidden attached element, then `sanitize.ts`, `math.ts` and `images.ts`), `publish.ts` (seal with `@mdsync/protocol`'s link format, store through `storage/adapters/link-broker.ts`). No UI: the flows are `ui/links/`. The page a link opens is `packages/viewer`, served by the relay - `drawing/` - Excalidraw files without Obsidian: reading the scene, its fingerprint, the merge by element. Pure; used by `sync/`, `vault/` and `live/` - `storage/` - remote backends behind StorageAdapter, plus the registry - `vault/` - Obsidian filesystem access, scanning, ignore rules - `settings/` - settings model, transfer, and the settings tab sections -- `ui/` - views and modals at the top; `live/` (note header in `header/`, relay and live status in `status/`, the states both show in `live-state.ts`), `shares/` (share window, share and invite flows), `explorer/` (file tree indicators and presence), `actions/` (menu actions), `common/` (notices, icon buttons, avatars, relay text and fixes, menus, helpers: whatever two of these use) +- `ui/` - views and modals at the top; `live/` (note header in `header/`, relay and live status in `status/`, the states both show in `live-state.ts`), `shares/` (share window, share and invite flows), `links/` (share-link window, its list, the menu entries, the note header button), `explorer/` (file tree indicators and presence), `actions/` (menu actions), `common/` (notices, icon buttons, avatars, relay text and fixes, menus, helpers: whatever two of these use) - `editor/` - CodeMirror gutter signs, the read-only share lock - `shared/`, `utils/` - app-aware helpers vs. generic algorithms - `styles/` - the stylesheet, one slice per feature (see Styles) diff --git a/.claude/commands-and-settings.md b/.claude/commands-and-settings.md index 682c8687..860719f6 100644 --- a/.claude/commands-and-settings.md +++ b/.claude/commands-and-settings.md @@ -9,6 +9,7 @@ - **The share's window** (**Manage sharing** in the folder menu of a share root or anything inside it, its badge in the file explorer, **Manage** in the settings, command `manage-shared-folder` for the open file's share) holds everything about one share: one list of the people (owner with the relay: everyone with access, else whoever is here): role, where they are now, open their note, **Revoke** (owner, with the relay), the relay status, the invite form, **Pause** (Off / On this device / On all my devices), **Stop sharing** / **Leave**. - **Invite** (the share's window, owner, needs the relay) registers the share's storage with the relay's broker, issues a token for one person and shows an `obsidian://mdsync-share` link with a generated password to send separately. Opening the link, or pasting it in **Accept an invite** (settings) or the command `accept-shared-folder-invite` (Obsidian opens a link in the vault last used, not always the right one), asks for the password (no vault storage needed: such a guest device syncs only its shares) and a new or empty folder (default `Shared/`), then pulls the share there. The participant never holds storage credentials. A **Read-only** invite is pulled but never auto-pushed; the broker refuses a manual push. Its notes open in source mode follow the others' typing live, locked. Inviting the same name again (any case) replaces that person's link. - **Shared folders** (settings, Sync tab) starts with **Use shared folders on this device** (device-local, on by default): off pauses every share here, arriving ones too, cancels running operations, and hides the rest of the section, the folder menu entries, **Manage sharing** and the Changes tab's paused line; the relay carries only the vault, and opening an invite first asks to turn shares on. The paused badge stays on a share folder that is on disk. On, it has **Your name** once the person owns an open share (how participants see them; empty shows "Owner"; stored on their shares, so it reaches all their devices), and lists this person's open shares, each with **Manage**, so a share whose folder is gone here can still be closed. **Stop sharing** (owner; ends every token at the broker) or **Leave** (participant) closes the record on all of the person's devices; the folder's files stay and sync with the vault again. **Pause** On this device stops syncing the share here, history included; On all my devices puts it on the record, so every device of the person stops at its next refresh, and Off from any of them clears it. The folder stays out of the vault meanwhile, and the Changes tab names it. **Pause shared folders added on other devices** (device-local) pauses a share new to this device in the same save that brings its record, before any pull. **Push shared folders right away** (on by default) pushes a change in a share 2 s after the last file event there (`quietQueue` in `sync/scheduler.ts`), comparing and pushing only that share (`autoPushShares`). Events find their share by the records from launch; before the first refresh, the push refreshes first. Under it **Push only added, moved and deleted files** (on by default) leaves edits for a push, so their gutter marks and the Changes tab show them; open notes still update live. Read-only and paused shares never push, and with it off share paths go to the vault's **Auto-push after change** queue. A push that published nothing signals no one. After a refresh in which an owned share compared fine, the device sends its current S3 credentials to the broker again when they changed, so rotated keys reach participants without a new invite. Renaming a shared folder (or a folder above it) in Obsidian moves it on the person's other devices too, each at its next refresh; where the new path is taken the share keeps syncing at the old one, with a notice. A move into another shared folder or a hidden one is renamed back. +- **Share link** (file menu of a Markdown note, not Excalidraw; disabled with "needs a relay" without one) and `share-note-link` open the window that publishes the open note as a link: expiry, view limit, optional passphrase, images, the note's name. The window shows what the note's copy leaves out; the passphrase is shown once, beside the link, and never stored. **Share links of this note (n)** (file menu, when it has links), `manage-note-links` (named "Manage share links") and **Settings → Sync → Share links** open the list: views left, **Copy link**, **Update** (same link, the note as it is now; views spent stay spent) and **Stop sharing**. A shared note shows a globe badge in the file tree and a header button that open its links, with a dot when the note changed since it was shared; `update-note-links` ("Update share links of this note") updates them. `settings.links` is device-local and outside device transfer. See [share links](share-links.md). - `refresh` and the Changes tab's refresh button also list the disk, as the first refresh after launch does: Obsidian's watcher misses files copied in bulk from outside the app. Files its index lacks sync anyway, with a notice to restart Obsidian to see them. - `rebuild-live-note` is available only for the active note bound to a live room. It moves the room into its next generation from the current text, shedding the edit history; attribution survives. - Presence: each open note's header shows whether it is live (and why not) and who else has it open, as coloured rings with their initial; tapping it lists them with **follow**: the tab opens each note they open and keeps their cursor in its middle; input there asks **Stop following** in a notice, and it ends on that, on a note opened there by hand, on their leaving the space, or on tapping them again. A note outside shares shows the mark only with another of your devices in it or relay trouble. The file explorer marks share roots by kind with a headcount of people in its notes, and people on the notes they have open (a collapsed folder gathers them); the desktop status bar shows the relay state and who is in shared notes. The header menu also has **Show who typed what** (shared notes), **Reconnect** (relay down; restarts only that space's socket) or **Open relay settings** (the relay refused this device), **Manage sharing** and **Rebuild live note** (not for a read-only person, who never writes the room). **Real-time sync** switches off presence and live editing together; **Show my open note to others** off keeps the device online but hides its note; **File and folder indicators** off keeps the share badge, the tree's way into the share window. Relay state everywhere comes from `realtime.hub.statusOf`. Commands `show-live-status` (named "Show relay status": the status bar item's menu: a relay row per space, who is in shared notes; the only live status on a phone) and `show-note-live-menu` (the note header's menu, **follow** included) reach both without a pointer. On a phone, tapping a people badge or the new dot in the tree shows its tooltip as a notice. diff --git a/.claude/security.md b/.claude/security.md index 69e83d28..0480dd2e 100644 --- a/.claude/security.md +++ b/.claude/security.md @@ -10,4 +10,5 @@ Follow Obsidian's Developer Policies and Plugin Guidelines: - Clearly disclose any external services used, data sent, and risks. Features requiring cloud services need explicit opt-in. - No deceptive patterns, ads, or spammy notifications. - Shared folders: `data.json` holds each share's key and a participant's broker token in plain text, like the storage credentials; the vault key also opens them through the space records. To presign for participants the relay keeps the owner's S3 credentials (its KV), so its operator is trusted with them. A participant with write access can delete the share's objects; history, not revocation, repairs that. +- Share links: creating one sends a rendered copy of one note, sealed on the device, to the user's own relay (disclosed in README). `data.json` keeps each link whole, its key included, so the owner can copy it again; the passphrase is never stored. A reader who ticks **Remember on this browser** keeps the derived non-extractable key and gate in that browser's IndexedDB until the link expires or is gone, never the passphrase. The relay holds ciphertext, a SHA-256 of the link's gate (derived from the key in the fragment, and the passphrase if any: the id alone serves and updates nothing), the view limit and expiry. Everything published passes `links/sanitize.ts` (no properties, comments, embedded notes or paths) and the viewer's DOMPurify; the viewer page runs under a CSP with no inline script, no `unsafe-eval` and no framing, and the key stays in the URL fragment, which in-page jumps must not replace. The relay's operator serves the viewer script, so they are trusted as the owner. - Register and clean up all DOM, app, and interval listeners with the provided `register*` helpers so the plugin unloads safely; teardown is idempotent so reload/unload leaks nothing. diff --git a/.claude/share-links.md b/.claude/share-links.md new file mode 100644 index 00000000..bfe75df2 --- /dev/null +++ b/.claude/share-links.md @@ -0,0 +1,262 @@ +# Share links + +Status: built (protocol, relay, viewer, plugin), tested in-process and end to +end (`pnpm e2e:links` against `wrangler dev` and Chrome, `pnpm +e2e:links-obsidian` through a real Obsidian). Share one note by a link that opens in any +browser, with optional passphrase, view limit and expiry. Not the same as a +shared folder ([shares](shares.md)): no accounts, no sync, a read-only snapshot. + +## Decisions + +| Decision | Why | +| --- | --- | +| The plugin renders the note to HTML (`MarkdownRenderer`) and uploads that, sealed | callouts, math, mermaid, code, tasks and other plugins' blocks come free and keep following Obsidian; the viewer needs no markdown parser | +| End to end: random key in the URL `#fragment`, the relay stores ciphertext | same zero-knowledge as the rest; the fragment never reaches a server | +| The viewer is a static package served by the relay worker (Workers Static Assets) | one deploy, one origin, no CORS, no third-party host | +| One `Link` Durable Object per link holds blob, counter, gate, expiry | view limit must be atomic; KV is eventually consistent (a fresh blob could miss, a spent link could re-serve) | +| A view is counted when the ciphertext is served, not when the page loads | link-preview bots fetch only the shell | +| Every link has a gate derived from the fragment key (plus the passphrase when there is one), checked by the relay before it serves or updates | the id alone burns no view and yields no ciphertext; a wrong guess burns no view; operator alone cannot decrypt | +| Seal format lives in `packages/protocol` | plugin and viewer share one source of truth | +| Link list is device-local (`settings.links`) | YAGNI; the link itself (with its key) must be re-copyable | + +## Link format + +`https:///s/#[/]`: `id` 16 random bytes base64url, `key` +16 random bytes base64url, `anchor` an optional heading slug, URI-encoded +(base64url has no `/`, so it cannot run into the key). The whole fragment stays +in the browser. + +Payload: `{title, html, createdAt}` (`createdAt` is the snapshot time) -> JSON +-> deflate -> AES-GCM. Content key = HKDF(`key` [+ PBKDF2(trimmed passphrase, +salt) when protected]); AAD `link:`. A second HKDF output (other `info`) is +the **gate**, with or without a passphrase; the relay stores only its hash and +compares it in constant time. A salt is stored exactly for protected links. + +The relay knows: id, size, expiry, view limit and count, protected yes/no, +timings. Not the title, text or key. + +## Relay (`packages/relay/src/link/`) + +| Route | Auth | Does | +| --- | --- | --- | +| `PUT /link/` | admin | body is the sealed bytes; query `maxViews`, `ttl` (seconds, counted on the relay's clock, so a wrong device clock cannot misdate it); headers `X-Mdsync-Gate` (required), `X-Mdsync-Salt` (protected links). Creates (409 if one stands) and answers `{expires}`; `?update=1` replaces only the blob of a standing link (404 when gone) and needs the same gate (403 `gate` otherwise, so a mistyped passphrase cannot re-key it), keeping limits, expiry and counter: an update never brings a spent link back and a creation never inherits a counter | +| `DELETE /link/` | admin | revoke: `deleteAll` | +| `GET /link//status` | admin | `{views, maxViews, expires, protected, size}` for the plugin's list | +| `GET /link//meta` | none | `{protected, salt}`; counts nothing; 404 when gone | +| `POST /link//open` | none | `{gate}` (body capped at 1 KiB) -> sealed bytes, `X-Mdsync-Views-Left` when limited, `X-Mdsync-Expires` when it expires; counts a view; 401 wrong gate; 429 in cooldown; 404 for missing, expired, spent alike | +| `GET /s/` | none | viewer shell from `ASSETS` | + +- `Link` DO (SQLite, migration `v3` in `wrangler.toml`): blob in rows of at + most 1 MiB (a SQLite value caps at 2 MB), total cap 6 MiB sealed. `open` + runs in one DO turn: expiry, cooldown, gate, `views++`, delete everything + when `views >= maxViews`. An alarm at `expiresAt` deletes too. +- Gate failures cool down the client, never the link (anyone holding only + the id could lock the readers out otherwise): after 5 in a row from one + client (`CF-Connecting-IP`, an IPv6 reduced to its /64, hashed) a cooldown of + 1 min, doubling, capped at 1 h, during which even the right gate is refused + for that client. The gate is checked in the Durable Object's synchronous + turn on a hash the worker computed, so counting stays atomic. 128 clients + are remembered per link, the least recent first. +- Response headers on the shell: CSP `default-src 'none'; script-src 'self'; + style-src 'self'; style-src-attr 'unsafe-inline'; img-src data: https:; + connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors + 'none'`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex`, `Cache-Control: + no-store` on `open`. +- `wrangler.toml`: `[assets] directory = "../viewer/dist"`, `binding = "ASSETS"`, + `run_worker_first` so `/hub`, `/share`, `/link`, `/auth` keep going to code. + +## Viewer (`packages/viewer`) + +Vanilla TS, esbuild to `dist/`. States: loading, passphrase form, content, +gone ("expired or reached its view limit"), error. + +- Reads `id` and `#key`, `GET meta`, asks the passphrase if protected, `POST + open`, decrypts with `@mdsync/protocol`, inflates (`DecompressionStream`), + sanitizes with DOMPurify (SVG on, scripts and handlers off), injects. +- Caches the sealed blob in `sessionStorage` per id: a reload decrypts locally + and costs no view. Views count opens, not people: another tab or browser + spends one. +- **Remember on this browser** (passphrase form, off by default: a shared + computer must not keep a note by accident) keeps the derived keys in + IndexedDB (`keys.ts`, `idb-keyval`): the non-extractable AES `CryptoKey` and + the gate, never the passphrase. Kept until the link's expiry (each visit + erases every expired entry), or until the relay says it is gone; a kept gate + the relay refuses is dropped. +- A passphrase is trimmed before it is stretched (`protocol`), so a pasted + trailing space is no wrong attempt. +- CSS written once (`viewer.css` page and layout, `note.css` the note, + `sidebar.css`), on Obsidian's variable names (`--code-keyword`, `--h2-size`, + `--color-blue-rgb`...) so its SVG reads them and a theme could set them: + typography, callout colour per type, Prism tokens, checkboxes, tables, + footnotes; light and dark by `prefers-color-scheme`; print styles. +- The reading view's behaviour is the viewer's, since the snapshot carries no + script: `sections.ts` nests the flat top-level blocks under their headings + (footnotes stay out); a click on the heading or its chevron folds it. + `anchors.ts` gives each heading a `#` button: it copies the link with the + heading's slug, puts it in the address (`replaceState`, key kept) and + scrolls there; a load or `hashchange` with an anchor jumps to it, unfolding. + `code.ts` puts a **Copy** button on each code block. A note opening with an + H1 equal to its name shows the name once. +- `sidebar.ts`: a column left of the note (a panel behind a menu button on a + narrow screen) with a card (views left, expiry from `X-Mdsync-Expires`, + **Copy as Markdown**, **Wide text** kept in `localStorage`) and `outline.ts`, + the headings (3 or more) marking the one being read. **Wide text** only + applies from 66rem up (below it the toggle is hidden); a paragraph holding + only an image (`images.ts`, `lone-image`) is centered while wide. +- **Copy as Markdown** (`markdown.ts`, turndown + GFM) converts the sanitized + page, not the author's file, so nothing the snapshot dropped can come back. + Callouts become `> [!type]` blocks (fold kept), tasks `- [x]`, tables GFM; + a formula is written back as TeX from `data-tex` on its `` (set by the + plugin's `math.ts`); vault images (`data:`) and drawings are left out. +- The tab icon is an inline SVG data URI in `index.html`: the CSP allows images + from `data:` and `https:` only, so a file served by the relay would be blocked. +- Footer text only, no analytics, no external requests of its own. + +## Plugin (`packages/plugin/src/links/`) + +- Entry: note file menu **Share link** and command `share-note-link` (stable + ID); hidden for non-markdown and Excalidraw files; disabled with the reason + when no relay is set (`RELAY_TEXT["no-relay"]`). +- Modal (`ui/links/share-link-modal.ts`): **Expires** (5 minutes, 15 minutes, + 1 hour, 1 day, 7 days default, 30 days, never, or **Pick a date…** with an + exact local date and time, 1 minute to 365 days ahead), **Views** (1, 5, 25, + unlimited), **Passphrase** + (optional, at least 8 characters, **Generate** uses the invite password + generator), **Include images** (on), **Show the note's name** (on), and a + line saying what was left out (counts from the snapshot, rerun when images + toggle). **Create link** then shows the link and the passphrase with + **Copy**; the passphrase is never stored, so the modal does not close while + the link is being made. +- Snapshot (`snapshot.ts`): `MarkdownRenderer.render` into an element + **attached** to the document (hidden off screen) with a throwaway `Component`: + detached, callout icons stay empty ``s, which Obsidian fills on insert. + Wait until it settles (images loaded, math rendered; 5 s cap). Then + `sanitize.ts`, a pure DOM pass. What the renderer really emits (spike on + Obsidian 1.13, `temp/spike-render.ts`) and what it becomes: + +| In the rendered DOM | Becomes | +| --- | --- | +| `pre.frontmatter` (raw YAML, hidden by an inline style) | removed: it holds every property, secrets included | +| `a.internal-link` (any vault link, also `[x](Other.md)`) | its text (alias or `Note > Heading`), no element | +| `a.tag` (`href="#tag"`) | its text in `span.tag` | +| `a.external-link` | kept; the viewer adds `rel` and `target` | +| footnote `a[href^="#"]` | kept, its `target` and `rel` dropped | +| `span.internal-embed.markdown-embed` (the other note's whole body inlined) | removed, counted; never published | +| `span.internal-embed` that is audio, video, pdf, missing (`mod-empty-attachment`) | removed, counted | +| `span.image-embed > img[src=app://...]` | `img` with a `data:` URI read from the vault by the span's `src` link text; width kept; over 1600 px re-encoded smaller; per image 1.5 MiB, total within the cap, else dropped and counted | +| external `img` | kept (the viewer's IP goes to that host; `no-referrer` is set) | +| `%%comment%%`, `` | dropped by the renderer, leaving an empty `

`: empty paragraphs removed | +| `button.copy-code-button` | removed | +| `.callout-content[style="display: none"]` (folded) | style removed; `is-collapsed` on the callout and the viewer's CSS fold it | +| `.mermaid-wrapper.is-guarded` (the vault has not allowed Mermaid) | replaced by its source as a code block; a rendered `svg` is kept | +| `mjx-container` (MathJax CHTML: per-glyph rules in a runtime stylesheet, fonts loaded from `app://`, no TeX kept) | replaced by MathML. `math.ts` patches `window.MathJax.tex2chtml` (the main window's global; `activeWindow` is not it) while a snapshot runs, tagging each node with its TeX and display mode, then converts with `MathJax.tex2mml`. The viewer's browser draws MathML itself: no stylesheet, fonts or payload field travel. A formula MathJax cannot convert stays as its TeX in `code`. | +| `node-insert-event`, `data-href`, `dir`, `contenteditable`, `draggable`, handlers | removed; other `class` and `data-*` kept | +| task checkboxes | disabled | + +Properties are never published (no option): the renderer does not draw them. + +- Publish: seal (protocol), then `PUT /link/` through + `storage/adapters/link-broker.ts` (beside `share-broker.ts`, `requestUrl`, + admin header); `relayAdmin` moves from `ui/shares/share-action.ts` to + `settings/model.ts` beside `RelayConfig`, so links never import `ui/shares`. + Then record `LinkRecord` (`links/record.ts`: id, url with key, path, + showTitle, createdAt, publishedAt, expiry as the relay set it, view limit, + salt, images, detached) in `settings.links` (device-local, never in device + transfer). If the record cannot be saved the link is revoked again, or it + would be live with no key here to stop it. Loading keeps any record with an + id and a link (`parseLinkRecord` repairs the rest): a record is never dropped + with its key. An older relay answers `not_found`: the error says to redeploy it. +- `SharedLinks` (`links/shared-links.ts`, `plugin.sharedLinks`) is the only + writer of `settings.links`: add, remove, `published` (Update), `move` + (rename), `detach` (the note was deleted: its links stay live and listed + under Manage, with no Update, and a new note at that path starts clean); + each change saves and notifies subscribers. +- Shared-note marks: `noteLinks` (`links/note-links.ts`) gives a note's + unexpired links and `stale` (`file.stat.mtime > publishedAt`; `publishedAt` + is taken just before the note is read, and a pulled write gets a local + mtime). A `globe` badge in the file tree (`mdsync-published-badge`, shown + even with file indicators off; not `link-2`, which marks a symlinked path) + and a header button on the open note (`ui/links/note-link-action.ts`) open + the note's links; a dot marks stale. The list starts a stale row with + "Changed since it was shared." Command `update-note-links` updates the + active note's links. +- Management (`ui/links/manage-links-modal.ts`, opened by the command **Manage + share links**, the settings Sync tab's **Share links**, or the note menu's + **Share links of this note (n)**): a row per link with views left (status + call), expiry, **Copy link**, **Update** (re-render, same id and key, + counter, limits and expiry kept; a protected link asks for its passphrase + and the relay refuses one that is not the link's) and **Stop sharing** (revoke at the relay, then forget). An ended + link, or one made through another relay than the vault's current one, can + only be removed from the list: nothing here can stop it. A rename (of the + note or a folder above it) updates the stored path (`plugin/links.ts`). +- `data.json` keeps the link with its key in plain text, like share keys; add + it to the security notes. + +## Steps + +Each step ships green (`pnpm lint`, `typecheck`, `test`) on its own. + +1. **Protocol**: `packages/protocol/src/link.ts` (keys, gate, seal, open); + vectors, wrong key, wrong AAD, tamper. +2. **Relay**: `Link` DO, routes, admin and public auth, migration `v3`, caps, + cooldown, alarm. Tests in-process like the hub's (`memory-sql`): one-view + link with N concurrent opens serves exactly one, expiry, gate and + cooldown, update keeps the counter, revoke, oversize, admin-only routes. +3. **Viewer + serving**: package, build, `[assets]`, `/s/`, headers; the + `pnpm build`, `typecheck`, `lint` and the Deploy Relay workflow build it + first. Tested with `wrangler dev` and a fixed payload (`pnpm e2e:links`, + a headless browser over CDP: text, passphrase, limit gone, reload costs no + view). +4. **Spike, in a real Obsidian over CDP** (done): the table above comes from + it. `require("obsidian")` is not reachable from the console, so the spike + loads a two-line plugin that exposes it. +5. **Snapshot**: `sanitize.ts` with unit tests on the spike's HTML as a + fixture (`tests/links/fixtures/rendered-note.html`; jsdom as a plugin + devDependency, per test file; tests run in `node` by default), `images.ts` + (budget), `math.ts` (MathML), `snapshot.ts`. +6. **Create flow**: modal, publish, copy, `settings.links`, commands, file + menu, no-relay and old-relay messages, `styles/links.css`. +7. **Manage**: list with status, update, stop, rename tracking. + `pnpm e2e:links-obsidian` runs the whole loop in a real Obsidian: menu, + modal, passphrase, view count, update, stop. +8. **Docs and release**: README, `security.md`, `commands-and-settings.md`, + `architecture.md`, `testing.md`, `CHANGELOG.md`. + +Deploying: run the Deploy Relay workflow again (it builds the viewer; the +relay gains the `Link` Durable Object, migration `v3`, and static assets). + +## Open checks + +- `run_worker_first` array and negation syntax in wrangler ^4.131 (step 3). +- `MarkdownRenderer.render` is current for `minAppVersion` 1.13 (step 4). +- Settled in steps 1 and 2: the passphrase stretch is its own PBKDF2 (600k) in + `packages/protocol/src/link.ts`, not the vault's `deriveKey`; a blob is + chunked at 1 MiB and checked in workerd up to the 6 MiB cap + (`pnpm e2e:links`); `deleteAll` then a new link on the same id works. + +## Accepted limitations + +- A view limit and a passphrase deter, they are not DRM: a reader can copy or + screenshot. +- Without a passphrase the link is the capability: a leaked full URL opens + until the limit or expiry. Revoke is immediate. +- The relay serves the viewer script, so a hostile operator could read a + note. The operator here is the owner of the deployment (one trust domain). +- No link previews: the content is sealed, the shell is generic. +- A closed tab after the response, or a failed decrypt, still counts a view. +- A sealed note over about 3.9 MiB does not fit the tab's `sessionStorage`, so a + reload of it spends another view. +- Pasted HTML with `style` attributes can still position or overlay parts of + the page (the CSP allows inline style attributes for the note's own layout). + The author is the link's owner. +- Every random id asked of the public routes wakes a Durable Object; only the + relay's own cost limits that. +- "Changed since it was shared" compares file times, so a touch, a sync rewrite + or an autosave after sharing an unsaved note can show it falsely. +- A heading link opened in another tab counts a view: the cache is per tab. +- The snapshot is static until **Update**; it looks as the owner's Obsidian, + theme-independent, rendered. Other plugins' output (Dataview) is published + as rendered, which can include data from other notes: the preview is the + check. +- The link list is per device: another device does not list or revoke it. +- Markdown notes only: drawings, canvases and bases are not offered. diff --git a/.claude/testing.md b/.claude/testing.md index a34ea216..e6bb354b 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -21,6 +21,9 @@ re-injected only on reload. `tests/e2e/` runs scenarios against real processes and tears them down: - `pnpm exec jiti tests/e2e/optimization-ui.ts` - a real Obsidian renders synthetic 20,000-file Timeline and Trash lists in 100-row pages; page navigation and full-list selection are checked. Build first. Uses ports 8832 and 9233. +- `pnpm e2e:settings` - an isolated Obsidian checks passphrase and toggle scroll retention, device-only ignore rules, and phone layouts at 320, 390 and 600 px. Uses port 9235; `E2E_SHOTS=1` saves screenshots. +- `pnpm e2e:links` - share links under `wrangler dev` and headless Chrome: the Link Durable Object (chunks, erase, expiry, a restart), the routes, the viewer page (passphrase, one view, reload costs none, a hostile note runs nothing, outline on desktop and phone, heading folds, heading anchors, wide text, code copy). Port 8799. +- `pnpm e2e:links-obsidian` - builds, then shares a note from a real Obsidian through the file menu and opens the link in Chrome: properties, comments and embedded notes absent, math as MathML, callout, task and image kept, outline from the real render, passphrase, view count, update and stop. Ports 8799 and 9246; `E2E_SHOTS=1` saves `link-viewer.png`. - `pnpm e2e:hub` - the relay hub under `wrangler dev`, driven by scripted peers; ending a share cuts its channel for good (across a restart too); its last run sets `HUB_STALE_MS` low to watch the stale-socket sweep. diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 6a2ed920..bce9c244 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -24,6 +24,9 @@ jobs: - name: Test run: pnpm --filter mdsync-relay run test + - name: Build link viewer + run: pnpm --filter mdsync-viewer run build + # The KV namespace and Durable Object are created by wrangler on the first # deploy; only the secrets below come from the repository. - name: Deploy diff --git a/.gitignore b/.gitignore index c62c4e54..7bb36bce 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ main.js # Bundled from src/styles/, same as main.js. packages/plugin/styles.css +# The link viewer's build, served by the relay. +packages/viewer/dist + # Exclude sourcemaps *.map @@ -30,4 +33,7 @@ temp # coverage output coverage -artifacts \ No newline at end of file +artifacts + +# Local code index. +.codegraph/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8ebac1..ce7a58c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ This file is the source of truth for release notes: the section of a version becomes the notes of its GitHub release. The newest section must match `version` in `manifest.json`. Headings are `## [x.y.z] - YYYY-MM-DD`, then `### Important`, `### Features`, `### Improvements` and `### Fixes` as needed. Write for users, in plain sentences, not commit titles. +## [1.1.0] - 2026-10-08 + +### Features + +- **Share links.** Publish one note as a link that opens in any browser, with an optional passphrase, a view limit and an expiry. The note is drawn as Obsidian draws it, with an outline, folding headings, links to a heading, copyable code and a button that copies the whole note as Markdown, encrypted on your device and kept on your own relay, which cannot read it. Properties, comments, links to other notes and embedded notes are left out; images are embedded. A globe in the file tree and the note header marks shared notes, with a dot once the note changed since; click it to update the link or stop it. A reader can let their browser remember the passphrase. Redeploy the relay to use it. + +### Improvements + +- The passphrase settings tell the passphrase saved on this device apart from the vault passphrase, and say what each button does. +- The settings tab keeps its scroll position when it redraws. + ## [1.0.0] - 2026-10-03 First release. diff --git a/README.md b/README.md index afb99a0c..29d10a96 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ MDSync syncs your vault through an S3 bucket, a WebDAV server or Google Drive. T - [Live editing](#live-editing): notes and Excalidraw drawings edit together, keystroke by keystroke. - [Presence](#presence): who has which note open, and following someone through the vault. - [Shared folders](#shared-folders): share a folder with other people without handing them your storage keys. +- [Share links](#share-links): publish one note as a link anyone can read, with an optional passphrase, a view limit and an expiry. **Setup and upkeep** @@ -248,6 +249,25 @@ From the share's window you can see who is in it, revoke a person, pause the sha +### Share links + +Publish one note as a link anyone can open in a browser with nothing installed, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**. It needs the relay, which serves the page. + +

+Demo + +![A note shared as a link with a passphrase, read in a browser, then stopped](docs/demos/links.webp) + +
+ +- **Expires** (5 minutes to 30 days, an exact date, or never), **Views** and an optional **passphrase** limit who can read it and for how long. Once the time or the views run out, the relay erases the note. +- The note is drawn as Obsidian's reading view draws it, then encrypted on your device. The key is in the part of the link after `#`, which browsers never send, so the relay holds only ciphertext. +- The page has an outline, folding headings, copyable code, wide text and **Copy as Markdown**. +- Left out: properties, comments, links to other notes (their text stays) and embedded notes. Vault images are embedded and shrunk; **Include images** turns that off. The window lists what was left out before you create the link. +- **Manage share links** shows the views left, copies the link, **Update**s it to the note as it is now (same link) or stops it at once. A globe in the file tree and in the note's header marks a shared note; a dot means it changed since. + +A link is a copy: later edits do not reach it until you update it. Anyone who has the link (and the passphrase) can read the note and pass it on. The links, with their keys, are kept in the plugin's `data.json`. + ## Device transfer **Settings → MDSync → Connection → Export setup** creates an encrypted link and QR code with your main sync settings, storage credentials included. It leaves out the cached passphrase and display preferences. The link is encrypted with your passphrase, so the new device needs the same passphrase and confirms the import before anything is applied. @@ -275,6 +295,7 @@ From the share's window you can see who is in it, revoke a person, pause the sha - **Forget cached passphrase**. - **Manage sharing**: the sharing window of the open file's shared folder. - **Accept shared folder invite**. +- **Share this note as a link** / **Update share links of this note** / **Manage share links**. - **Show relay status**: relay state per space and who is in shared notes. - **Show live menu of this note**. - **Toggle authors in live notes**. @@ -295,6 +316,8 @@ Storage credentials, share keys and the relay secret are stored in the plugin's **What the relay sees.** It never sees file contents, file names or keys: live edits and presence travel encrypted under each space's key. It does see a hash of your storage identity, a device id per connection, and when each sync and note switch happens. For a shared folder with participants, it keeps your S3 access key in its storage to sign requests for them, plus the names you invited people by. With Google Drive, it exchanges your sign-in for tokens and sees your Drive refresh token. +**Share links.** Creating one sends a rendered copy of that note to your relay, encrypted on your device. The relay stores the ciphertext, a fingerprint of the passphrase check, the view limit and the expiry, and counts views. It never sees the key (it stays in the part of the link after `#`), the passphrase, or the text. The page a recipient opens is served by your relay and decrypts in their browser. +
What MDSync writes to your storage @@ -313,7 +336,7 @@ Storage credentials, share keys and the relay secret are stored in the plugin's ## Self-hosting the relay -The relay is `packages/relay`: one Cloudflare Worker that handles instant sync, live editing, invites to shared folders and the Google Drive sign-in. It fits in the Workers free tier. +The relay is `packages/relay`: one Cloudflare Worker that handles instant sync, live editing, invites to shared folders, share links and the Google Drive sign-in. It fits in the Workers free tier. 1. Fork this repository. 2. In **Settings → MDSync → Connection → Relay server**, select **Generate**. The secret is copied to your clipboard. @@ -343,7 +366,7 @@ pnpm typecheck pnpm test ``` -The plugin lives in `packages/plugin`, the relay in `packages/relay`. +The plugin lives in `packages/plugin`, the relay in `packages/relay`, the page share links open in `packages/viewer` (built into the relay's static assets), and the format both share in `packages/protocol`. ## License diff --git a/biome.json b/biome.json index ef44d9bb..ef294840 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!**/tests/**/fixtures/**/*.html"] }, "formatter": { "enabled": true, diff --git a/docs/demos/links.webp b/docs/demos/links.webp new file mode 100644 index 00000000..5eface10 Binary files /dev/null and b/docs/demos/links.webp differ diff --git a/manifest.json b/manifest.json index d2a39c28..ef1f2a62 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "mdsync", "name": "MDSync", - "version": "1.0.0", + "version": "1.1.0", "minAppVersion": "1.13.0", "description": "Sync your vault end-to-end encrypted through your own S3, WebDAV or Google Drive storage, with conflict resolution, file history, shared folders and live editing.", "author": "kitbyte", diff --git a/package.json b/package.json index 7dbb6f0a..ed71b442 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mdsync-monorepo", - "version": "1.0.0", + "version": "1.1.0", "description": "Monorepo for the MDSync Obsidian plugin and its companion services.", "private": true, "type": "module", @@ -15,7 +15,10 @@ "version": "node version-bump.mjs", "obsidian": "node tools/obsidian.mjs", "e2e:hub": "jiti tests/e2e/hub.ts", + "e2e:links": "jiti tests/e2e/links.ts", + "e2e:links-obsidian": "pnpm build && jiti tests/e2e/links-obsidian.ts", "e2e:realtime": "pnpm build && jiti tests/e2e/realtime.ts", + "e2e:settings": "pnpm build && jiti tests/e2e/settings.ts", "e2e:live": "pnpm build && jiti tests/e2e/live.ts", "e2e:live-share": "pnpm build && jiti tests/e2e/live-share.ts", "e2e:live-into-share": "pnpm build && jiti tests/e2e/live-into-share.ts", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index bb7fca82..931dfd96 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "mdsync", - "version": "1.0.0", + "version": "1.1.0", "description": "Encrypted vault sync for Obsidian over your own storage.", "main": "main.js", "type": "module", @@ -30,6 +30,7 @@ "@types/node": "^22.20.1", "@types/qrcode": "^1.5.6", "esbuild": "^0.25.12", + "jsdom": "^30.1.2", "mdsync-relay": "workspace:*", "vitest": "^4.1.11" }, diff --git a/packages/plugin/src/commands/index.ts b/packages/plugin/src/commands/index.ts index 3ce6f2a1..83c8b5ae 100644 --- a/packages/plugin/src/commands/index.ts +++ b/packages/plugin/src/commands/index.ts @@ -1,15 +1,20 @@ import { MarkdownView, type Plugin } from "obsidian"; + import { SOURCE_CONTROL_VIEW_TYPE } from "@/constants"; +import { noteLinks, onRelay } from "@/links"; import type { PluginHost } from "@/plugin/host"; import { spaceOf } from "@/sync/space"; import type { DiffResult } from "@/sync/types"; import { canRebuild, deepCleanOrphanedObjects, + isLinkable, notifyError, notifyInfo, openDiffView, openInvite, + openManageLinks, + openShareLink, openShareWindow, openSourceControlDeleted, openSourceControlHistory, @@ -19,6 +24,7 @@ import { resetRemoteStorage, runWithNotice, toggleAuthors, + updateSharedLinks, verifyRemoteIntegrity, } from "@/ui"; @@ -156,6 +162,38 @@ export function registerCommands( }, }); + plugin.addCommand({ + id: "share-note-link", + name: "Share this note as a link", + checkCallback: (checking) => { + const file = plugin.app.workspace.getActiveFile(); + if (!isLinkable(plugin.app, file)) return false; + if (!checking) openShareLink(plugin, file); + return true; + }, + }); + + plugin.addCommand({ + id: "update-note-links", + name: "Update share links of this note", + checkCallback: (checking) => { + const file = plugin.app.workspace.getActiveFile(); + if (!file) return false; + const records = noteLinks(plugin, file.path)?.records.filter((record) => + onRelay(record, plugin.settings), + ); + if (records === undefined || records.length === 0) return false; + if (!checking) void updateSharedLinks(plugin, records); + return true; + }, + }); + + plugin.addCommand({ + id: "manage-note-links", + name: "Manage share links", + callback: () => openManageLinks(plugin), + }); + plugin.addCommand({ id: "accept-shared-folder-invite", name: "Accept shared folder invite", diff --git a/packages/plugin/src/links/describe.ts b/packages/plugin/src/links/describe.ts new file mode 100644 index 00000000..77aec217 --- /dev/null +++ b/packages/plugin/src/links/describe.ts @@ -0,0 +1,125 @@ +import type { LinkStatus } from "@mdsync/protocol"; +import { LINK_MAX_TTL_S } from "@mdsync/protocol"; + +import type { LinkRecord } from "./record"; + +const MS_PER_S = 1000; +const MINUTE_S = 60; +const HOUR_S = 60 * MINUTE_S; +const DAY_S = 24 * HOUR_S; + +/** The expiry choice that reads an exact date from the owner instead of a span. */ +export const PICK_DATE = "date"; + +export const EXPIRY_CHOICES = [ + { key: "five-minutes", label: "5 minutes", seconds: 5 * MINUTE_S }, + { key: "fifteen-minutes", label: "15 minutes", seconds: 15 * MINUTE_S }, + { key: "hour", label: "1 hour", seconds: HOUR_S }, + { key: "day", label: "1 day", seconds: DAY_S }, + { key: "week", label: "7 days", seconds: 7 * DAY_S }, + { key: "month", label: "30 days", seconds: 30 * DAY_S }, + { key: "never", label: "Never", seconds: null }, + { key: PICK_DATE, label: "Pick a date…" }, +] as const; + +export const VIEW_CHOICES = [ + { key: "1", label: "1 view", views: 1 }, + { key: "5", label: "5 views", views: 5 }, + { key: "25", label: "25 views", views: 25 }, + { key: "any", label: "Unlimited", views: null }, +] as const; + +export const DEFAULT_EXPIRY = "week"; +export const DEFAULT_VIEWS = "any"; + +export type ExpiryResult = + | { + ok: true; + /** What the relay is sent; it counts the span on its own clock. */ + ttl: number | null; + /** Where this device expects it to end, for the owner to read. */ + expires: number | null; + } + | { ok: false; reason: string }; + +export function resolveExpiry( + key: string, + pickedDate: string, + nowMs: number, +): ExpiryResult { + const choice = EXPIRY_CHOICES.find((choice) => choice.key === key); + if (!choice) return { ok: false, reason: "Choose an expiry." }; + const expiringIn = (ttl: number | null): ExpiryResult => ({ + ok: true, + ttl, + expires: ttl === null ? null : Math.floor(nowMs / MS_PER_S) + ttl, + }); + if (choice.key !== PICK_DATE) return expiringIn(choice.seconds); + const pickedMs = new Date(pickedDate).getTime(); + if (!Number.isFinite(pickedMs)) { + return { ok: false, reason: "Pick a date and time." }; + } + const aheadMs = pickedMs - nowMs; + if (aheadMs < MINUTE_S * MS_PER_S) { + return { ok: false, reason: "Pick a date at least 1 minute ahead." }; + } + if (aheadMs > LINK_MAX_TTL_S * MS_PER_S) { + return { + ok: false, + reason: `Pick a date no more than ${LINK_MAX_TTL_S / DAY_S} days ahead.`, + }; + } + return expiringIn(Math.floor(aheadMs / MS_PER_S)); +} + +export function expiryLine(expires: number | null): string { + const text = expiryText(expires); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; +} + +/** Where a link stands, for its owner; `null` status means the relay no longer has it. */ +export function linkStatusText( + record: LinkRecord, + status: LinkStatus | null, +): string { + if (!status) return "Ended: it expired, was used up or was stopped."; + const views = + status.maxViews === null + ? `${status.views} ${status.views === 1 ? "view" : "views"}` + : `${Math.max(status.maxViews - status.views, 0)} of ${status.maxViews} views left`; + const parts = [views, expiryText(status.expires)]; + if (record.salt) parts.push("passphrase"); + return parts.join(" · "); +} + +function expiryText(expires: number | null): string { + if (expires === null) return "never expires"; + const when = new Date(expires * MS_PER_S).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + return `expires ${when}`; +} + +/** What the preview tells the owner was left out; empty when nothing was. */ +export function leftOutText(left: { + embeds: number; + images: number; + diagrams: number; +}): string { + const parts: string[] = []; + if (left.embeds) { + parts.push( + `${left.embeds} embedded ${left.embeds === 1 ? "file" : "files"}`, + ); + } + if (left.images) { + parts.push(`${left.images} ${left.images === 1 ? "image" : "images"}`); + } + if (left.diagrams) { + parts.push( + `${left.diagrams} ${left.diagrams === 1 ? "diagram" : "diagrams"} (shown as text)`, + ); + } + return parts.join(", "); +} diff --git a/packages/plugin/src/links/errors.ts b/packages/plugin/src/links/errors.ts new file mode 100644 index 00000000..1d18df84 --- /dev/null +++ b/packages/plugin/src/links/errors.ts @@ -0,0 +1,10 @@ +import { errorMessage } from "@/shared"; +import { StorageRequestError } from "@/storage"; + +/** A relay refusal in the owner's words; any other error as it came. */ +export function linkError(err: unknown): Error { + if (err instanceof StorageRequestError) { + return new Error(err.userMessage); + } + return err instanceof Error ? err : new Error(errorMessage(err)); +} diff --git a/packages/plugin/src/links/images.ts b/packages/plugin/src/links/images.ts new file mode 100644 index 00000000..fdd4af5e --- /dev/null +++ b/packages/plugin/src/links/images.ts @@ -0,0 +1,83 @@ +import type { App } from "obsidian"; + +const MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + avif: "image/avif", +}; +/** An image under this goes as it is (a JPEG apart); a bigger photo is re-encoded smaller. */ +const KEEP_BYTES = 300 * 1024; +const MAX_EDGE = 1600; +/** A note's images share the link's size limit with its text. */ +export const IMAGE_MAX_BYTES = 1.5 * 1024 * 1024; +const REENCODE_QUALITY = 0.82; + +/** The vault image a note links to as a `data:` URI; null when it is missing, not an image, or too big. */ +export async function inlineImage( + app: App, + sourcePath: string, + link: string, +): Promise { + const target = app.metadataCache.getFirstLinkpathDest( + decodeLink(link), + sourcePath, + ); + const mime = target && MIME[target.extension.toLowerCase()]; + if (!target || !mime) return null; + const bytes = await app.vault.readBinary(target); + let blob = new Blob([bytes], { type: mime }); + // An animation or vector would lose what makes it itself. + const flat = mime !== "image/gif" && mime !== "image/svg+xml"; + // A camera's JPEG carries its EXIF (GPS included); drawing it anew leaves that behind. + const photo = mime === "image/jpeg"; + if (flat && (photo || bytes.byteLength > KEEP_BYTES)) { + const redrawn = await reencode(blob); + if (photo && !redrawn) return null; + if (redrawn && (photo || redrawn.size < blob.size)) blob = redrawn; + } + return blob.size <= IMAGE_MAX_BYTES ? readAsDataUrl(blob) : null; +} + +/** Obsidian writes links with spaces as `%20` in some embeds. */ +function decodeLink(link: string): string { + try { + return decodeURIComponent(link); + } catch { + return link; + } +} + +async function reencode(blob: Blob): Promise { + try { + const bitmap = await createImageBitmap(blob); + const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height)); + const canvas = new OffscreenCanvas( + Math.max(1, Math.round(bitmap.width * scale)), + Math.max(1, Math.round(bitmap.height * scale)), + ); + canvas + .getContext("2d") + ?.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + return await canvas.convertToBlob({ + type: "image/webp", + quality: REENCODE_QUALITY, + }); + } catch { + return null; + } +} + +function readAsDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} diff --git a/packages/plugin/src/links/index.ts b/packages/plugin/src/links/index.ts new file mode 100644 index 00000000..bb595aea --- /dev/null +++ b/packages/plugin/src/links/index.ts @@ -0,0 +1,34 @@ +export { + DEFAULT_EXPIRY, + DEFAULT_VIEWS, + EXPIRY_CHOICES, + expiryLine, + leftOutText, + linkStatusText, + PICK_DATE, + resolveExpiry, + VIEW_CHOICES, +} from "./describe"; +export { linkError } from "./errors"; +export { type NoteLinks, noteLinks, noteLinksText } from "./note-links"; +export { + linkStatusOf, + NO_RELAY, + previewLink, + publishLink, + type RenderedNote, + revokeLinkRecord, + type ShareOptions, + TOO_LARGE, + updateLink, +} from "./publish"; +export { + isExpired, + isStale, + type LinkRecord, + noteName, + onRelay, + parseLinkRecord, +} from "./record"; +export { SharedLinks } from "./shared-links"; +export type { Snapshot } from "./snapshot"; diff --git a/packages/plugin/src/links/math.ts b/packages/plugin/src/links/math.ts new file mode 100644 index 00000000..94ea9e64 --- /dev/null +++ b/packages/plugin/src/links/math.ts @@ -0,0 +1,110 @@ +import { loadMathJax } from "obsidian"; + +const TEX = "data-mdsync-tex"; +const DISPLAY = "data-mdsync-display"; + +interface MathOptions { + display?: boolean; +} + +/** MathJax 3 as Obsidian bundles it (checked on 1.13: 3.2.2); it has no types of its own. */ +interface MathJaxGlobal { + tex2chtml?: (source: string, options?: MathOptions) => Element; + tex2chtmlPromise?: ( + source: string, + options?: MathOptions, + ) => Promise; + tex2mml?: (source: string, options?: MathOptions) => string; +} + +/** The main window's: the global Obsidian's own renderer calls, whichever window is active. */ +const mathJax = (): MathJaxGlobal | undefined => + (window as unknown as { MathJax?: MathJaxGlobal }).MathJax; + +let tagging = 0; +let undo: (() => void) | null = null; + +/** + * Obsidian draws math as MathJax's own markup and keeps no TeX in it, so the source is tagged on each node as it + * is made. Returns the release; tagging stays on while any caller holds it. + */ +export async function tagMathSources(markdown: string): Promise<() => void> { + if (markdown.includes("$")) await loadMathJax(); + const mj = mathJax(); + if (!mj) return () => {}; + if (tagging++ === 0) undo = patch(mj); + let released = false; + return () => { + if (released) return; + released = true; + if (--tagging === 0) { + undo?.(); + undo = null; + } + }; +} + +function patch(mj: MathJaxGlobal): () => void { + const { tex2chtml, tex2chtmlPromise } = mj; + const tag = ( + source: string, + options: MathOptions | undefined, + node: Element, + ) => { + node.setAttribute(TEX, source); + if (options?.display) node.setAttribute(DISPLAY, ""); + return node; + }; + if (tex2chtml) { + mj.tex2chtml = (source, options) => + tag(source, options, tex2chtml.call(mj, source, options)); + } + if (tex2chtmlPromise) { + mj.tex2chtmlPromise = async (source, options) => + tag(source, options, await tex2chtmlPromise.call(mj, source, options)); + } + return () => { + if (tex2chtml) mj.tex2chtml = tex2chtml; + if (tex2chtmlPromise) mj.tex2chtmlPromise = tex2chtmlPromise; + }; +} + +/** + * Swaps MathJax's styled markup for plain MathML, which the viewer's browser draws itself: no stylesheet and no + * fonts travel with the link. A formula MathJax cannot convert stays as its TeX. + */ +export function mathToMathML(root: HTMLElement): void { + const mj = mathJax(); + const parser = new DOMParser(); + for (const node of Array.from(root.querySelectorAll(`[${TEX}]`))) { + const source = node.getAttribute(TEX) ?? ""; + const display = node.hasAttribute(DISPLAY); + const mml = convert(mj, source, display, parser); + if (mml) { + // The viewer's Markdown copy writes the formula back as TeX. + mml.setAttribute("data-tex", source); + node.replaceWith(mml); + } else { + const code = root.ownerDocument.createElement("code"); + code.textContent = source; + node.replaceWith(code); + } + } +} + +function convert( + mj: MathJaxGlobal | undefined, + source: string, + display: boolean, + parser: DOMParser, +): Element | null { + try { + const markup = mj?.tex2mml?.(source, { display }); + if (!markup) return null; + const math = parser.parseFromString(markup, "text/html").body + .firstElementChild; + return math?.localName === "math" ? math : null; + } catch { + return null; + } +} diff --git a/packages/plugin/src/links/note-links.ts b/packages/plugin/src/links/note-links.ts new file mode 100644 index 00000000..aabb218e --- /dev/null +++ b/packages/plugin/src/links/note-links.ts @@ -0,0 +1,32 @@ +import type { PluginHost } from "@/plugin/host"; + +import { expiryLine } from "./describe"; +import { isExpired, isStale, type LinkRecord } from "./record"; + +/** A note's live links, and whether one still shows a copy older than the note. */ +export interface NoteLinks { + records: LinkRecord[]; + stale: boolean; +} + +/** Null when the note has no links, or only expired ones. */ +export function noteLinks(plugin: PluginHost, path: string): NoteLinks | null { + const records = plugin.sharedLinks + .of(path) + .filter((record) => !isExpired(record)); + if (records.length === 0) return null; + const mtime = plugin.app.vault.getFileByPath(path)?.stat.mtime ?? 0; + return { records, stale: records.some((record) => isStale(record, mtime)) }; +} + +/** The tooltip of a shared note's badge and header button. */ +export function noteLinksText({ records, stale }: NoteLinks): string { + const [only] = records; + const shared = + records.length === 1 && only + ? `Shared by link. ${expiryLine(only.expires)}` + : `Shared by ${records.length} links.`; + return stale + ? `${shared} Changed since it was shared: click to update.` + : `${shared} Click to manage.`; +} diff --git a/packages/plugin/src/links/note-text.ts b/packages/plugin/src/links/note-text.ts new file mode 100644 index 00000000..6bca797d --- /dev/null +++ b/packages/plugin/src/links/note-text.ts @@ -0,0 +1,12 @@ +import { type App, MarkdownView, type TFile } from "obsidian"; + +/** An open view is newer than the file it saves a moment later; `getViewData` is right in source and reading mode. */ +export async function noteText(app: App, file: TFile): Promise { + for (const leaf of app.workspace.getLeavesOfType("markdown")) { + const { view } = leaf; + if (view instanceof MarkdownView && view.file?.path === file.path) { + return view.getViewData(); + } + } + return app.vault.read(file); +} diff --git a/packages/plugin/src/links/publish.ts b/packages/plugin/src/links/publish.ts new file mode 100644 index 00000000..9454958c --- /dev/null +++ b/packages/plugin/src/links/publish.ts @@ -0,0 +1,201 @@ +import { + deriveLinkKeys, + fromBase64Url, + LINK_MAX_SEALED_BYTES, + linkUrl, + newLinkId, + newLinkKey, + newLinkSalt, + parseLinkLocation, + sealLinkPayload, + toBase64Url, +} from "@mdsync/protocol"; +import type { TFile } from "obsidian"; + +import type { PluginHost } from "@/plugin/host"; +import { isRelayConfigured, relayAdmin } from "@/settings/model"; +import { relayBase } from "@/shared"; +import { + type BrokerAdmin, + createLink, + linkStatus, + replaceLink, + revokeLink, +} from "@/storage"; +import { noteText } from "./note-text"; +import { type LinkRecord, onRelay } from "./record"; +import { type Snapshot, takeSnapshot } from "./snapshot"; + +export interface ShareOptions { + /** Seconds from now, counted by the relay; null never expires. */ + ttl: number | null; + maxViews: number | null; + passphrase: string | null; + /** Off publishes the note without its name. */ + showTitle: boolean; +} + +export const TOO_LARGE = "This note is too large to share as a link."; +export const NO_RELAY = "Set up the relay under Connection to share links."; +const OTHER_RELAY = + "This link was made through another relay: set that relay again to manage it."; +/** Ids of the links being updated now. */ +const updating = new Set(); + +/** What would be published, for the owner to look at first. */ +export async function previewLink( + plugin: PluginHost, + file: TFile, + images: boolean, +): Promise { + const takenAt = Date.now(); + const markdown = await noteText(plugin.app, file); + const rendered = await takeSnapshot(plugin.app, file, markdown, { images }); + return { ...rendered, takenAt }; +} + +/** The `snapshot` the owner looked at is exactly what goes out. */ +export async function publishLink( + plugin: PluginHost, + file: TFile, + options: ShareOptions, + snapshot: Snapshot, +): Promise { + if (!isRelayConfigured(plugin.settings)) throw new Error(NO_RELAY); + // The address and the upload must name the same relay, whatever the settings become meanwhile. + const admin = relayAdmin(plugin.settings); + const id = newLinkId(); + const key = newLinkKey(); + const salt = options.passphrase ? toBase64Url(newLinkSalt()) : null; + const sealed = await sealNote({ + id, + key, + salt, + passphrase: options.passphrase, + title: options.showTitle ? file.basename : "", + snapshot, + }); + const expires = await createLink(admin, id, sealed.bytes, { + maxViews: options.maxViews, + ttl: options.ttl, + gate: sealed.gate, + salt, + }); + const record: LinkRecord = { + id, + url: linkUrl(relayBase(admin.relayUrl), id, key), + // A rename during the upload moved only the links already stored. + path: file.path, + showTitle: options.showTitle, + createdAt: Date.now(), + publishedAt: snapshot.takenAt, + expires, + maxViews: options.maxViews, + salt, + images: snapshot.images, + // A deletion during the upload came before the record: a note made at that path since is another. + detached: plugin.app.vault.getFileByPath(file.path) !== file, + }; + try { + await plugin.sharedLinks.add(record); + } catch (err) { + // Without the record the key is lost and nothing here could ever stop the link. If it cannot be + // stopped now either, the record stays in memory for the next save and a retry. + await revokeLink(admin, id) + .then(() => plugin.sharedLinks.remove(id)) + .catch(() => undefined); + throw err; + } + return record; +} + +/** Snapshots taken for one batch of updates, by whether they carry images: a note is drawn at most twice. */ +export type RenderedNote = Map>; + +/** + * The same link, id and key, now showing the note as it is; views already spent stay spent. Updating several + * links of one note can share `rendered`. + */ +export async function updateLink( + plugin: PluginHost, + shown: LinkRecord, + passphrase: string | null, + rendered: RenderedNote = new Map(), +): Promise { + // Uploads can reach the relay in either order: a second one would leave the stale mark wrong. + if (updating.has(shown.id)) throw new Error("This link is already updating."); + updating.add(shown.id); + try { + // The note may have moved, or the link gone, while the passphrase was asked. + const record = plugin.sharedLinks.all().find(({ id }) => id === shown.id); + if (!record) throw new Error("This link is no longer shared."); + if (record.detached) throw new Error("The note was deleted."); + const admin = adminFor(plugin, record); + const file = plugin.app.vault.getFileByPath(record.path); + if (!file) throw new Error(`The note is no longer at "${record.path}".`); + const { hash, pathname } = new URL(record.url); + const location = parseLinkLocation(pathname, hash); + if (!location) + throw new Error("This link's key is damaged. Create a new one."); + const drawn = + rendered.get(record.images) ?? previewLink(plugin, file, record.images); + rendered.set(record.images, drawn); + const snapshot = await drawn; + const sealed = await sealNote({ + id: record.id, + key: location.key, + salt: record.salt, + passphrase, + title: record.showTitle ? file.basename : "", + snapshot, + }); + await replaceLink(admin, record.id, sealed.bytes, sealed.gate); + await plugin.sharedLinks.published(record.id, snapshot.takenAt); + } finally { + updating.delete(shown.id); + } +} + +/** Ends the link at the relay, then forgets it here; unreachable, it stays listed to try again. */ +export async function revokeLinkRecord( + plugin: PluginHost, + record: LinkRecord, +): Promise { + await revokeLink(adminFor(plugin, record), record.id); + await plugin.sharedLinks.remove(record.id); +} + +export async function linkStatusOf(plugin: PluginHost, record: LinkRecord) { + return linkStatus(adminFor(plugin, record), record.id); +} + +/** Asked again at each call: the relay in the settings may have changed since the list was drawn. */ +function adminFor(plugin: PluginHost, record: LinkRecord): BrokerAdmin { + if (!onRelay(record, plugin.settings)) throw new Error(OTHER_RELAY); + return relayAdmin(plugin.settings); +} + +async function sealNote(job: { + id: string; + key: Uint8Array; + /** Set exactly when the link has a passphrase, which is then needed. */ + salt: string | null; + passphrase: string | null; + title: string; + snapshot: Snapshot; +}): Promise<{ bytes: Uint8Array; gate: string }> { + const { id, key, salt, passphrase, title, snapshot } = job; + const protection = + salt && passphrase ? { passphrase, salt: fromBase64Url(salt) } : undefined; + if (salt && !protection) throw new Error("Enter the link's passphrase."); + const keys = await deriveLinkKeys(key, protection); + const bytes = await sealLinkPayload( + id, + { title, html: snapshot.html, createdAt: snapshot.takenAt }, + keys.content, + ).catch((error: unknown) => { + throw error instanceof RangeError ? new Error(TOO_LARGE) : error; + }); + if (bytes.length > LINK_MAX_SEALED_BYTES) throw new Error(TOO_LARGE); + return { bytes, gate: keys.gate }; +} diff --git a/packages/plugin/src/links/record.ts b/packages/plugin/src/links/record.ts new file mode 100644 index 00000000..6a178841 --- /dev/null +++ b/packages/plugin/src/links/record.ts @@ -0,0 +1,127 @@ +import { relayBase } from "@/shared"; + +const MS_PER_S = 1000; + +/** One share link this device published. */ +export interface LinkRecord { + id: string; + /** The whole link, key included: the only way to copy it again. */ + url: string; + /** Where the note was when last shared; a rename in Obsidian updates it. */ + path: string; + /** Whether the page carries the note's name; an update keeps the owner's choice. */ + showTitle: boolean; + /** Milliseconds. */ + createdAt: number; + /** Milliseconds; the last create or update. A note modified later shows a changed copy. */ + publishedAt: number; + /** Unix seconds, as the relay set it; null never expires. */ + expires: number | null; + maxViews: number | null; + /** base64url; set exactly when the link has a passphrase. An update needs it to seal again. */ + salt: string | null; + /** Whether its images went along: an update keeps the owner's choice. */ + images: boolean; + /** The note was deleted: the link stays live and listed, but no note of that path feeds it any more. */ + detached: boolean; +} + +/** + * Keeps whatever a stored record still tells: the id and the link are all that stopping it takes, so a record + * missing other fields is repaired rather than dropped with its key. + */ +export function parseLinkRecord(value: unknown): LinkRecord | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + const { id, url, path } = record; + if (typeof id !== "string" || typeof url !== "string") return null; + return { + id, + url, + path: typeof path === "string" ? path : "", + // Records of the first builds kept the choice as `title`, empty when the name was hidden. + showTitle: + typeof record.showTitle === "boolean" + ? record.showTitle + : record.title !== "", + createdAt: numberOr(record.createdAt, 0), + publishedAt: numberOr(record.publishedAt, 0), + expires: numberOr(record.expires, null), + maxViews: numberOr(record.maxViews, null), + salt: typeof record.salt === "string" ? record.salt : null, + images: record.images !== false, + detached: record.detached === true || typeof path !== "string", + }; +} + +function numberOr(value: unknown, fallback: T): number | T { + return typeof value === "number" ? value : fallback; +} + +/** The note's name as last known: its file name without the extension. */ +export function noteName(record: LinkRecord): string { + return record.path + .slice(record.path.lastIndexOf("/") + 1) + .replace(/\.md$/, ""); +} + +/** The note changed after its last publish: the link shows an older copy. */ +export function isStale(record: LinkRecord, mtime: number): boolean { + return mtime > record.publishedAt; +} + +/** Past its expiry the relay has erased it, whatever was last heard. */ +export function isExpired(record: LinkRecord, now = Date.now()): boolean { + return record.expires !== null && record.expires * MS_PER_S <= now; +} + +/** Only the relay that holds a link can stop or update it. */ +export function onRelay( + record: LinkRecord, + relay: { relayUrl: string; relaySecret: string }, +): boolean { + if (!relay.relayUrl || !relay.relaySecret) return false; + try { + return ( + new URL(record.url).origin === new URL(relayBase(relay.relayUrl)).origin + ); + } catch { + return false; + } +} + +/** The links after a note, or the folder holding it, moved; null when none was affected. */ +export function renamedLinks( + links: readonly LinkRecord[], + from: string, + to: string, +): LinkRecord[] | null { + return changedLinks(links, from, (record) => ({ + ...record, + path: to + record.path.slice(from.length), + })); +} + +/** The links after a note, or the folder holding it, was deleted; null when none was affected. */ +export function detachedLinks( + links: readonly LinkRecord[], + path: string, +): LinkRecord[] | null { + return changedLinks(links, path, (record) => ({ ...record, detached: true })); +} + +/** Applies `change` to the attached links at or under `path`; null when there were none. */ +function changedLinks( + links: readonly LinkRecord[], + path: string, + change: (record: LinkRecord) => LinkRecord, +): LinkRecord[] | null { + let touched = false; + const next = links.map((record) => { + const within = record.path === path || record.path.startsWith(`${path}/`); + if (record.detached || !within) return record; + touched = true; + return change(record); + }); + return touched ? next : null; +} diff --git a/packages/plugin/src/links/sanitize.ts b/packages/plugin/src/links/sanitize.ts new file mode 100644 index 00000000..f2a67f32 --- /dev/null +++ b/packages/plugin/src/links/sanitize.ts @@ -0,0 +1,155 @@ +import { LINK_HREF_ALLOWED, LINK_IMAGE_SRC_ALLOWED } from "@mdsync/protocol"; + +export interface LocalImage { + img: HTMLImageElement; + link: string; +} + +export interface SanitizeReport { + /** Vault images the caller still reads and inlines; each img's src is already removed. */ + images: LocalImage[]; + /** Embeds of notes, audio, video, pdf, missing files: removed. */ + embedsDropped: number; + imagesDropped: number; + /** Mermaid blocks left as their source because the vault has not allowed Mermaid. */ + mermaidAsSource: number; +} + +export function sanitizeRendered(root: HTMLElement): SanitizeReport { + const report: SanitizeReport = { + images: [], + embedsDropped: 0, + imagesDropped: 0, + mermaidAsSource: 0, + }; + removeFrontmatter(root); + collectEmbeds(root, report); + unguardMermaid(root, report); + removeUnsafeElements(root); + sanitizeAnchors(root); + sanitizeImages(root, report); + unfoldCallouts(root); + removeEmptyParagraphs(root); + sanitizeAttributes(root); + disableCheckboxes(root); + return report; +} + +function removeFrontmatter(root: HTMLElement): void { + for (const frontmatter of root.querySelectorAll("pre.frontmatter")) { + frontmatter.remove(); + } +} + +function collectEmbeds(root: HTMLElement, report: SanitizeReport): void { + for (const embed of root.querySelectorAll(".internal-embed")) { + if (!root.contains(embed)) continue; + const img = embed.querySelector("img"); + if (embed.classList.contains("image-embed") && img) { + const link = embed.getAttribute("src") ?? ""; + report.images.push({ img, link }); + img.removeAttribute("src"); + // Obsidian's default alt is the link as typed, folders of the vault included. + if (isPathAlt(img.getAttribute("alt"), link)) img.removeAttribute("alt"); + embed.replaceWith(img); + } else { + embed.remove(); + report.embedsDropped++; + } + } +} + +function unguardMermaid(root: HTMLElement, report: SanitizeReport): void { + for (const wrapper of root.querySelectorAll(".mermaid-wrapper.is-guarded")) { + const pre = wrapper.querySelector(".mermaid-guard-source pre"); + if (pre) { + wrapper.replaceWith(pre); + report.mermaidAsSource++; + } + } +} + +function removeUnsafeElements(root: HTMLElement): void { + for (const element of root.querySelectorAll( + "audio, video, iframe, object, embed, script, style, button", + )) { + element.remove(); + } +} + +function sanitizeAnchors(root: HTMLElement): void { + for (const anchor of root.querySelectorAll("a")) { + const href = anchor.getAttribute("href") ?? ""; + if (anchor.classList.contains("tag")) { + const tag = root.ownerDocument.createElement("span"); + tag.className = "tag"; + tag.textContent = anchor.textContent; + anchor.replaceWith(tag); + } else if ( + anchor.classList.contains("internal-link") || + !LINK_HREF_ALLOWED.test(href) + ) { + anchor.replaceWith( + root.ownerDocument.createTextNode(anchor.textContent ?? ""), + ); + } else if (href.startsWith("#")) { + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + } + } +} + +function sanitizeImages(root: HTMLElement, report: SanitizeReport): void { + for (const img of root.querySelectorAll("img")) { + const src = img.getAttribute("src"); + // Collected vault images have no src until the caller inlines them. + if (src !== null && !LINK_IMAGE_SRC_ALLOWED.test(src)) { + img.remove(); + report.imagesDropped++; + } + } +} + +function unfoldCallouts(root: HTMLElement): void { + for (const content of root.querySelectorAll(".callout-content")) { + content.removeAttribute("style"); + } +} + +function removeEmptyParagraphs(root: HTMLElement): void { + for (const paragraph of root.querySelectorAll("p")) { + if (paragraph.childElementCount === 0 && !paragraph.textContent?.trim()) { + paragraph.remove(); + } + } +} + +function sanitizeAttributes(root: HTMLElement): void { + stripAttributes(root); + for (const element of root.querySelectorAll("*")) stripAttributes(element); +} + +function stripAttributes(element: Element): void { + for (const attribute of Array.from(element.attributes)) { + if ( + attribute.name === "data-href" || + attribute.name === "contenteditable" || + attribute.name === "draggable" || + attribute.name.toLowerCase().startsWith("on") + ) { + element.removeAttribute(attribute.name); + } + } + element.classList.remove("node-insert-event"); + if (element.getAttribute("class") === "") element.removeAttribute("class"); +} + +function disableCheckboxes(root: HTMLElement): void { + for (const checkbox of root.querySelectorAll('input[type="checkbox"]')) { + checkbox.setAttribute("disabled", ""); + } +} + +function isPathAlt(alt: string | null, link: string): boolean { + return alt !== null && (alt === link || alt === link.split("/").pop()); +} diff --git a/packages/plugin/src/links/shared-links.ts b/packages/plugin/src/links/shared-links.ts new file mode 100644 index 00000000..7b8b6a81 --- /dev/null +++ b/packages/plugin/src/links/shared-links.ts @@ -0,0 +1,98 @@ +import { detachedLinks, type LinkRecord, renamedLinks } from "./record"; + +const MS_PER_S = 1000; +/** setTimeout's ceiling; a later expiry just takes another lap. */ +const MAX_DELAY_MS = 2 ** 31 - 1; + +/** The one owner of this device's share links: each change is saved and told to the subscribers. */ +export class SharedLinks { + private readonly listeners = new Set<() => void>(); + private expiryTimer: number | null = null; + + constructor( + /** Asked each time: a settings import replaces the object. */ + private readonly settings: () => { links: LinkRecord[] }, + private readonly save: () => Promise, + ) {} + + all(): readonly LinkRecord[] { + return this.settings().links; + } + + /** The links the note at `path` feeds; those of a deleted note stay under {@link all}. */ + of(path: string): LinkRecord[] { + return this.all().filter( + (record) => !record.detached && record.path === path, + ); + } + + add(record: LinkRecord): Promise { + return this.write([...this.all(), record]); + } + + remove(id: string): Promise { + return this.write(this.all().filter((record) => record.id !== id)); + } + + published(id: string, at: number): Promise { + return this.write( + this.all().map((record) => + record.id === id ? { ...record, publishedAt: at } : record, + ), + ); + } + + /** A renamed note, or the folder holding it, keeps its links. */ + async move(from: string, to: string): Promise { + const links = renamedLinks(this.all(), from, to); + if (links) await this.write(links); + } + + /** A deleted note no longer feeds its links: a new note at that path must not inherit them. */ + async detach(path: string): Promise { + const links = detachedLinks(this.all(), path); + if (links) await this.write(links); + } + + /** Also told when a link expires: what is shared changes then without a write. */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + this.armExpiry(); + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0) this.disarmExpiry(); + }; + } + + private async write(links: LinkRecord[]): Promise { + this.settings().links = links; + this.notify(); + await this.save(); + } + + private notify(): void { + for (const listener of this.listeners) listener(); + this.armExpiry(); + } + + private armExpiry(): void { + this.disarmExpiry(); + if (this.listeners.size === 0) return; + const now = Date.now(); + const next = Math.min( + ...this.all() + .map(({ expires }) => (expires ?? Number.POSITIVE_INFINITY) * MS_PER_S) + .filter((at) => at > now), + ); + if (!Number.isFinite(next)) return; + this.expiryTimer = window.setTimeout( + () => this.notify(), + Math.min(next - now, MAX_DELAY_MS), + ); + } + + private disarmExpiry(): void { + if (this.expiryTimer !== null) window.clearTimeout(this.expiryTimer); + this.expiryTimer = null; + } +} diff --git a/packages/plugin/src/links/snapshot.ts b/packages/plugin/src/links/snapshot.ts new file mode 100644 index 00000000..1b52f936 --- /dev/null +++ b/packages/plugin/src/links/snapshot.ts @@ -0,0 +1,122 @@ +import { type App, Component, MarkdownRenderer, type TFile } from "obsidian"; + +import { inlineImage } from "./images"; +import { mathToMathML, tagMathSources } from "./math"; +import { sanitizeRendered } from "./sanitize"; + +/** Roughly what a note's own text leaves of the link's size limit. */ +const IMAGES_TOTAL_MAX_CHARS = 4 * 1024 * 1024; +const SETTLE_MAX_MS = 5000; +const SETTLE_STEP_MS = 100; +const STEADY_STEPS = 2; + +export interface Snapshot { + html: string; + /** Milliseconds, just before the note was read. */ + takenAt: number; + /** Whether the vault's images were inlined. */ + images: boolean; + /** False when something was still drawing after the wait; the page may show it unfinished. */ + complete: boolean; + /** What the owner is told was left out of the link. */ + left: { embeds: number; images: number; diagrams: number }; +} + +/** + * The note as Obsidian's reading view draws it, made safe to publish. It renders attached to the page, hidden: + * detached, callout icons stay empty. + */ +export async function takeSnapshot( + app: App, + file: TFile, + markdown: string, + options: { images: boolean }, +): Promise> { + // Before the host exists: a MathJax that fails to load leaves nothing behind. + const untag = await tagMathSources(markdown); + const host = activeDocument.body.createDiv({ cls: "mdsync-link-render" }); + const component = new Component(); + component.load(); + try { + await MarkdownRenderer.render(app, markdown, host, file.path, component); + const complete = await settled(host); + mathToMathML(host); + const report = sanitizeRendered(host); + const images = options.images + ? await inline(app, file.path, report.images) + : dropAll(report.images); + return { + html: host.innerHTML, + images: options.images, + complete, + left: { + embeds: report.embedsDropped, + images: images + report.imagesDropped, + diagrams: report.mermaidAsSource, + }, + }; + } finally { + untag(); + component.unload(); + host.remove(); + } +} + +/** Renders asynchronously: math, embeds and images arrive after `render` resolves. False when time ran out. */ +async function settled(host: HTMLElement): Promise { + const deadline = Date.now() + SETTLE_MAX_MS; + let last = -1; + let steady = 0; + while (Date.now() < deadline) { + const loading = + host.querySelectorAll( + ".math:not(.is-loaded), .internal-embed:not(.is-loaded)", + ).length + + [...host.querySelectorAll("img")].filter((img) => !img.complete).length; + const size = host.innerHTML.length; + // One quiet step can sit between two slow renderers' updates. + steady = loading === 0 && size === last ? steady + 1 : 0; + if (steady >= STEADY_STEPS) return true; + last = size; + await sleep(SETTLE_STEP_MS); + } + return false; +} + +/** Returns how many images could not be inlined. */ +async function inline( + app: App, + sourcePath: string, + images: { img: HTMLImageElement; link: string }[], +): Promise { + let left = 0; + let room = IMAGES_TOTAL_MAX_CHARS; + // An image shown twice is read once; each copy still counts against the room. + const read = new Map>(); + for (const { img, link } of images) { + if (!read.has(link)) { + read.set( + link, + inlineImage(app, sourcePath, link).catch(() => null), + ); + } + const uri = await read.get(link); + if (uri && uri.length <= room) { + img.setAttribute("src", uri); + room -= uri.length; + } else { + img.remove(); + left++; + } + } + return left; +} + +function dropAll(images: { img: HTMLImageElement }[]): number { + for (const { img } of images) img.remove(); + return images.length; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index 620c889b..559c2d3f 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -9,6 +9,7 @@ import { } from "@/core"; import { registerReadOnlyLock } from "@/editor/read-only"; import { registerEditorSigns, type SignsHandle } from "@/editor/signs"; +import { SharedLinks } from "@/links"; import type { Unseen } from "@/presence"; import { canSync, @@ -45,6 +46,7 @@ import { type IgnoreStateHandle, registerIgnoreState, } from "./plugin/ignore-state"; +import { registerLinkPaths } from "./plugin/links"; import { registerProtocolHandlers } from "./plugin/protocols"; import { createRealtime, type Realtime } from "./plugin/realtime"; import { registerShareRenames } from "./plugin/share-moves"; @@ -79,6 +81,10 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { ignoreState!: IgnoreStateHandle; spaces!: SpaceRecords; unseen!: Unseen; + readonly sharedLinks = new SharedLinks( + () => this.settings, + () => this.saveSettings(), + ); private settingsTab?: MdsyncSettingTab; private statePersister!: StatePersister; private scopeRefreshTimer: number | null = null; @@ -157,6 +163,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { registerScheduler(this, this.controller); registerWorkspaceMenus(this); registerShareRenames(this); + registerLinkPaths(this); registerIgnoreFileRefresh(this); registerStatePersistenceFlush(this, this.statePersister); diff --git a/packages/plugin/src/plugin/events.ts b/packages/plugin/src/plugin/events.ts index 9f658df0..924b53a2 100644 --- a/packages/plugin/src/plugin/events.ts +++ b/packages/plugin/src/plugin/events.ts @@ -11,6 +11,7 @@ import { reportWarning } from "@/shared"; import { carryHashes } from "@/sync/session-state"; import { addIgnoreMenuItem, + addLinkMenuItems, addPushMenuItem, addShareMenuItem, openSourceControlDeleted, @@ -27,6 +28,7 @@ export function registerWorkspaceMenus(plugin: Plugin & PluginHost): void { plugin.app.workspace.on("file-menu", (menu, file) => { addIgnoreItem(menu, plugin, file); addPushMenuItem(menu, plugin, file.path, file instanceof TFolder); + addLinkMenuItems(menu, plugin, file); if (file instanceof TFolder && !file.isRoot()) { addShareMenuItem(menu, plugin, file.path); } diff --git a/packages/plugin/src/plugin/host.ts b/packages/plugin/src/plugin/host.ts index ed707670..c323af86 100644 --- a/packages/plugin/src/plugin/host.ts +++ b/packages/plugin/src/plugin/host.ts @@ -1,6 +1,7 @@ import type { App } from "obsidian"; import type { DeviceName, LogService, PassphraseManager } from "@/core"; +import type { SharedLinks } from "@/links"; import type { Unseen } from "@/presence"; import type { MdsyncSettings } from "@/settings/model"; import type { SettingsTransferController } from "@/settings/transfer-controller"; @@ -26,6 +27,7 @@ export interface PluginHost { readonly ignoreState: IgnoreStateHandle; readonly spaces: SpaceRecords; readonly unseen: Unseen; + readonly sharedLinks: SharedLinks; saveSettings(): Promise; scheduleScopeRefresh(reason?: string): void; diff --git a/packages/plugin/src/plugin/links.ts b/packages/plugin/src/plugin/links.ts new file mode 100644 index 00000000..b3fbcc99 --- /dev/null +++ b/packages/plugin/src/plugin/links.ts @@ -0,0 +1,26 @@ +import type { Plugin } from "obsidian"; + +import { reportWarning } from "@/shared"; + +import type { PluginHost } from "./host"; + +/** A renamed note keeps its links; a deleted one lets go of them, so a new note at its path starts clean. */ +export function registerLinkPaths(plugin: Plugin & PluginHost): void { + const { vault } = plugin.app; + plugin.registerEvent( + vault.on("rename", (file, oldPath) => { + plugin.sharedLinks + .move(oldPath, file.path) + .catch((err) => reportWarning("Could not save the moved link.", err)); + }), + ); + plugin.registerEvent( + vault.on("delete", (file) => { + plugin.sharedLinks + .detach(file.path) + .catch((err) => + reportWarning("Could not save the detached link.", err), + ); + }), + ); +} diff --git a/packages/plugin/src/plugin/ui.ts b/packages/plugin/src/plugin/ui.ts index 338c9755..cd4be589 100644 --- a/packages/plugin/src/plugin/ui.ts +++ b/packages/plugin/src/plugin/ui.ts @@ -9,6 +9,7 @@ import { registerFileContextIndicators, registerFileExplorerIndicators, registerLiveStatusBar, + registerNoteLinkActions, registerNotePresence, registerRibbon, registerStatusBar, @@ -39,6 +40,7 @@ export function registerPluginUi( registerLiveStatusBar(plugin); } const openNoteMenu = registerNotePresence(plugin); + registerNoteLinkActions(plugin); if (plugin.settings.showRibbonIcon) { registerRibbon(plugin, controller, plugin.realtime.hub); } diff --git a/packages/plugin/src/settings/model.ts b/packages/plugin/src/settings/model.ts index 521cde16..0a11bb75 100644 --- a/packages/plugin/src/settings/model.ts +++ b/packages/plugin/src/settings/model.ts @@ -6,8 +6,10 @@ import { FILE_HISTORY_MAX_SNAPSHOTS, FILE_HISTORY_MIN_SNAPSHOTS, } from "@/constants"; +import { type LinkRecord, parseLinkRecord } from "@/links/record"; import { isSpaceRecord, type SpaceRecord } from "@/spaces/record"; import { + type BrokerAdmin, CONCURRENCY_FIELD, defaultS3Config, EStorageBackend, @@ -81,6 +83,8 @@ export interface MdsyncSettings { localRoots: Record; /** The vault storage (identity) the records were traded with: another vault's never reach this one. */ spacesVault: string | null; + /** Notes this device shared as links. Holds each link's key, so never published or transferred. */ + links: LinkRecord[]; /** Self-hosted worker (packages/relay): realtime signals. */ relayUrl: string; /** The worker's RELAY_SECRET; relay room tokens derive from it. */ @@ -124,6 +128,7 @@ export const DEFAULT_SETTINGS: MdsyncSettings = { shareEditsWait: true, localRoots: {}, spacesVault: null, + links: [], relayUrl: "", relaySecret: "", cachePassphrase: true, @@ -173,6 +178,14 @@ export function isRelayConfigured(relay: RelayConfig): boolean { return Boolean(relay.relayUrl && relay.relaySecret); } +/** The owner's side of the relay: shares and links both speak to it with the secret. */ +export function relayAdmin({ + relayUrl, + relaySecret, +}: RelayConfig): BrokerAdmin { + return { relayUrl, secret: relaySecret }; +} + /** Dropped settings. Several held secrets - share keys among them - so they are deleted, not kept. */ const RETIRED_KEYS = [ "realtimeServerUrl", @@ -275,6 +288,9 @@ export function mergeSettings( ? stored.pausedSpaces.filter((id) => typeof id === "string") : [], localRoots: stringValues(stored?.localRoots), + links: Array.isArray(stored?.links) + ? stored.links.flatMap((link) => parseLinkRecord(link) ?? []) + : [], spacesVault: typeof stored?.spacesVault === "string" ? stored.spacesVault : null, }; diff --git a/packages/plugin/src/settings/sections/exclusions.ts b/packages/plugin/src/settings/sections/exclusions.ts new file mode 100644 index 00000000..cb510029 --- /dev/null +++ b/packages/plugin/src/settings/sections/exclusions.ts @@ -0,0 +1,107 @@ +import { type App, debounce, Setting, TFile } from "obsidian"; + +import { IGNORE_FILE_NAME } from "@/constants"; +import { + type FieldContext, + renderFields, + type SettingsField, + TYPING_SETTLE_MS, +} from "@/settings/fields"; +import { NUMERIC_BOUNDS } from "@/settings/model"; +import { EFieldKind } from "@/storage"; +import { notifyError, notifyInfo, openInEditor, reportError } from "@/ui"; + +const SCOPE_CHANGED = "Sync scope settings changed."; +const BYTES_PER_MB = 1024 * 1024; +const MIN_MAX_FILE_MB = 1; + +const EXCLUSION_FIELDS: ReadonlyArray = [ + { + kind: EFieldKind.Toggle, + name: "Ignore symlinks", + desc: "Skip symbolic links, Windows junctions and directory links on this device. They can point outside the vault.", + get: (s) => s.ignoreSymlinks, + set: (v) => ({ ignoreSymlinks: v }), + refreshScope: true, + }, + { + kind: EFieldKind.Number, + name: "Max file size (MB)", + desc: "Skip larger files on this device.", + get: (s) => String(Math.round(s.maxFileBytes / BYTES_PER_MB)), + parse: (raw) => + Math.min( + NUMERIC_BOUNDS.maxFileBytes.max / BYTES_PER_MB, + Math.max(MIN_MAX_FILE_MB, Number.parseInt(raw, 10)), + ), + set: (mb) => ({ maxFileBytes: mb * BYTES_PER_MB }), + refreshScope: true, + }, +]; + +export function renderExclusionsSection( + parent: HTMLElement, + ctx: FieldContext, +): void { + const { plugin } = ctx; + new Setting(parent).setName("Exclusions").setHeading(); + new Setting(parent) + .setName("Shared vault rules") + .setDesc( + `Stored in ${IGNORE_FILE_NAME} at the vault root and synced to your other devices. Shared folders use their own ${IGNORE_FILE_NAME}.`, + ) + .addButton((button) => + button + .setButtonText(`Open ${IGNORE_FILE_NAME}`) + .onClick(() => void openSharedIgnore(plugin.app).catch(reportError)), + ); + + const savePatterns = debounce( + () => { + void plugin + .saveSettings() + .then(() => plugin.ignoreState.refresh()) + .then(() => plugin.scheduleScopeRefresh(SCOPE_CHANGED)) + .catch(reportError); + }, + TYPING_SETTLE_MS, + true, + ); + const patterns = new Setting(parent) + .setName("Rules for this device only") + .setDesc( + `Added to the shared rules; does not edit ${IGNORE_FILE_NAME}. One gitignore-style pattern per line.`, + ) + .setClass("mdsync-ignore-patterns"); + patterns.nameEl.id = "mdsync-local-ignore-label"; + patterns.descEl.id = "mdsync-local-ignore-description"; + patterns.addTextArea((text) => { + text.inputEl.rows = 6; + text.inputEl.spellcheck = false; + text.inputEl.setAttribute("aria-labelledby", patterns.nameEl.id); + text.inputEl.setAttribute("aria-describedby", patterns.descEl.id); + text + .setPlaceholder("Attachments/\n*.tmp") + .setValue(plugin.settings.ignorePatterns) + .onChange((value) => { + plugin.settings.ignorePatterns = value; + savePatterns(); + }); + }); + renderFields(parent, ctx, EXCLUSION_FIELDS); +} + +async function openSharedIgnore(app: App): Promise { + const existing = app.vault.getAbstractFileByPath(IGNORE_FILE_NAME); + if (existing && !(existing instanceof TFile)) { + notifyError(`${IGNORE_FILE_NAME} already exists and is not a file.`); + return; + } + if (!existing) { + await app.vault.create(IGNORE_FILE_NAME, ""); + notifyInfo(`${IGNORE_FILE_NAME} created.`); + } + await openInEditor(app, IGNORE_FILE_NAME); + const { setting } = app as App & { setting?: { close(): void } }; + setting?.close(); +} diff --git a/packages/plugin/src/settings/sections/index.ts b/packages/plugin/src/settings/sections/index.ts index be7521ab..ba3f4111 100644 --- a/packages/plugin/src/settings/sections/index.ts +++ b/packages/plugin/src/settings/sections/index.ts @@ -1,5 +1,7 @@ export { renderAutomationSection } from "./automation"; export { renderBackendSection } from "./backend"; +export { renderExclusionsSection } from "./exclusions"; +export { renderLinksSection } from "./links"; export { renderMaintenanceSection } from "./maintenance"; export { renderRelaySection } from "./relay"; export { renderSecuritySection } from "./security"; diff --git a/packages/plugin/src/settings/sections/links.ts b/packages/plugin/src/settings/sections/links.ts new file mode 100644 index 00000000..124f43a2 --- /dev/null +++ b/packages/plugin/src/settings/sections/links.ts @@ -0,0 +1,20 @@ +import { Setting } from "obsidian"; + +import type { PluginHost } from "@/plugin/host"; +import { openManageLinks } from "@/ui"; + +export function renderLinksSection( + parent: HTMLElement, + plugin: PluginHost, +): void { + new Setting(parent).setName("Share links").setHeading(); + const count = plugin.sharedLinks.all().length; + new Setting(parent) + .setName("Notes shared by link") + .setDesc( + `${count === 0 ? "None yet. " : `${count} on this device. `}A link publishes one note, encrypted on this device, to your relay. Create one from the note's menu: MDSync: Share link.`, + ) + .addButton((button) => + button.setButtonText("Manage").onClick(() => openManageLinks(plugin)), + ); +} diff --git a/packages/plugin/src/settings/sections/security.ts b/packages/plugin/src/settings/sections/security.ts index ff8d5e65..384265eb 100644 --- a/packages/plugin/src/settings/sections/security.ts +++ b/packages/plugin/src/settings/sections/security.ts @@ -1,6 +1,8 @@ import { Setting } from "obsidian"; + import { clearCachedPassphrase } from "@/crypto/passphrase-cache"; import type { PluginHost } from "@/plugin/host"; +import { isStorageConfigured } from "@/settings/model"; import { PassphraseRotatedError } from "@/sync/keyfile"; import { askNewPassphrase, notifyError, notifyInfo, reportError } from "@/ui"; @@ -12,21 +14,19 @@ export function renderSecuritySection( new Setting(parent).setName("Encryption").setHeading(); const status = plugin.passphrase.has() - ? "Passphrase is loaded for this session." - : "Passphrase is not set. You will be prompted before the next sync."; + ? "Loaded for this session." + : "Not loaded for this session. Sync will use a saved passphrase or ask you for one."; new Setting(parent) .setName("Cache passphrase between launches") .setDesc( - "Stores the passphrase encrypted with a per-device key inside the plugin folder. " + - "Disable for stricter security on shared devices.", + "Saves a verified passphrase on this device. Its encryption key is stored in the same plugin folder; disable on shared devices.", ) .addToggle((t) => t.setValue(plugin.settings.cachePassphrase).onChange(async (v) => { Object.assign(plugin.settings, { cachePassphrase: v }); await plugin.saveSettings(); if (v) { - // Turning on with a loaded passphrase caches it immediately. await plugin.passphrase.persistIfEnabled(); return; } @@ -38,48 +38,56 @@ export function renderSecuritySection( ); new Setting(parent) - .setName("Passphrase") - .setDesc(status) + .setName("Passphrase on this device") + .setDesc( + `${status} Enter does not change the vault passphrase. Forget clears it from this device, including the saved copy.`, + ) .addButton((b) => - b - .setButtonText(plugin.passphrase.has() ? "Replace" : "Set") - .onClick(async () => { - await plugin.passphrase.prompt(true); - onDisplay(); - }), + b.setButtonText("Enter…").onClick(async () => { + if (await plugin.passphrase.prompt(true)) onDisplay(); + }), ) .addButton((b) => b .setButtonText("Forget") .setDestructive() - .setDisabled(!plugin.passphrase.has()) .onClick(async () => { await plugin.passphrase.forget(); - notifyInfo("Passphrase forgotten."); + notifyInfo("Passphrase forgotten on this device."); onDisplay(); }), ); new Setting(parent) - .setName("Rotate passphrase") + .setName("Change vault passphrase") .setDesc( - "Switch to a new passphrase. Re-wraps the data key only. Notes are not re-encrypted, so it is instant. All other devices must enter the new passphrase afterwards.", + "Set a new passphrase for this vault. Notes do not need to be uploaded again. Your other devices must enter the new passphrase.", ) - .addButton((b) => - b.setButtonText("Change…").onClick(async () => { - const next = await askNewPassphrase(plugin.app); - if (!next) return; + .addButton((button) => + button.setButtonText("Change…").onClick(async () => { + if (!isStorageConfigured(plugin.settings)) { + notifyInfo("Configure a storage backend first."); + return; + } + button.setDisabled(true); try { + if (!(await plugin.passphrase.prompt(false))) return; + const next = await askNewPassphrase(plugin.app); + if (!next) return; const epoch = await plugin.passphrase.rotate(next); if (epoch === null) return; - notifyInfo(`Passphrase changed (key epoch ${epoch}).`); - onDisplay(); + notifyInfo( + "Vault passphrase changed. Enter it on your other devices.", + ); } catch (err) { if (err instanceof PassphraseRotatedError) { notifyError("Current passphrase is incorrect."); return; } reportError(err); + } finally { + button.setDisabled(false); + onDisplay(); } }), ); diff --git a/packages/plugin/src/settings/sections/shares.ts b/packages/plugin/src/settings/sections/shares.ts index 72d153fe..ab9cf57e 100644 --- a/packages/plugin/src/settings/sections/shares.ts +++ b/packages/plugin/src/settings/sections/shares.ts @@ -52,15 +52,16 @@ export function renderSharesSection( set: (v) => ({ pushSharesRightAway: v }), rerender: true, }); - renderField(parent, ctx, { - kind: EFieldKind.Toggle, - name: "Push only added, moved and deleted files", - desc: "Edits wait for your push, so their gutter marks show what changed. Open notes still update live.", - when: (s) => s.pushSharesRightAway, - sub: true, - get: (s) => s.shareEditsWait, - set: (v) => ({ shareEditsWait: v }), - }); + if (plugin.settings.pushSharesRightAway) { + renderField(parent, ctx, { + kind: EFieldKind.Toggle, + name: "Push only added, moved and deleted files", + desc: "Edits wait for your push, so their gutter marks show what changed. Open notes still update live.", + sub: true, + get: (s) => s.shareEditsWait, + set: (v) => ({ shareEditsWait: v }), + }); + } renderField(parent, ctx, { kind: EFieldKind.Toggle, name: "Pause shared folders added on other devices", diff --git a/packages/plugin/src/settings/tab.ts b/packages/plugin/src/settings/tab.ts index f658a502..db6f0704 100644 --- a/packages/plugin/src/settings/tab.ts +++ b/packages/plugin/src/settings/tab.ts @@ -4,10 +4,8 @@ import { type Plugin, PluginSettingTab, Setting, - TFile, } from "obsidian"; -import { IGNORE_FILE_NAME } from "@/constants"; import type { PluginHost } from "@/plugin/host"; import { EFieldKind } from "@/storage"; import { defaultDeviceName } from "@/sync/device"; @@ -17,7 +15,6 @@ import { notifyError, notifyInfo, openConfirmModal, - openInEditor, reportError, showSettingsTransferExport, } from "@/ui"; @@ -30,14 +27,12 @@ import { TYPING_SETTLE_MS, } from "./fields"; import { renderLogsView } from "./logs-view"; -import { - type MdsyncSettings, - NUMERIC_BOUNDS, - type SettingsSyncCategories, -} from "./model"; +import type { MdsyncSettings, SettingsSyncCategories } from "./model"; import { renderAutomationSection, renderBackendSection, + renderExclusionsSection, + renderLinksSection, renderMaintenanceSection, renderRelaySection, renderSecuritySection, @@ -116,34 +111,6 @@ function countRemoteCategories( return counts; } -const SCOPE_CHANGED = "Sync scope settings changed."; -const BYTES_PER_MB = 1024 * 1024; -const MIN_MAX_FILE_MB = 1; - -const EXCLUSION_FIELDS: ReadonlyArray = [ - { - kind: EFieldKind.Toggle, - name: "Ignore symlinks", - desc: "Skip symbolic links, Windows junctions and directory links. They point outside the vault and exist only on this device.", - get: (s) => s.ignoreSymlinks, - set: (v) => ({ ignoreSymlinks: v }), - refreshScope: true, - }, - { - kind: EFieldKind.Number, - name: "Max file size (MB)", - desc: "Files larger than this are skipped.", - get: (s) => String(Math.round(s.maxFileBytes / BYTES_PER_MB)), - parse: (raw) => - Math.min( - NUMERIC_BOUNDS.maxFileBytes.max / BYTES_PER_MB, - Math.max(MIN_MAX_FILE_MB, Number.parseInt(raw, 10)), - ), - set: (mb) => ({ maxFileBytes: mb * BYTES_PER_MB }), - refreshScope: true, - }, -]; - const INTERFACE_FIELDS: ReadonlyArray = [ { kind: EFieldKind.Toggle, @@ -205,6 +172,8 @@ export class MdsyncSettingTab extends PluginSettingTab { display(): void { this.unsubscribeSections(); const { containerEl } = this; + const scrollTop = containerEl.scrollTop; + containerEl.addClass("mdsync-settings"); containerEl.empty(); this.renderTabBar(containerEl); @@ -225,6 +194,7 @@ export class MdsyncSettingTab extends PluginSettingTab { renderLogsView(containerEl, this.plugin, () => this.display()); break; } + containerEl.scrollTop = scrollTop; } private fieldContext(): FieldContext { @@ -259,6 +229,7 @@ export class MdsyncSettingTab extends PluginSettingTab { button.addEventListener("click", () => { if (tab === this.activeTab) return; this.activeTab = tab; + this.containerEl.scrollTop = 0; this.display(); }); } @@ -277,8 +248,9 @@ export class MdsyncSettingTab extends PluginSettingTab { ); if (automationUnsub) this.sectionUnsubs.push(automationUnsub); renderSharesSection(parent, this.fieldContext()); + renderLinksSection(parent, this.plugin); this.renderSettingsSyncSection(parent); - this.renderIgnoreSection(parent); + renderExclusionsSection(parent, this.fieldContext()); } private renderTransferSection(parent: HTMLElement): void { @@ -381,35 +353,6 @@ export class MdsyncSettingTab extends PluginSettingTab { this.display(); } - private renderIgnoreSection(parent: HTMLElement): void { - new Setting(parent).setName("Device-local exclusions").setHeading(); - new Setting(parent).setDesc( - "Applied only on this device, in addition to the shared syncignore.md note in the vault root.", - ); - - renderFields(parent, this.fieldContext(), EXCLUSION_FIELDS); - - new Setting(parent) - .setName("Patterns") - .setDesc("Gitignore-style, one per line.") - .addTextArea((t) => { - t.inputEl.rows = 6; - t.inputEl.cols = 40; - t.setValue(this.plugin.settings.ignorePatterns).onChange((v) => { - this.plugin.settings.ignorePatterns = v; - void this.plugin - .saveSettings() - .then(() => this.plugin.ignoreState.refresh()) - .then(() => this.plugin.scheduleScopeRefresh(SCOPE_CHANGED)); - }); - }) - .addButton((button) => - button - .setButtonText("Open syncignore.md") - .onClick(() => void this.handleOpenSharedIgnore()), - ); - } - private renderUiSection(parent: HTMLElement): void { new Setting(parent).setName("Interface").setHeading(); renderFields(parent, this.fieldContext(), INTERFACE_FIELDS); @@ -455,27 +398,4 @@ export class MdsyncSettingTab extends PluginSettingTab { reportError(err); } } - - private async handleOpenSharedIgnore(): Promise { - try { - await this.openSharedIgnore(); - } catch (err) { - reportError(err); - } - } - - private async openSharedIgnore(): Promise { - const existing = this.app.vault.getAbstractFileByPath(IGNORE_FILE_NAME); - if (existing instanceof TFile) { - await openInEditor(this.app, IGNORE_FILE_NAME); - return; - } - if (existing) { - notifyError(`${IGNORE_FILE_NAME} already exists and is not a file.`); - return; - } - await this.app.vault.create(IGNORE_FILE_NAME, ""); - notifyInfo(`${IGNORE_FILE_NAME} created.`); - await openInEditor(this.app, IGNORE_FILE_NAME); - } } diff --git a/packages/plugin/src/settings/transfer-controller.ts b/packages/plugin/src/settings/transfer-controller.ts index 3c3b9840..8f0727db 100644 --- a/packages/plugin/src/settings/transfer-controller.ts +++ b/packages/plugin/src/settings/transfer-controller.ts @@ -48,7 +48,11 @@ export class SettingsTransferController { importedSections(imported), ); if (!confirmed) return false; - await this.apply(merged, passphrase); + // Merged again: a link published while asking must not be lost. + await this.apply( + mergeTransferredSettings(this.deps.settings, imported), + passphrase, + ); return true; } diff --git a/packages/plugin/src/storage/adapters/link-broker.ts b/packages/plugin/src/storage/adapters/link-broker.ts new file mode 100644 index 00000000..22eb3ac9 --- /dev/null +++ b/packages/plugin/src/storage/adapters/link-broker.ts @@ -0,0 +1,134 @@ +import { LINK_HEADERS, type LinkStatus } from "@mdsync/protocol"; +import { requestUrl } from "obsidian"; + +import { relayBase } from "@/shared"; +import { StorageRequestError } from "@/storage/types"; + +import { ADMIN_HEADER, type BrokerAdmin } from "./share-broker"; +import { errorCode, StorageHttpError } from "./util"; + +const OUTDATED_RELAY = + "This relay is out of date for share links. Redeploy it with the Deploy Relay workflow."; + +export interface NewLink { + maxViews: number | null; + /** Seconds the relay counts on its own clock, so a wrong device clock cannot misdate it; null never expires. */ + ttl: number | null; + gate: string; + /** base64url; set exactly when a passphrase protects the link. */ + salt: string | null; +} + +/** Unix seconds the relay will end the link at; null never. */ +export async function createLink( + admin: BrokerAdmin, + id: string, + sealed: Uint8Array, + options: NewLink, +): Promise { + const query = new URLSearchParams(); + if (options.maxViews !== null) + query.set("maxViews", String(options.maxViews)); + if (options.ttl !== null) query.set("ttl", String(options.ttl)); + const stored = (await putSealed( + admin, + `/link/${id}${query.size ? `?${query}` : ""}`, + sealed, + options, + )) as { expires?: number | null }; + if (stored.expires === undefined) { + // An older relay ignores `ttl` and would keep the link for ever. + await revokeLink(admin, id).catch(() => undefined); + throw new StorageRequestError( + "Link broker answered a creation without an expiry", + OUTDATED_RELAY, + ); + } + return stored.expires; +} + +/** Replaces a standing link's note and keeps its limits and view counter; the gate must be the link's own. */ +export async function replaceLink( + admin: BrokerAdmin, + id: string, + sealed: Uint8Array, + gate: string, +): Promise { + await putSealed(admin, `/link/${id}?update=1`, sealed, { gate, salt: null }); +} + +function putSealed( + admin: BrokerAdmin, + path: string, + sealed: Uint8Array, + protection: { gate: string; salt: string | null }, +): Promise { + const headers: Record = { + "Content-Type": "application/octet-stream", + [LINK_HEADERS.gate]: protection.gate, + }; + if (protection.salt) headers[LINK_HEADERS.salt] = protection.salt; + return callLink(admin, path, { + method: "PUT", + headers, + body: sealed.slice().buffer, + }); +} + +/** Null when the link is gone (expired, spent, revoked). */ +export async function linkStatus( + admin: BrokerAdmin, + id: string, +): Promise { + return (await callLink(admin, `/link/${id}/status`, { + method: "GET", + })) as LinkStatus | null; +} + +export async function revokeLink( + admin: BrokerAdmin, + id: string, +): Promise { + await callLink(admin, `/link/${id}`, { method: "DELETE" }); +} + +async function callLink( + admin: BrokerAdmin, + path: string, + request: { + method: "PUT" | "GET" | "DELETE"; + headers?: Record; + body?: ArrayBuffer; + }, +): Promise { + const res = await requestUrl({ + url: `${relayBase(admin.relayUrl)}${path}`, + method: request.method, + headers: { [ADMIN_HEADER]: admin.secret, ...request.headers }, + body: request.body, + throw: false, + }); + if (res.status >= 200 && res.status < 300) return res.json; + const code = errorCode(res.text); + const message = `Link broker ${path} answered HTTP ${res.status}${code ? ` (${code})` : ""}`; + let refusal: string | undefined; + if (res.status === 404) { + if (code !== "gone") { + refusal = OUTDATED_RELAY; + } else if (request.method === "GET") { + return null; + } else if (request.method === "PUT") { + refusal = "This link has ended. Create a new one."; + } + } else if (res.status === 401 && code === "unauthorized") { + refusal = "The relay did not accept its secret. Check the relay settings."; + } else if (res.status === 403 && code === "gate") { + refusal = "That is not this link's passphrase."; + } else if (res.status === 413 && code === "too_large") { + refusal = "This note is too large to share as a link."; + } else if (res.status === 400 && code === "bad_request") { + refusal = "The relay refused this link's settings."; + } + if (refusal) throw new StorageRequestError(message, refusal); + throw new StorageHttpError(res.status, message); +} diff --git a/packages/plugin/src/storage/adapters/share-broker.ts b/packages/plugin/src/storage/adapters/share-broker.ts index 3213f26e..6f946886 100644 --- a/packages/plugin/src/storage/adapters/share-broker.ts +++ b/packages/plugin/src/storage/adapters/share-broker.ts @@ -22,7 +22,7 @@ import { } from "./s3-signer"; import { createS3Store } from "./s3-store"; import { ShareSignedUrls } from "./share-signed-urls"; -import { StorageHttpError } from "./util"; +import { errorCode, StorageHttpError } from "./util"; export interface BrokerAccess { relayUrl: string; @@ -41,7 +41,7 @@ const SIGN_OPS: Record = { DELETE: "delete", HEAD: "head", }; -const ADMIN_HEADER = "X-Mdsync-Admin"; +export const ADMIN_HEADER = "X-Mdsync-Admin"; /** Refusals that say something about the share, not the network: never retried. */ const PARTICIPANT_REFUSALS: Record = { unauthorized: "This shared folder's invite is no longer valid.", @@ -288,13 +288,3 @@ async function callBroker( if (refusal) throw new StorageRequestError(message, refusal); throw new StorageHttpError(res.status, message); } - -function errorCode(text: string): string | null { - try { - const parsed: unknown = JSON.parse(text); - const code = (parsed as { error?: unknown } | null)?.error; - return typeof code === "string" ? code : null; - } catch { - return null; - } -} diff --git a/packages/plugin/src/storage/adapters/util.ts b/packages/plugin/src/storage/adapters/util.ts index 0d780c25..36493436 100644 --- a/packages/plugin/src/storage/adapters/util.ts +++ b/packages/plugin/src/storage/adapters/util.ts @@ -94,6 +94,17 @@ export function assertOk( ); } +/** The `error` code of a relay's JSON refusal; null for anything else. */ +export function errorCode(text: string): string | null { + try { + const parsed: unknown = JSON.parse(text); + const code = (parsed as { error?: unknown } | null)?.error; + return typeof code === "string" ? code : null; + } catch { + return null; + } +} + /** Header lookup that does not assume the platform's casing. */ export function headerValue( headers: Record | undefined, diff --git a/packages/plugin/src/storage/index.ts b/packages/plugin/src/storage/index.ts index 9c2b5650..db6e965b 100644 --- a/packages/plugin/src/storage/index.ts +++ b/packages/plugin/src/storage/index.ts @@ -2,6 +2,12 @@ export { defaultGoogleDriveConfig, googleLoginUrl, } from "./adapters/google-drive-auth"; +export { + createLink, + linkStatus, + replaceLink, + revokeLink, +} from "./adapters/link-broker"; export { defaultS3Config } from "./adapters/s3"; export { type BrokerAccess, diff --git a/packages/plugin/src/styles/explorer.css b/packages/plugin/src/styles/explorer.css index 72a166ed..8fe8a41c 100644 --- a/packages/plugin/src/styles/explorer.css +++ b/packages/plugin/src/styles/explorer.css @@ -115,15 +115,34 @@ } /* Out of a line box: an inline icon sits on the text baseline, above centre. */ -.mdsync-share-icon { +.mdsync-share-icon, +.mdsync-published-icon { display: flex; } -.mdsync-share-badge { +.mdsync-published-icon { + position: relative; +} + +/* The badge's ::after is reserved for the mobile touch target. */ +.mdsync-published-badge.is-stale .mdsync-published-icon::after { + content: ""; + position: absolute; + top: 0; + inset-inline-end: 0; + width: var(--size-2-2); + height: var(--size-2-2); + border-radius: 50%; + background: var(--interactive-accent); +} + +.mdsync-share-badge, +.mdsync-published-badge { cursor: pointer; } -.mdsync-share-badge:focus-visible { +.mdsync-share-badge:focus-visible, +.mdsync-published-badge:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 1px; border-radius: var(--radius-s); diff --git a/packages/plugin/src/styles/index.css b/packages/plugin/src/styles/index.css index 233ab387..474cc602 100644 --- a/packages/plugin/src/styles/index.css +++ b/packages/plugin/src/styles/index.css @@ -33,6 +33,7 @@ @import "./ribbon.css"; @import "./modals.css"; @import "./shares.css"; +@import "./links.css"; @import "./source-control/history.css"; @import "./editor/signs.css"; diff --git a/packages/plugin/src/styles/links.css b/packages/plugin/src/styles/links.css new file mode 100644 index 00000000..d2340a62 --- /dev/null +++ b/packages/plugin/src/styles/links.css @@ -0,0 +1,48 @@ +/* Off-screen but attached: a detached note renders its callout icons empty. */ +.mdsync-link-render { + position: fixed; + inset-block-start: 0; + inset-inline-start: -10000px; + width: 800px; + visibility: hidden; + pointer-events: none; +} + +.mdsync-published-action.is-stale { + position: relative; +} + +.mdsync-published-action.is-stale::after { + content: ""; + position: absolute; + inset-block-start: var(--size-2-1); + inset-inline-end: var(--size-2-1); + width: var(--size-4-1); + height: var(--size-4-1); + border-radius: var(--radius-round); + background-color: var(--interactive-accent); +} + +.mdsync-link-preview summary { + cursor: pointer; +} + +.mdsync-link-preview iframe { + width: 100%; + height: 16rem; + margin-top: var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); +} + +.mdsync-link-modal input[readonly] { + text-overflow: ellipsis; +} + +.mdsync-link-modal .setting-item-description { + overflow-wrap: anywhere; +} + +.is-phone .mdsync-link-modal .setting-item-control button:not(.clickable-icon) { + width: auto; +} diff --git a/packages/plugin/src/styles/settings/tabs-and-logs.css b/packages/plugin/src/styles/settings/tabs-and-logs.css index 44663b4d..09ccf42a 100644 --- a/packages/plugin/src/styles/settings/tabs-and-logs.css +++ b/packages/plugin/src/styles/settings/tabs-and-logs.css @@ -73,3 +73,41 @@ body.is-phone .mdsync-settings-nav .mdsync-settings-tab-button { .mdsync-settings-error { color: var(--text-error); } + +.mdsync-settings .mdsync-ignore-patterns { + flex-direction: column; + align-items: stretch; + gap: var(--size-4-2); +} + +.mdsync-ignore-patterns .setting-item-info { + margin-inline-end: 0; +} + +.mdsync-ignore-patterns .setting-item-control { + width: 100%; +} + +.mdsync-ignore-patterns textarea { + width: 100%; + min-width: 0; + max-width: 100%; + resize: vertical; + font-family: var(--font-monospace); +} + +body.is-phone .mdsync-settings button { + min-height: var(--mdsync-touch); +} + +@media (max-width: 600px) { + .mdsync-settings .setting-item-control { + min-width: 0; + flex-wrap: wrap; + } + + .mdsync-settings .setting-item-control > :is(input, select) { + min-width: 0; + max-width: 100%; + } +} diff --git a/packages/plugin/src/ui/common/copyable.ts b/packages/plugin/src/ui/common/copyable.ts new file mode 100644 index 00000000..c86a30b2 --- /dev/null +++ b/packages/plugin/src/ui/common/copyable.ts @@ -0,0 +1,27 @@ +import { Setting } from "obsidian"; + +import { runWithNotice } from "./notices"; + +/** A read-only field with a copy button. */ +export function copyable(el: HTMLElement, name: string, value: string): void { + new Setting(el) + .setName(name) + .addText((text) => { + text.setValue(value); + text.inputEl.readOnly = true; + }) + .addExtraButton((button) => + button + .setIcon("copy") + .setTooltip(`Copy ${name.toLowerCase()}`) + .onClick(() => void copyText(name, value)), + ); +} + +export function copyText(name: string, value: string): Promise { + return runWithNotice( + () => navigator.clipboard.writeText(value), + `${name} copied.`, + `Could not copy the ${name.toLowerCase()}`, + ); +} diff --git a/packages/plugin/src/ui/common/index.ts b/packages/plugin/src/ui/common/index.ts index b90389b4..c669b893 100644 --- a/packages/plugin/src/ui/common/index.ts +++ b/packages/plugin/src/ui/common/index.ts @@ -2,6 +2,7 @@ export { makeActivatable } from "./activatable"; export { alertLine } from "./alert-line"; export { describePeople, renderAvatar, renderAvatarStack } from "./avatars"; export { type ChangeAction, changeActionOf } from "./change-action"; +export { copyable, copyText } from "./copyable"; export { onEnter } from "./enter-key"; export { focusKey, renderKeepingFocus } from "./focus"; export { appendIconButton, appendLabeledButton } from "./icon-button"; diff --git a/packages/plugin/src/ui/explorer/file-explorer-badges.ts b/packages/plugin/src/ui/explorer/file-explorer-badges.ts index 0aafa916..98c20203 100644 --- a/packages/plugin/src/ui/explorer/file-explorer-badges.ts +++ b/packages/plugin/src/ui/explorer/file-explorer-badges.ts @@ -2,9 +2,12 @@ import { Notice, Platform, setIcon } from "obsidian"; import type { PluginHost } from "@/plugin/host"; import { describePeople, lastEditLabel, renderAvatarStack } from "@/ui/common"; +import { openManageLinks } from "@/ui/links"; import { openShareWindow } from "@/ui/shares/share-window"; + import type { PresenceMarks, + PublishedMark, ShareKind, ShareMark, UnseenMark, @@ -24,6 +27,7 @@ const SHARE_TOOLTIPS: Record = { }; const SHARE_ROOT_ATTR = "data-share-root"; const SHARE_BADGE = ".mdsync-share-badge"; +const PUBLISHED_BADGE = ".mdsync-published-badge"; const TAP_BADGES = ".mdsync-people-badge, .mdsync-unseen-dot, .mdsync-skip-badge"; const TIP_MS = 4000; @@ -51,12 +55,10 @@ export function renderPresenceMarks( renderAvatarStack(badge, marks.people); } if (marks.share) renderShareBadge(target, marks.share); + if (marks.published) renderPublishedBadge(target, marks.published); } -/** - * A share badge opens the share's window instead of folding the folder; a phone has no hover, so a tap - * shows the tooltip. - */ +/** Action badges open management; other badges show a tooltip on phones. */ export function badgeActivation( plugin: PluginHost, ): (event: MouseEvent | KeyboardEvent) => void { @@ -65,11 +67,12 @@ export function badgeActivation( if ("key" in event && event.key !== "Enter" && event.key !== " ") return; if (!(event.target instanceof Element)) return; const share = event.target.closest(SHARE_BADGE); + const published = event.target.closest(PUBLISHED_BADGE); const mark = - !share && event.type === "click" && Platform.isMobile + !share && !published && event.type === "click" && Platform.isMobile ? event.target.closest(TAP_BADGES) : null; - if (!share && !mark) return; + if (!share && !published && !mark) return; event.preventDefault(); event.stopPropagation(); if (mark) { @@ -77,6 +80,10 @@ export function badgeActivation( tip = new Notice(mark.getAttribute("aria-label") ?? "", TIP_MS); return; } + if (published) { + openManageLinks(plugin, published.getAttribute("data-path") ?? ""); + return; + } const record = plugin.spaces.shareAt( share?.getAttribute(SHARE_ROOT_ATTR) ?? "", ); @@ -92,6 +99,23 @@ function unseenTooltip( return `${count} changed by others since you opened them`; } +function renderPublishedBadge( + target: HTMLElement, + published: PublishedMark, +): void { + const badge = target.createSpan({ + cls: "mdsync-path-badge mdsync-published-badge", + attr: { + "data-path": published.path, + role: "button", + tabindex: "0", + "aria-label": published.text, + }, + }); + if (published.stale) badge.addClass("is-stale"); + setIcon(badge.createSpan({ cls: "mdsync-published-icon" }), "globe"); +} + function renderShareBadge(target: HTMLElement, share: ShareMark): void { const lines = ["Manage sharing", SHARE_TOOLTIPS[share.kind]]; if (share.here > 0) lines.push(`${share.here} in its notes now`); diff --git a/packages/plugin/src/ui/explorer/file-explorer-decorations.ts b/packages/plugin/src/ui/explorer/file-explorer-decorations.ts index b5151fb1..9b13a94e 100644 --- a/packages/plugin/src/ui/explorer/file-explorer-decorations.ts +++ b/packages/plugin/src/ui/explorer/file-explorer-decorations.ts @@ -1,4 +1,5 @@ import { setIcon } from "obsidian"; + import type { PluginHost } from "@/plugin/host"; import type { SyncController } from "@/sync/controller"; import type { EChangeType } from "@/sync/types"; @@ -8,6 +9,7 @@ import { skippedFiles, skippedText, } from "@/ui/common"; + import { renderPresenceMarks } from "./file-explorer-badges"; import type { PresenceMarks } from "./file-explorer-marks"; @@ -86,6 +88,7 @@ export function sameDecoration( left.skipped === right.skipped && sameFlat(left.unseen, right.unseen) && sameFlat(left.share, right.share) && + sameFlat(left.published, right.published) && sameList(left.people, right.people)) ); } @@ -103,6 +106,7 @@ export function renderDecoration( decoration.linkRoot || decoration.people?.length || decoration.share || + decoration.published || decoration.unseen ) { target.addClass("mdsync-has-path-badge"); diff --git a/packages/plugin/src/ui/explorer/file-explorer-indicators.ts b/packages/plugin/src/ui/explorer/file-explorer-indicators.ts index 8bdfc3f5..f5ae56c2 100644 --- a/packages/plugin/src/ui/explorer/file-explorer-indicators.ts +++ b/packages/plugin/src/ui/explorer/file-explorer-indicators.ts @@ -1,6 +1,8 @@ import type { Plugin, TAbstractFile } from "obsidian"; + import type { PluginHost } from "@/plugin/host"; import type { SyncController } from "@/sync/controller"; + import { readFileExplorer, readFileExplorerContainer, @@ -82,6 +84,7 @@ export function registerFileExplorerIndicators( ); plugin.register(plugin.realtime.people.subscribe(() => schedule())); plugin.register(plugin.unseen.subscribe(() => schedule())); + plugin.register(plugin.sharedLinks.subscribe(() => schedule())); // Capturing: the explorer would fold the folder before a bubbling handler ran. const onBadge = badgeActivation(plugin); for (const type of ["click", "keydown"] as const) { @@ -96,6 +99,11 @@ export function registerFileExplorerIndicators( plugin.registerEvent(plugin.app.vault.on("create", invalidateLinks)); plugin.registerEvent(plugin.app.vault.on("delete", invalidateLinks)); plugin.registerEvent(plugin.app.vault.on("rename", invalidateLinks)); + plugin.registerEvent( + plugin.app.vault.on("modify", (file) => { + if (plugin.sharedLinks.of(file.path).length > 0) schedule(); + }), + ); plugin.registerEvent( plugin.app.workspace.on("layout-change", () => { diff --git a/packages/plugin/src/ui/explorer/file-explorer-marks.ts b/packages/plugin/src/ui/explorer/file-explorer-marks.ts index 180192e8..083bf8c2 100644 --- a/packages/plugin/src/ui/explorer/file-explorer-marks.ts +++ b/packages/plugin/src/ui/explorer/file-explorer-marks.ts @@ -1,3 +1,4 @@ +import { noteLinks, noteLinksText } from "@/links"; import type { PluginHost } from "@/plugin/host"; import { isAtNote, mergePeople, type Person } from "@/presence"; import { type Space, spaceOf, VAULT_SPACE } from "@/sync/space"; @@ -17,8 +18,16 @@ export interface UnseenMark { file?: string; } +export interface PublishedMark { + path: string; + count: number; + stale: boolean; + text: string; +} + export interface PresenceMarks { share?: ShareMark; + published?: PublishedMark; people?: Person[]; unseen?: UnseenMark; } @@ -39,6 +48,20 @@ export function shareMarks( }, }); } + const paths = new Set(plugin.sharedLinks.all().map(({ path }) => path)); + for (const path of paths) { + const links = noteLinks(plugin, path); + if (!links) continue; + out.set(path, { + ...out.get(path), + published: { + path, + count: links.records.length, + stale: links.stale, + text: noteLinksText(links), + }, + }); + } return out; } diff --git a/packages/plugin/src/ui/index.ts b/packages/plugin/src/ui/index.ts index 0e65f5a1..647c0ca6 100644 --- a/packages/plugin/src/ui/index.ts +++ b/packages/plugin/src/ui/index.ts @@ -20,6 +20,14 @@ export { DiffView } from "./diff-view"; export { registerFileContextIndicators } from "./explorer/file-context-indicators"; export { registerFileExplorerIndicators } from "./explorer/file-explorer-indicators"; export type { IndicatorHandle } from "./explorer/indicator-handle"; +export { + addLinkMenuItems, + isLinkable, + openManageLinks, + openShareLink, + registerNoteLinkActions, + updateSharedLinks, +} from "./links"; export { createDeletedElsewhere } from "./live/deleted-elsewhere"; export { rebuildLiveNote, toggleAuthors } from "./live/header/live-actions"; export { canRebuild } from "./live/header/note-menu-items"; diff --git a/packages/plugin/src/ui/links/index.ts b/packages/plugin/src/ui/links/index.ts new file mode 100644 index 00000000..d21c9746 --- /dev/null +++ b/packages/plugin/src/ui/links/index.ts @@ -0,0 +1,8 @@ +export { updateSharedLinks } from "./link-actions"; +export { + addLinkMenuItems, + isLinkable, + openManageLinks, + openShareLink, +} from "./link-menu"; +export { registerNoteLinkActions } from "./note-link-action"; diff --git a/packages/plugin/src/ui/links/link-actions.ts b/packages/plugin/src/ui/links/link-actions.ts new file mode 100644 index 00000000..6ded1d5b --- /dev/null +++ b/packages/plugin/src/ui/links/link-actions.ts @@ -0,0 +1,49 @@ +import { + type LinkRecord, + linkError, + type RenderedNote, + updateLink, +} from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { notifyError, notifyInfo } from "@/ui/common"; +import { openPromptModal } from "@/ui/modals"; + +/** Updates each link in turn, asking a protected one for its passphrase; one notice says how it went. */ +export async function updateSharedLinks( + plugin: PluginHost, + records: readonly LinkRecord[], +): Promise { + const rendered: RenderedNote = new Map(); + const updated: LinkRecord[] = []; + for (const record of records) { + try { + const passphrase = record.salt ? await askPassphrase(plugin) : null; + if (record.salt && passphrase === null) continue; + await updateLink(plugin, record, passphrase, rendered); + updated.push(record); + } catch (err) { + notifyError("Could not change the link", linkError(err)); + } + } + if (updated.length === 0) return; + const shows = + updated.length === 1 + ? "The link now shows" + : `${updated.length} links now show`; + const spent = updated.some(({ maxViews }) => maxViews !== null) + ? " Views already used stay used." + : ""; + notifyInfo(`${shows} the note as it is.${spent}`); +} + +function askPassphrase(plugin: PluginHost): Promise { + return openPromptModal({ + app: plugin.app, + title: "Passphrase", + description: + "Type the link's passphrase to publish the note again under it.", + initialValue: "", + confirmLabel: "Update", + label: "Passphrase", + }); +} diff --git a/packages/plugin/src/ui/links/link-menu.ts b/packages/plugin/src/ui/links/link-menu.ts new file mode 100644 index 00000000..fe27d257 --- /dev/null +++ b/packages/plugin/src/ui/links/link-menu.ts @@ -0,0 +1,60 @@ +import { type App, type Menu, type TAbstractFile, TFile } from "obsidian"; +import { NO_RELAY } from "@/links"; +import { docKindOf } from "@/live"; +import type { PluginHost } from "@/plugin/host"; +import { isRelayConfigured } from "@/settings/model"; +import { notifyInfo } from "@/ui/common"; + +import { ManageLinksModal } from "./manage-links-modal"; +import { ShareLinkModal } from "./share-link-modal"; + +/** Excalidraw drawings are markdown on disk but render as their compressed data; the name covers unindexed ones. */ +export function isLinkable( + app: App, + file: TAbstractFile | null, +): file is TFile { + return ( + file instanceof TFile && + file.extension === "md" && + !file.name.endsWith(".excalidraw.md") && + docKindOf(app, file) !== "drawing" + ); +} + +export function openShareLink(plugin: PluginHost, file: TFile): void { + if (!isRelayConfigured(plugin.settings)) { + notifyInfo(NO_RELAY); + return; + } + new ShareLinkModal(plugin, file).open(); +} + +export function openManageLinks(plugin: PluginHost, path?: string): void { + new ManageLinksModal(plugin, path).open(); +} + +export function addLinkMenuItems( + menu: Menu, + plugin: PluginHost, + file: TAbstractFile, +): void { + if (!isLinkable(plugin.app, file)) return; + const ready = isRelayConfigured(plugin.settings); + menu.addItem((item) => + item + .setTitle( + ready ? "MDSync: Share link" : "MDSync: Share link (needs a relay)", + ) + .setIcon("link") + .setDisabled(!ready) + .onClick(() => openShareLink(plugin, file)), + ); + const count = plugin.sharedLinks.of(file.path).length; + if (count === 0) return; + menu.addItem((item) => + item + .setTitle(`MDSync: Share links of this note (${count})`) + .setIcon("list") + .onClick(() => openManageLinks(plugin, file.path)), + ); +} diff --git a/packages/plugin/src/ui/links/manage-links-modal.ts b/packages/plugin/src/ui/links/manage-links-modal.ts new file mode 100644 index 00000000..9575a6c9 --- /dev/null +++ b/packages/plugin/src/ui/links/manage-links-modal.ts @@ -0,0 +1,199 @@ +import type { LinkStatus } from "@mdsync/protocol"; +import { type EventRef, Modal, Setting, type TFile } from "obsidian"; +import { + isExpired, + isStale, + type LinkRecord, + linkError, + linkStatusOf, + linkStatusText, + noteName, + onRelay, + revokeLinkRecord, +} from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { copyText, notifyError, serial } from "@/ui/common"; +import { openConfirmModal } from "@/ui/modals"; + +import { updateSharedLinks } from "./link-actions"; + +/** Null: the relay no longer has it. */ +type Known = LinkStatus | null | Error; + +/** The links this device published: where each stands, and copy, update or stop. */ +export class ManageLinksModal extends Modal { + private readonly known = new Map(); + private closed = false; + /** Follows a rename while open, where the path would not. */ + private note: TFile | null = null; + private unsubscribe: (() => void) | null = null; + private modifyRef: EventRef | null = null; + + constructor( + private readonly plugin: PluginHost, + /** Only this note's links; all of them without it. */ + private readonly path?: string, + ) { + super(plugin.app); + } + + onOpen(): void { + this.modalEl.addClass("mdsync-link-modal"); + this.titleEl.setText(this.path ? "Links of this note" : "Share links"); + this.note = this.path ? this.app.vault.getFileByPath(this.path) : null; + // Rows hold their records: a rename, an update or an edit elsewhere must redraw them. + this.unsubscribe = this.plugin.sharedLinks.subscribe(() => this.draw()); + this.modifyRef = this.app.vault.on("modify", (file) => { + if (this.records().some(({ path }) => path === file.path)) this.draw(); + }); + this.draw(); + void this.load(); + } + + onClose(): void { + this.closed = true; + this.unsubscribe?.(); + if (this.modifyRef) this.app.vault.offref(this.modifyRef); + this.contentEl.empty(); + } + + private records(): LinkRecord[] { + const path = this.note?.path ?? this.path; + const records = path + ? this.plugin.sharedLinks.of(path) + : this.plugin.sharedLinks.all(); + return [...records].sort((a, b) => b.createdAt - a.createdAt); + } + + private reachable(record: LinkRecord): boolean { + return onRelay(record, this.plugin.settings); + } + + /** Asks the relay where these stand; one update need not ask about the rest. */ + private async load(records = this.records()): Promise { + await Promise.all( + records + .filter((record) => this.reachable(record)) + .map(async (record) => { + try { + this.known.set(record.id, await linkStatusOf(this.plugin, record)); + } catch (err) { + this.known.set(record.id, linkError(err)); + } + if (!this.closed) this.draw(); + }), + ); + } + + private draw(): void { + const list = this.contentEl; + list.empty(); + const records = this.records(); + if (records.length === 0) { + list.createEl("p", { + cls: "setting-item-description", + text: "No links yet. Open a note's menu and select MDSync: Share link.", + }); + return; + } + for (const record of records) this.drawRow(list, record); + } + + private drawRow(list: HTMLElement, record: LinkRecord): void { + const reachable = this.reachable(record); + // Expiry redraws without asking the relay again. + const known = isExpired(record) ? null : this.known.get(record.id); + const ended = known === null || !reachable; + const file = record.detached + ? null + : this.app.vault.getFileByPath(record.path); + const stale = file && isStale(record, file.stat.mtime); + const deleted = record.detached ? "The note was deleted. " : ""; + const setting = new Setting(list) + .setName(noteName(record)) + .setDesc( + `${deleted}${stale ? "Changed since it was shared. " : ""}${describe(record, reachable, known)}`, + ); + if (record.path.includes("/")) { + setting.descEl.createDiv({ text: record.path }); + } + if (!ended) { + setting.addExtraButton((button) => + button + .setIcon("copy") + .setTooltip("Copy link") + .onClick(() => void copyText("Link", record.url)), + ); + } + if (!ended && !record.detached) { + setting.addButton((button) => + button.setButtonText("Update").onClick( + this.guarded(button, async () => { + await updateSharedLinks(this.plugin, [record]); + await this.load([record]); + }), + ), + ); + } + setting.addExtraButton((button) => + button + .setIcon("trash-2") + .setTooltip(ended ? "Remove from the list" : "Stop sharing") + .onClick(this.guarded(button, () => this.stop(record, ended))), + ); + } + + private guarded( + button: { setDisabled(disabled: boolean): unknown }, + action: () => Promise, + ): () => Promise { + return serial(async () => { + button.setDisabled(true); + try { + await action(); + } catch (err) { + notifyError("Could not change the link", linkError(err)); + } finally { + button.setDisabled(false); + } + }); + } + + /** An ended link, or one of another relay, only leaves the list: there is nothing here to stop. */ + private async stop(record: LinkRecord, listOnly: boolean): Promise { + if (listOnly) { + await this.plugin.sharedLinks.remove(record.id); + } else { + const confirmed = await openConfirmModal({ + app: this.plugin.app, + title: "Stop sharing this link?", + body: [ + `The link to "${noteName(record)}" stops working at once. Anyone who already opened it keeps what they saw.`, + ], + confirmLabel: "Stop sharing", + confirmClass: "mod-warning", + }); + if (!confirmed) return; + await revokeLinkRecord(this.plugin, record); + } + if (!this.closed) this.draw(); + } +} + +function describe( + record: LinkRecord, + reachable: boolean, + known: Known | undefined, +): string { + if (!reachable) { + if (!URL.canParse(record.url)) { + return "This link's address is damaged: remove it from the list."; + } + return `Made through ${new URL(record.url).origin}, which is not this vault's relay: set that relay again to manage it.`; + } + if (known === undefined) return "Checking…"; + if (known instanceof Error) { + return `Could not reach the relay: ${known.message}`; + } + return linkStatusText(record, known); +} diff --git a/packages/plugin/src/ui/links/note-link-action.ts b/packages/plugin/src/ui/links/note-link-action.ts new file mode 100644 index 00000000..5e793a00 --- /dev/null +++ b/packages/plugin/src/ui/links/note-link-action.ts @@ -0,0 +1,69 @@ +import { FileView, type Plugin, type WorkspaceLeaf } from "obsidian"; + +import { noteLinks, noteLinksText } from "@/links"; +import type { PluginHost } from "@/plugin/host"; + +import { openManageLinks } from "./link-menu"; + +interface Shown { + view: FileView; + path: string; + el: HTMLElement; +} + +export function registerNoteLinkActions(plugin: Plugin & PluginHost): void { + const { workspace, vault } = plugin.app; + const shown = new Map(); + let disposed = false; + + const refresh = (): void => { + if (disposed) return; + const seen = new Set(); + workspace.iterateAllLeaves((leaf) => { + const { view } = leaf; + if (!(view instanceof FileView) || view.file?.extension !== "md") return; + const file = view.file; + const links = noteLinks(plugin, file.path); + if (!links) return; + seen.add(leaf); + let entry = shown.get(leaf); + if ( + entry?.view !== view || + entry.path !== file.path || + !entry.el.parentElement + ) { + entry?.el.remove(); + const el = view.addAction("globe", noteLinksText(links), () => + openManageLinks(plugin, file.path), + ); + el.addClass("mdsync-published-action"); + entry = { view, path: file.path, el }; + shown.set(leaf, entry); + } + entry.el.setAttr("aria-label", noteLinksText(links)); + entry.el.toggleClass("is-stale", links.stale); + }); + for (const [leaf, { el }] of shown) { + if (seen.has(leaf)) continue; + el.remove(); + shown.delete(leaf); + } + }; + + plugin.register(plugin.sharedLinks.subscribe(refresh)); + plugin.registerEvent(workspace.on("layout-change", refresh)); + plugin.registerEvent(workspace.on("file-open", refresh)); + // A sync pull modifies thousands of notes; only a shared one can change its mark. + plugin.registerEvent( + vault.on("modify", (file) => { + if (plugin.sharedLinks.of(file.path).length > 0) refresh(); + }), + ); + plugin.registerEvent(vault.on("rename", refresh)); + plugin.register(() => { + disposed = true; + for (const { el } of shown.values()) el.remove(); + shown.clear(); + }); + workspace.onLayoutReady(refresh); +} diff --git a/packages/plugin/src/ui/links/preview-frame.ts b/packages/plugin/src/ui/links/preview-frame.ts new file mode 100644 index 00000000..2f1f1887 --- /dev/null +++ b/packages/plugin/src/ui/links/preview-frame.ts @@ -0,0 +1,22 @@ +const STYLE = + "body{margin:8px;font:14px/1.5 system-ui,sans-serif}img,svg{max-width:100%}pre{overflow:auto}table{border-collapse:collapse}td,th{border:1px solid #8886;padding:2px 6px}.callout{margin:8px 0;padding:8px;border-radius:6px;background:#8881}"; + +/** A page that runs nothing and loads only what the link itself would: embedded and https images. */ +export function previewDocument(html: string, dark: boolean): string { + return `${html}`; +} + +/** Shows exactly what would be published, in a frame with every capability off. */ +export function renderPreviewFrame( + parent: HTMLElement, + html: string, +): HTMLIFrameElement { + const dark = activeDocument.body.classList.contains("theme-dark"); + return parent.createEl("iframe", { + attr: { + sandbox: "", + srcdoc: previewDocument(html, dark), + "aria-label": "Preview of the link", + }, + }); +} diff --git a/packages/plugin/src/ui/links/share-link-modal.ts b/packages/plugin/src/ui/links/share-link-modal.ts new file mode 100644 index 00000000..5e4e5b43 --- /dev/null +++ b/packages/plugin/src/ui/links/share-link-modal.ts @@ -0,0 +1,302 @@ +import { LINK_MAX_TTL_S } from "@mdsync/protocol"; +import { + ButtonComponent, + Modal, + Setting, + type TextComponent, + type TFile, +} from "obsidian"; + +import { + DEFAULT_EXPIRY, + DEFAULT_VIEWS, + EXPIRY_CHOICES, + expiryLine, + type LinkRecord, + leftOutText, + linkError, + PICK_DATE, + previewLink, + publishLink, + resolveExpiry, + type Snapshot, + VIEW_CHOICES, +} from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { invitePassword } from "@/spaces"; +import { alertLine, copyable, serial } from "@/ui/common"; +import { renderPreviewFrame } from "./preview-frame"; + +const MIN_PASSPHRASE = 8; +const MS_PER_S = 1000; +const MINUTE_MS = 60 * MS_PER_S; +const DAY_MS = 24 * 60 * MINUTE_MS; +const LOCAL_DATETIME_LENGTH = 16; + +const UNFINISHED = + "Part of the note was still drawing when this copy was taken: check the preview."; + +const INTRO = + "Anyone with the link can read a copy of this note as it is now. It is encrypted on this device and kept on your relay, which cannot read it. Links to other notes, embedded notes and files other than images are left out."; + +/** Publishes one note as a link and shows it once. */ +export class ShareLinkModal extends Modal { + private expiry: string = DEFAULT_EXPIRY; + private pickedDate = ""; + private views: string = DEFAULT_VIEWS; + private passphrase = ""; + private images = true; + private showName = true; + private closed = false; + private uploading = false; + private previews = 0; + /** What the owner is looking at: the copy that goes out. Null while it is being made. */ + private snapshot: Snapshot | null = null; + private passphraseField: TextComponent | null = null; + private createButton: ButtonComponent | null = null; + + constructor( + private readonly plugin: PluginHost, + private readonly file: TFile, + ) { + super(plugin.app); + } + + onOpen(): void { + this.modalEl.addClass("mdsync-link-modal"); + this.titleEl.setText(`Share a link to "${this.file.basename}"`); + this.renderForm(); + } + + /** The passphrase is shown only in the result, so a link being made is waited for. */ + close(): void { + if (!this.uploading) super.close(); + } + + onClose(): void { + this.closed = true; + this.contentEl.empty(); + } + + private renderForm(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("p", { cls: "setting-item-description", text: INTRO }); + this.renderExpiry(contentEl); + new Setting(contentEl) + .setName("Views") + .setDesc("The link stops working after this many openings.") + .addDropdown((dropdown) => + dropdown + .addOptions(optionsOf(VIEW_CHOICES)) + .setValue(this.views) + .onChange((value) => { + this.views = value; + }), + ); + new Setting(contentEl) + .setName("Passphrase") + .setDesc( + "Optional. Whoever opens the link has to type it. Send it by a different route than the link.", + ) + .addText((text) => { + text.setPlaceholder("None").setValue(this.passphrase); + text.inputEl.setAttr("aria-label", "Passphrase"); + text.onChange((value) => { + this.passphrase = value.trim(); + }); + this.passphraseField = text; + }) + .addButton((button) => + button.setButtonText("Generate").onClick(() => { + this.passphrase = invitePassword(); + this.passphraseField?.setValue(this.passphrase); + }), + ); + const preview = contentEl.createDiv({ cls: "mdsync-link-preview" }); + new Setting(contentEl) + .setName("Include images") + .setDesc("Images from your vault are embedded in the link.") + .addToggle((toggle) => + toggle.setValue(this.images).onChange((value) => { + this.images = value; + void this.refreshPreview(preview); + }), + ); + new Setting(contentEl) + .setName("Show the note's name") + .setDesc("Off, the page opens without a title.") + .addToggle((toggle) => + toggle.setValue(this.showName).onChange((value) => { + this.showName = value; + }), + ); + const failure = alertLine(contentEl); + const buttons = contentEl.createDiv({ cls: "mdsync-modal-buttons" }); + new ButtonComponent(buttons) + .setButtonText("Cancel") + .onClick(() => this.close()); + const create = new ButtonComponent(buttons) + .setButtonText("Create link") + .setCta(); + this.createButton = create; + create.onClick( + serial(async () => { + failure.setText(""); + const expiry = resolveExpiry(this.expiry, this.pickedDate, Date.now()); + if (!expiry.ok) { + failure.setText(expiry.reason); + return; + } + if (this.passphrase && this.passphrase.length < MIN_PASSPHRASE) { + failure.setText( + `Use at least ${MIN_PASSPHRASE} characters, or press Generate.`, + ); + return; + } + // Only what was shown goes out, and what was typed now: the fields stay editable meanwhile. + const { snapshot } = this; + const passphrase = this.passphrase; + if (!snapshot) return; + this.uploading = true; + this.syncCreate(); + try { + const record = await this.create(passphrase, snapshot, expiry.ttl); + this.renderResult(record, passphrase); + } catch (err) { + failure.setText(linkError(err).message); + } finally { + this.uploading = false; + this.syncCreate(); + } + }), + ); + void this.refreshPreview(preview); + } + + private renderExpiry(parent: HTMLElement): void { + const setting = new Setting(parent).setName("Expires"); + this.pickedDate = localDateTime(Date.now() + DAY_MS); + let dateField: TextComponent; + const update = (): void => { + const nowMs = Date.now(); + dateField.inputEl.toggle(this.expiry === PICK_DATE); + dateField.inputEl.min = localDateTime( + Math.ceil((nowMs + MINUTE_MS) / MINUTE_MS) * MINUTE_MS, + ); + dateField.inputEl.max = localDateTime(nowMs + LINK_MAX_TTL_S * MS_PER_S); + const expiry = resolveExpiry(this.expiry, this.pickedDate, nowMs); + setting.setDesc(expiry.ok ? expiryLine(expiry.expires) : expiry.reason); + }; + setting + .addDropdown((dropdown) => + dropdown + .addOptions(optionsOf(EXPIRY_CHOICES)) + .setValue(this.expiry) + .onChange((value) => { + this.expiry = value; + update(); + }), + ) + .addText((text) => { + dateField = text; + text.inputEl.type = "datetime-local"; + text.inputEl.setAttr("aria-label", "Expiry date and time"); + text.setValue(this.pickedDate).onChange((value) => { + this.pickedDate = value; + update(); + }); + }); + update(); + } + + private create( + passphrase: string, + snapshot: Snapshot, + ttl: number | null, + ): Promise { + const views = VIEW_CHOICES.find(({ key }) => key === this.views); + return publishLink( + this.plugin, + this.file, + { + ttl, + maxViews: views?.views ?? null, + passphrase: passphrase || null, + showTitle: this.showName, + }, + snapshot, + ); + } + + /** Creating waits for it: what goes out is what was shown. */ + private async refreshPreview(pane: HTMLElement): Promise { + const run = ++this.previews; + this.snapshot = null; + this.syncCreate(); + pane.empty(); + const line = pane.createEl("p", { + cls: "setting-item-description", + text: "Checking what the link will contain…", + }); + try { + const snapshot = await previewLink(this.plugin, this.file, this.images); + if (run !== this.previews || this.closed) return; + this.snapshot = snapshot; + const left = leftOutText(snapshot.left); + line.setText( + [ + left ? `Left out of the link: ${left}.` : "", + snapshot.complete ? "" : UNFINISHED, + ] + .filter(Boolean) + .join(" "), + ); + const details = pane.createEl("details"); + details.createEl("summary", { text: "Preview" }); + renderPreviewFrame(details, snapshot.html); + } catch (err) { + if (run !== this.previews || this.closed) return; + line.setText( + `Could not check the note: ${linkError(err).message} Close this window and try again.`, + ); + } + this.syncCreate(); + } + + private syncCreate(): void { + this.createButton?.setDisabled(this.snapshot === null || this.uploading); + } + + private renderResult(record: LinkRecord, passphrase: string): void { + const { contentEl } = this; + contentEl.empty(); + this.titleEl.setText("Link created"); + contentEl.createEl("p", { + cls: "setting-item-description", + text: passphrase + ? "Send the link and the passphrase by different routes. The passphrase is not saved: you will not see it again. The link stays listed under Manage share links." + : "Anyone who gets this link can read the note. It stays listed under Manage share links.", + }); + copyable(contentEl, "Link", record.url); + if (passphrase) copyable(contentEl, "Passphrase", passphrase); + const buttons = contentEl.createDiv({ cls: "mdsync-modal-buttons" }); + new ButtonComponent(buttons) + .setButtonText("Done") + .setCta() + .onClick(() => this.close()); + } +} + +function localDateTime(ms: number): string { + const date = new Date(ms); + return new Date(ms - date.getTimezoneOffset() * MINUTE_MS) + .toISOString() + .slice(0, LOCAL_DATETIME_LENGTH); +} + +function optionsOf( + choices: ReadonlyArray<{ key: string; label: string }>, +): Record { + return Object.fromEntries(choices.map(({ key, label }) => [key, label])); +} diff --git a/packages/plugin/src/ui/shares/invite-action.ts b/packages/plugin/src/ui/shares/invite-action.ts index b389d978..a2de4ec1 100644 --- a/packages/plugin/src/ui/shares/invite-action.ts +++ b/packages/plugin/src/ui/shares/invite-action.ts @@ -2,7 +2,7 @@ import { TFile, TFolder } from "obsidian"; import { randomId } from "@/crypto"; import type { PluginHost } from "@/plugin/host"; -import { ownerStorage } from "@/settings/model"; +import { ownerStorage, relayAdmin } from "@/settings/model"; import { normalizePath, stripTrailingSlash } from "@/shared"; import { acceptInvite, @@ -24,7 +24,6 @@ import { import { scopedPaths } from "@/ui/actions/push-action"; import { notifyInfo, runWithNotice } from "@/ui/common"; import { AcceptInviteModal, openConfirmModal } from "@/ui/modals"; -import { relayAdmin } from "./share-action"; export interface CreatedInvite { link: string; diff --git a/packages/plugin/src/ui/shares/invite-section.ts b/packages/plugin/src/ui/shares/invite-section.ts index 02c3cf1b..f1d71a33 100644 --- a/packages/plugin/src/ui/shares/invite-section.ts +++ b/packages/plugin/src/ui/shares/invite-section.ts @@ -1,7 +1,7 @@ import { type ButtonComponent, Setting } from "obsidian"; import { errorMessage } from "@/shared"; -import { alertLine, onEnter, runWithNotice, serial } from "@/ui/common"; +import { alertLine, copyable, onEnter, serial } from "@/ui/common"; import type { CreatedInvite } from "./invite-action"; export function renderInviteForm( @@ -60,25 +60,3 @@ function showInvite(el: HTMLElement, invite: CreatedInvite): void { copyable(el, "Password", invite.password); el.scrollIntoView({ block: "nearest" }); } - -function copyable(el: HTMLElement, name: string, value: string): void { - new Setting(el) - .setName(name) - .addText((text) => { - text.setValue(value); - text.inputEl.readOnly = true; - }) - .addExtraButton((button) => - button - .setIcon("copy") - .setTooltip(`Copy ${name.toLowerCase()}`) - .onClick( - () => - void runWithNotice( - () => navigator.clipboard.writeText(value), - `${name} copied.`, - `Could not copy the ${name.toLowerCase()}`, - ), - ), - ); -} diff --git a/packages/plugin/src/ui/shares/share-action.ts b/packages/plugin/src/ui/shares/share-action.ts index 35cd55e6..e826997c 100644 --- a/packages/plugin/src/ui/shares/share-action.ts +++ b/packages/plugin/src/ui/shares/share-action.ts @@ -1,14 +1,9 @@ import type { PluginHost } from "@/plugin/host"; -import { - isRelayConfigured, - ownerStorage, - type RelayConfig, -} from "@/settings/model"; +import { isRelayConfigured, ownerStorage, relayAdmin } from "@/settings/model"; import { relayBase } from "@/shared"; import { createShare, mountError, ownerNameOf } from "@/spaces"; import type { SpaceRecord } from "@/spaces/record"; import { - type BrokerAdmin, endShare, leaveShare, type Participant, @@ -19,13 +14,6 @@ import { pushScope } from "@/ui/actions/push-action"; import { notifyError, notifyInfo, RELAY_TEXT } from "@/ui/common"; import { openConfirmModal } from "@/ui/modals"; -export function relayAdmin({ - relayUrl, - relaySecret, -}: RelayConfig): BrokerAdmin { - return { relayUrl, secret: relaySecret }; -} - export function closeLabel({ access }: SpaceRecord): string { return access.kind === "owner" ? "Stop sharing" : "Leave"; } diff --git a/packages/plugin/src/ui/shares/share-modal.ts b/packages/plugin/src/ui/shares/share-modal.ts index 5b27044b..d353728a 100644 --- a/packages/plugin/src/ui/shares/share-modal.ts +++ b/packages/plugin/src/ui/shares/share-modal.ts @@ -1,7 +1,7 @@ import { Modal, Setting } from "obsidian"; import type { PluginHost } from "@/plugin/host"; -import { isRelayConfigured } from "@/settings/model"; +import { isRelayConfigured, relayAdmin } from "@/settings/model"; import { errorMessage } from "@/shared"; import type { PauseKind } from "@/spaces"; import type { SpaceRecord } from "@/spaces/record"; @@ -25,7 +25,6 @@ import { renderInviteForm } from "./invite-section"; import { closeLabel, closeShare, - relayAdmin, revokeAccess, strandedInvites, } from "./share-action"; diff --git a/packages/plugin/tests/helpers/explorer-host.ts b/packages/plugin/tests/helpers/explorer-host.ts index 736feaac..d9d4377a 100644 --- a/packages/plugin/tests/helpers/explorer-host.ts +++ b/packages/plugin/tests/helpers/explorer-host.ts @@ -1,6 +1,8 @@ +import { type LinkRecord, SharedLinks } from "@/links"; import type { PluginHost } from "@/plugin/host"; import type { Person } from "@/presence/people"; import { Unseen } from "@/presence/unseen"; +import { DEFAULT_SETTINGS } from "@/settings/model"; import type { Space } from "@/sync/space"; export const ALEX: Person = { @@ -25,6 +27,20 @@ export const LAPTOP: Person = { idle: false, }; +export const LINK: LinkRecord = { + id: "link", + url: "https://example.com/link", + path: "notes/published.md", + showTitle: true, + detached: false, + createdAt: 0, + publishedAt: 0, + expires: null, + maxViews: null, + salt: null, + images: false, +}; + const RECORDS = [ { id: "own", access: { kind: "owner" } }, { id: "in", access: { kind: "participant", readOnly: false } }, @@ -34,9 +50,20 @@ export function host( spaces: Space[], people: Person[], unseen: string[] = [], + links: LinkRecord[] = [], ): PluginHost { + const settings = { ...DEFAULT_SETTINGS, links }; return { - app: { vault: { getFileByPath: (path: string) => ({ path }) } }, + settings, + sharedLinks: new SharedLinks( + () => settings, + async () => {}, + ), + app: { + vault: { + getFileByPath: (path: string) => ({ path, stat: { mtime: 0 } }), + }, + }, controller: { lastEdit: () => ({ key: "p1", name: "Alex", at: Date.now() }), }, diff --git a/packages/plugin/tests/links/describe.test.ts b/packages/plugin/tests/links/describe.test.ts new file mode 100644 index 00000000..2491ab4d --- /dev/null +++ b/packages/plugin/tests/links/describe.test.ts @@ -0,0 +1,244 @@ +import type { LinkStatus } from "@mdsync/protocol"; +import { LINK_MAX_TTL_S } from "@mdsync/protocol"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EXPIRY, + EXPIRY_CHOICES, + expiryLine, + leftOutText, + linkStatusText, + resolveExpiry, +} from "@/links/describe"; +import type { LinkRecord } from "@/links/record"; + +const MS_PER_S = 1000; +const MINUTE_MS = 60 * MS_PER_S; +const DAY_MS = 24 * 60 * MINUTE_MS; +const NOW_MS = new Date("2025-01-15T12:00:00Z").getTime(); + +function pickedAt(ms: number): string { + const date = new Date(ms); + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +const record: LinkRecord = { + id: "abcdefghijklmnopqrstuv", + url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", + path: "note.md", + showTitle: true, + detached: false, + createdAt: 1_760_000_000_000, + publishedAt: 1_760_000_000_000, + expires: null, + maxViews: null, + salt: null, + images: true, +}; +const status: LinkStatus = { + views: 2, + maxViews: null, + expires: null, + protected: false, + size: 123, +}; + +describe("resolveExpiry", () => { + it("keeps the week default and the ordered choices", () => { + expect(DEFAULT_EXPIRY).toBe("week"); + expect(EXPIRY_CHOICES.map(({ key, label }) => [key, label])).toEqual([ + ["five-minutes", "5 minutes"], + ["fifteen-minutes", "15 minutes"], + ["hour", "1 hour"], + ["day", "1 day"], + ["week", "7 days"], + ["month", "30 days"], + ["never", "Never"], + ["date", "Pick a date…"], + ]); + }); + + it.each([ + ["five-minutes", 300], + ["fifteen-minutes", 900], + ["hour", 3600], + ["day", 86400], + ["week", 604800], + ["month", 2592000], + ])("resolves %s relative to now in unix seconds", (key, seconds) => { + expect(resolveExpiry(key, "", NOW_MS + 999)).toEqual({ + ok: true, + ttl: seconds, + expires: NOW_MS / MS_PER_S + seconds, + }); + }); + + it("resolves never without a date", () => { + expect(resolveExpiry("never", "", NOW_MS)).toEqual({ + ok: true, + ttl: null, + expires: null, + }); + }); + + it("ignores a picked date for presets", () => { + expect(resolveExpiry("day", "invalid", NOW_MS)).toEqual({ + ok: true, + ttl: DAY_MS / MS_PER_S, + expires: (NOW_MS + DAY_MS) / MS_PER_S, + }); + }); + + it.each([MINUTE_MS, DAY_MS, LINK_MAX_TTL_S * MS_PER_S])( + "accepts a local date %i milliseconds ahead, including the bounds", + (aheadMs) => { + expect(resolveExpiry("date", pickedAt(NOW_MS + aheadMs), NOW_MS)).toEqual( + { + ok: true, + ttl: aheadMs / MS_PER_S, + expires: (NOW_MS + aheadMs) / MS_PER_S, + }, + ); + }, + ); + + it.each([-MINUTE_MS, 0])( + "rejects a date %i milliseconds ahead", + (aheadMs) => { + expect(resolveExpiry("date", pickedAt(NOW_MS + aheadMs), NOW_MS)).toEqual( + { + ok: false, + reason: "Pick a date at least 1 minute ahead.", + }, + ); + }, + ); + + it("rejects a date less than one minute ahead even by one millisecond", () => { + expect( + resolveExpiry("date", pickedAt(NOW_MS + MINUTE_MS), NOW_MS + 1), + ).toEqual({ ok: false, reason: "Pick a date at least 1 minute ahead." }); + }); + + it("rejects a date beyond the maximum even by one millisecond", () => { + expect( + resolveExpiry( + "date", + pickedAt(NOW_MS + LINK_MAX_TTL_S * MS_PER_S), + NOW_MS - 1, + ), + ).toEqual({ + ok: false, + reason: "Pick a date no more than 365 days ahead.", + }); + }); + + it.each(["", "invalid"])( + "rejects an empty or invalid picked date: %s", + (picked) => { + expect(resolveExpiry("date", picked, NOW_MS)).toEqual({ + ok: false, + reason: "Pick a date and time.", + }); + }, + ); + + it("floors the picked date span from the current clock", () => { + expect( + resolveExpiry("date", pickedAt(NOW_MS + DAY_MS), NOW_MS + 999), + ).toEqual({ + ok: true, + ttl: DAY_MS / MS_PER_S - 1, + expires: (NOW_MS + DAY_MS) / MS_PER_S - 1, + }); + expect(EXPIRY_CHOICES.at(-1)).not.toHaveProperty("seconds"); + }); + + it("rejects an unknown choice", () => { + expect(resolveExpiry("unknown", "", NOW_MS)).toEqual({ + ok: false, + reason: "Choose an expiry.", + }); + }); +}); + +describe("expiryLine", () => { + it("describes a link that never expires", () => { + expect(expiryLine(null)).toBe("Never expires."); + }); + + it("uses the same locale formatting as the link status", () => { + const expires = (NOW_MS + DAY_MS) / MS_PER_S; + const when = new Date(expires * MS_PER_S).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + expect(expiryLine(expires)).toBe(`Expires ${when}.`); + expect(linkStatusText(record, { ...status, expires })).toBe( + `2 views · expires ${when}`, + ); + }); +}); + +describe("linkStatusText", () => { + it("describes a missing link as ended", () => { + expect(linkStatusText(record, null)).toBe( + "Ended: it expired, was used up or was stopped.", + ); + }); + + it.each([ + [0, "0 views"], + [1, "1 view"], + [2, "2 views"], + ])("describes %i unlimited views", (views, text) => { + expect(linkStatusText(record, { ...status, views })).toBe( + `${text} · never expires`, + ); + }); + + it.each([ + [2, 3], + [5, 0], + [8, 0], + ])("clamps views left after %i views", (views, left) => { + expect(linkStatusText(record, { ...status, views, maxViews: 5 })).toBe( + `${left} of 5 views left · never expires`, + ); + }); + + it("describes a dated expiry without relying on the locale", () => { + const text = linkStatusText(record, { ...status, expires: 2_000_000_000 }); + + expect(text).toContain("expires"); + expect(text).not.toContain("never expires"); + }); + + it("adds a passphrase suffix for a salted record", () => { + expect(linkStatusText({ ...record, salt: "salt" }, status)).toBe( + "2 views · never expires · passphrase", + ); + }); +}); + +describe("leftOutText", () => { + it("returns no text when nothing was left out", () => { + expect(leftOutText({ embeds: 0, images: 0, diagrams: 0 })).toBe(""); + }); + + it("uses singular nouns", () => { + expect(leftOutText({ embeds: 1, images: 1, diagrams: 1 })).toBe( + "1 embedded file, 1 image, 1 diagram (shown as text)", + ); + }); + + it("uses plural nouns", () => { + expect(leftOutText({ embeds: 2, images: 3, diagrams: 4 })).toBe( + "2 embedded files, 3 images, 4 diagrams (shown as text)", + ); + }); + + it("omits zero counts without leaving separators", () => { + expect(leftOutText({ embeds: 0, images: 2, diagrams: 0 })).toBe("2 images"); + }); +}); diff --git a/packages/plugin/tests/links/errors.test.ts b/packages/plugin/tests/links/errors.test.ts new file mode 100644 index 00000000..f4baa560 --- /dev/null +++ b/packages/plugin/tests/links/errors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { linkError } from "@/links/errors"; +import { StorageRequestError } from "@/storage"; + +describe("linkError", () => { + it("uses the storage refusal's user-facing message", () => { + const refused = new StorageRequestError("HTTP 401", "Check the secret."); + const error = linkError(refused); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Check the secret."); + expect(error).not.toBe(refused); + }); + + it("passes an ordinary Error through unchanged", () => { + const error = new Error("Network failure"); + + expect(linkError(error)).toBe(error); + }); + + it.each([ + ["plain failure", "plain failure"], + [{ message: "Relay failure" }, "Relay failure"], + [42, "42"], + [null, "null"], + ])("wraps a non-Error throwable %#", (value, message) => { + const error = linkError(value); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe(message); + }); +}); diff --git a/packages/plugin/tests/links/fixtures/rendered-note.html b/packages/plugin/tests/links/fixtures/rendered-note.html new file mode 100644 index 00000000..2b422b78 --- /dev/null +++ b/packages/plugin/tests/links/fixtures/rendered-note.html @@ -0,0 +1,67 @@ +
+

Heading one

+

Plain bold italic strike highlight inline code and a #tag here.

+

+ +

Links: Other, an alias, Other > Section, text, ext, https://auto.example.com

+

Embeds:
+

Other

Other

+

Other note body, private.

+

Section

+

Section text.


+img.png
+img.png
+"missing.png" could not be found.
+
+ext image

+
A note callout
+

Body of the note.

+
+ +
Open warning
+

Nested:

+
Danger
+

Deep.

+
+
+
const answer: number = 42; // comment
+function hi() { return "x"; }
+
+
    +
  • open task
  • +
  • done task
  • +
  • plain item +
      +
    • nested
    • +
    +
  • +
+ + + + + + + + + + + + + +
ab
12
+

Math inline and block:

+
+
Display Mermaid diagrams in this vault?
Only allow if you trust this vault's contents.
graph TD; A-->B;
+

Footnote ref[1].

+
+

Final paragraph.

+ + +

    +
  1. The footnote text.↩︎

  2. +
\ No newline at end of file diff --git a/packages/plugin/tests/links/images.test.ts b/packages/plugin/tests/links/images.test.ts new file mode 100644 index 00000000..2a1f5fef --- /dev/null +++ b/packages/plugin/tests/links/images.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import type { App, TFile } from "obsidian"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IMAGE_MAX_BYTES, inlineImage } from "@/links/images"; + +const sourcePath = "notes/Trip.md"; + +function imageApp(extension = "png", bytes = new ArrayBuffer(0)) { + const file = { path: `images/photo.${extension}`, extension } as TFile; + const getFirstLinkpathDest = vi.fn((): TFile | null => file); + const readBinary = vi.fn(async () => bytes); + const app = { + metadataCache: { getFirstLinkpathDest }, + vault: { readBinary }, + } as unknown as App; + return { app, file, getFirstLinkpathDest, readBinary }; +} + +function redraw(size: number) { + const bitmap = { width: 3200, height: 1600, close: vi.fn() }; + const createBitmap = vi.fn(async () => bitmap); + const drawImage = vi.fn(); + const convertToBlob = vi.fn( + async () => new Blob([new ArrayBuffer(size)], { type: "image/webp" }), + ); + const canvas = vi.fn(function ( + this: OffscreenCanvas, + width: number, + height: number, + ) { + Object.assign(this, { + width, + height, + getContext: () => ({ drawImage }), + convertToBlob, + }); + }); + vi.stubGlobal("createImageBitmap", createBitmap); + vi.stubGlobal("OffscreenCanvas", canvas); + return { createBitmap, canvas, drawImage, convertToBlob, bitmap }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("inlineImage", () => { + it("inlines a small PNG as a data URI", async () => { + const png = new Uint8Array([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, + ]); + const { app, file, getFirstLinkpathDest, readBinary } = imageApp( + "png", + png.buffer, + ); + + expect(await inlineImage(app, sourcePath, "images/photo.png")).toBe( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", + ); + expect(getFirstLinkpathDest).toHaveBeenCalledWith( + "images/photo.png", + sourcePath, + ); + expect(readBinary).toHaveBeenCalledWith(file); + }); + + it.each(["jpg", "jpeg"])( + "redraws even a small %s and keeps a larger redraw", + async (extension) => { + const { app } = imageApp(extension, new ArrayBuffer(1)); + const { createBitmap, canvas, drawImage, convertToBlob, bitmap } = + redraw(2); + + expect(await inlineImage(app, sourcePath, "photo.jpg")).toBe( + "data:image/webp;base64,AAA=", + ); + expect(createBitmap).toHaveBeenCalledOnce(); + expect(canvas).toHaveBeenCalledWith(1600, 800); + expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 1600, 800); + expect(bitmap.close).toHaveBeenCalledOnce(); + expect(convertToBlob).toHaveBeenCalledWith({ + type: "image/webp", + quality: 0.82, + }); + }, + ); + + it.each(["jpg", "png"])( + "handles a failed %s redraw without leaking JPEG metadata", + async (extension) => { + const { app } = imageApp( + extension, + new ArrayBuffer(extension === "jpg" ? 1 : 301 * 1024), + ); + const { createBitmap } = redraw(1); + createBitmap.mockRejectedValueOnce(new Error("Cannot decode")); + + const uri = await inlineImage(app, sourcePath, "photo"); + if (extension === "jpg") expect(uri).toBeNull(); + else expect(uri).toMatch(/^data:image\/png;base64,/); + }, + ); + + it.each(["png", "webp", "bmp", "avif"])( + "only redraws %s above 300 KiB and keeps a smaller result", + async (extension) => { + const { app } = imageApp(extension, new ArrayBuffer(300 * 1024)); + const { createBitmap } = redraw(1); + await inlineImage(app, sourcePath, "photo"); + expect(createBitmap).not.toHaveBeenCalled(); + + const large = imageApp(extension, new ArrayBuffer(300 * 1024 + 1)); + expect(await inlineImage(large.app, sourcePath, "photo")).toBe( + "data:image/webp;base64,AA==", + ); + expect(createBitmap).toHaveBeenCalledOnce(); + }, + ); + + it.each([301 * 1024, 302 * 1024])( + "keeps the original PNG when its redraw is not smaller (%i bytes)", + async (size) => { + const { app } = imageApp("png", new ArrayBuffer(301 * 1024)); + const { createBitmap } = redraw(size); + expect(await inlineImage(app, sourcePath, "photo")).toMatch( + /^data:image\/png;base64,/, + ); + expect(createBitmap).toHaveBeenCalledOnce(); + }, + ); + + it.each(["gif", "svg"])("never redraws %s", async (extension) => { + const { app } = imageApp(extension, new ArrayBuffer(301 * 1024)); + const { createBitmap } = redraw(1); + expect(await inlineImage(app, sourcePath, "photo")).toMatch( + /^data:image\//, + ); + expect(createBitmap).not.toHaveBeenCalled(); + }); + + it("decodes spaces in vault image links", async () => { + const { app, getFirstLinkpathDest } = imageApp(); + + await inlineImage(app, sourcePath, "images/my%20photo.png"); + + expect(getFirstLinkpathDest).toHaveBeenCalledWith( + "images/my photo.png", + sourcePath, + ); + }); + + it("returns null for a missing vault image", async () => { + const { app, getFirstLinkpathDest, readBinary } = imageApp(); + getFirstLinkpathDest.mockReturnValueOnce(null); + + expect(await inlineImage(app, sourcePath, "missing.png")).toBeNull(); + expect(readBinary).not.toHaveBeenCalled(); + }); + + it("returns null for a non-image extension", async () => { + const { app, readBinary } = imageApp("md"); + + expect(await inlineImage(app, sourcePath, "note.md")).toBeNull(); + expect(readBinary).not.toHaveBeenCalled(); + }); + + it("returns null for an image over the cap without re-encoding GIFs", async () => { + const { app } = imageApp("gif", new ArrayBuffer(IMAGE_MAX_BYTES + 1)); + + expect(await inlineImage(app, sourcePath, "images/photo.gif")).toBeNull(); + }); +}); diff --git a/packages/plugin/tests/links/math.test.ts b/packages/plugin/tests/links/math.test.ts new file mode 100644 index 00000000..bfd50cfc --- /dev/null +++ b/packages/plugin/tests/links/math.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { mathToMathML, tagMathSources } from "@/links/math"; + +interface FakeMathJax { + tex2chtml: (source: string, options?: { display?: boolean }) => Element; + tex2chtmlPromise: ( + source: string, + options?: { display?: boolean }, + ) => Promise; + tex2mml: (source: string, options?: { display?: boolean }) => string; +} + +const chtml = (): Element => { + const node = document.createElement("mjx-container"); + node.append(document.createElement("mjx-math")); + return node; +}; + +const fake: FakeMathJax = { + tex2chtml: () => chtml(), + tex2chtmlPromise: () => Promise.resolve(chtml()), + tex2mml: (source, options) => { + if (source === "\\bad") throw new Error("Undefined control sequence"); + return `${source}`; + }, +}; +const original = { ...fake }; + +beforeEach(() => { + (window as unknown as { MathJax?: FakeMathJax }).MathJax = fake; +}); + +afterEach(() => { + Object.assign(fake, original); +}); + +describe("math", () => { + it("tags each formula with its TeX while held, and stops after release", async () => { + const release = await tagMathSources("$x$"); + const inline = fake.tex2chtml("x", { display: false }); + const block = await fake.tex2chtmlPromise("y", { display: true }); + release(); + expect(inline.getAttribute("data-mdsync-tex")).toBe("x"); + expect(inline.hasAttribute("data-mdsync-display")).toBe(false); + expect(block.getAttribute("data-mdsync-tex")).toBe("y"); + expect(block.hasAttribute("data-mdsync-display")).toBe(true); + expect(fake.tex2chtml).toBe(original.tex2chtml); + expect(fake.tex2chtml("z").hasAttribute("data-mdsync-tex")).toBe(false); + }); + + it("stays on until every holder has released", async () => { + const first = await tagMathSources("$a$"); + const second = await tagMathSources("$b$"); + first(); + first(); + expect(fake.tex2chtml("x").hasAttribute("data-mdsync-tex")).toBe(true); + second(); + expect(fake.tex2chtml).toBe(original.tex2chtml); + }); + + it("replaces tagged formulas with MathML and keeps the rest", async () => { + const release = await tagMathSources("$x$"); + const root = document.createElement("div"); + root.append( + fake.tex2chtml("x", { display: false }), + fake.tex2chtml("y", { display: true }), + document.createElement("mjx-container"), + ); + release(); + mathToMathML(root); + const maths = root.querySelectorAll("math"); + expect(maths).toHaveLength(2); + expect(maths[0]?.getAttribute("display")).toBe("inline"); + expect(maths[1]?.getAttribute("display")).toBe("block"); + expect(maths[1]?.namespaceURI).toBe("http://www.w3.org/1998/Math/MathML"); + expect(maths.item(0).getAttribute("data-tex")).toBe("x"); + expect(maths.item(1).getAttribute("data-tex")).toBe("y"); + expect(root.querySelectorAll("mjx-container")).toHaveLength(1); + expect(root.querySelector("[data-mdsync-tex]")).toBeNull(); + }); + + it("falls back to the TeX as code when MathJax cannot convert it", async () => { + const release = await tagMathSources("$\\bad$"); + const root = document.createElement("div"); + root.append(fake.tex2chtml("\\bad")); + release(); + mathToMathML(root); + expect(root.querySelector("math")).toBeNull(); + expect(root.querySelector("code")?.textContent).toBe("\\bad"); + }); +}); diff --git a/packages/plugin/tests/links/publish.test.ts b/packages/plugin/tests/links/publish.test.ts new file mode 100644 index 00000000..c69047b0 --- /dev/null +++ b/packages/plugin/tests/links/publish.test.ts @@ -0,0 +1,578 @@ +import { randomBytes } from "node:crypto"; +import { + deriveLinkKeys, + fromBase64Url, + LINK_GATE_PATTERN, + LINK_MAX_SEALED_BYTES, + linkUrl, + newLinkId, + newLinkKey, + newLinkSalt, + openLinkPayload, + parseLinkLocation, + toBase64Url, +} from "@mdsync/protocol"; +import { TFile } from "obsidian"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; +import { noteText } from "@/links/note-text"; +import { + linkStatusOf, + previewLink, + publishLink, + revokeLinkRecord, + type ShareOptions, + TOO_LARGE, + updateLink, +} from "@/links/publish"; +import type { LinkRecord } from "@/links/record"; +import { SharedLinks } from "@/links/shared-links"; +import { type Snapshot, takeSnapshot } from "@/links/snapshot"; +import type { PluginHost } from "@/plugin/host"; +import { createLink, linkStatus, replaceLink, revokeLink } from "@/storage"; + +vi.mock("@/storage", async (original) => ({ + ...(await original()), + createLink: vi.fn(async () => 2_000_000_000), + replaceLink: vi.fn(async () => {}), + linkStatus: vi.fn(async () => null), + revokeLink: vi.fn(async () => {}), +})); +vi.mock("@/links/snapshot", () => ({ takeSnapshot: vi.fn() })); +vi.mock("@/links/note-text", () => ({ noteText: vi.fn() })); + +const relay = "https://relay.example"; +const admin = { relayUrl: relay, secret: "secret" }; +const snapshot: Snapshot = { + html: "

Trip notes in May

", + takenAt: 1_760_000_000_000, + images: true, + complete: true, + left: { embeds: 0, images: 0, diagrams: 0 }, +}; +const options: ShareOptions = { + ttl: 3600, + maxViews: 5, + passphrase: null, + showTitle: true, +}; + +function host() { + const file = Object.assign(new TFile(), { + path: "notes/Trip.md", + basename: "Trip", + extension: "md", + }); + const getFileByPath = vi.fn((): TFile | null => file); + const saveSettings = vi.fn(async () => {}); + const plugin = { + app: { vault: { getFileByPath } }, + settings: { relayUrl: relay, relaySecret: "secret", links: [] }, + saveSettings, + } as unknown as PluginHost; + Object.assign(plugin, { + sharedLinks: new SharedLinks(() => plugin.settings, saveSettings), + }); + return { plugin, file, getFileByPath, saveSettings }; +} + +function record(file: TFile): LinkRecord { + const id = newLinkId(); + return { + id, + url: linkUrl(relay, id, newLinkKey()), + path: file.path, + showTitle: true, + detached: false, + createdAt: 1_760_000_000_000, + publishedAt: 1_760_000_000_000, + expires: 2_000_000_000, + maxViews: options.maxViews, + salt: null, + images: true, + }; +} + +function locationOf(record: LinkRecord) { + const url = new URL(record.url); + const location = parseLinkLocation(url.pathname, url.hash); + assert(location); + return location; +} + +function uploadCall() { + const call = vi.mocked(createLink).mock.calls[0]; + assert(call); + return call; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(createLink).mockResolvedValue(2_000_000_000); + vi.mocked(takeSnapshot).mockResolvedValue(snapshot); + vi.mocked(noteText).mockResolvedValue("# Trip\nTrip notes in May"); +}); + +describe("publishLink", () => { + it("revokes and removes the link when saving its record fails", async () => { + const { plugin, file, saveSettings } = host(); + const existing = record(file); + plugin.settings.links = [existing]; + const failure = new Error("Save failed"); + saveSettings.mockRejectedValueOnce(failure); + const remove = vi.spyOn(plugin.sharedLinks, "remove"); + vi.mocked(revokeLink).mockImplementationOnce(async () => { + expect(remove).not.toHaveBeenCalled(); + expect(plugin.settings.links).toHaveLength(2); + }); + await expect(publishLink(plugin, file, options, snapshot)).rejects.toBe( + failure, + ); + const id = uploadCall()[1]; + expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, id); + expect(remove).toHaveBeenCalledExactlyOnceWith(id); + expect(plugin.settings.links).toEqual([existing]); + }); + + it("retains the record when saving and revoking both fail, rethrowing the save error", async () => { + const { plugin, file, saveSettings } = host(); + const failure = new Error("Save failed"); + saveSettings.mockRejectedValueOnce(failure); + const remove = vi.spyOn(plugin.sharedLinks, "remove"); + vi.mocked(revokeLink).mockRejectedValueOnce(new Error("Relay unavailable")); + + await expect(publishLink(plugin, file, options, snapshot)).rejects.toBe( + failure, + ); + + const id = uploadCall()[1]; + expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, id); + expect(remove).not.toHaveBeenCalled(); + expect(plugin.settings.links).toEqual([expect.objectContaining({ id })]); + }); + + it.each(["deleted", "recreated"])( + "detaches the record when its note is %s during upload", + async (change) => { + const { plugin, file, getFileByPath } = host(); + vi.mocked(createLink).mockImplementationOnce(async () => { + getFileByPath.mockReturnValue( + change === "deleted" + ? null + : Object.assign(new TFile(), { path: file.path }), + ); + return 2_000_000_000; + }); + const published = await publishLink(plugin, file, options, snapshot); + expect(published.detached).toBe(true); + expect(plugin.settings.links).toEqual([published]); + }, + ); + + it("uploads a sealed snapshot and appends the unprotected link", async () => { + const { plugin, file, saveSettings } = host(); + const existing = record(file); + plugin.settings.links = [existing]; + + const looked = await previewLink(plugin, file, true); + const published = await publishLink(plugin, file, options, looked); + const location = locationOf(published); + const [, id, sealed, uploadedOptions] = uploadCall(); + + expect(published.url).toBe( + `${relay}/s/${published.id}#${toBase64Url(location.key)}`, + ); + expect(location.id).toBe(published.id); + expect(published).toMatchObject({ + path: file.path, + showTitle: true, + detached: false, + expires: 2_000_000_000, + publishedAt: looked.takenAt, + maxViews: options.maxViews, + salt: null, + images: true, + }); + expect(createLink).toHaveBeenCalledTimes(1); + expect(uploadCall()[0]).toEqual(admin); + expect(id).toBe(published.id); + expect(uploadedOptions).toEqual({ + ttl: options.ttl, + maxViews: options.maxViews, + gate: (await deriveLinkKeys(location.key)).gate, + salt: null, + }); + expect(plugin.settings.links).toEqual([existing, published]); + expect(saveSettings).toHaveBeenCalledTimes(1); + expect(noteText).toHaveBeenCalledWith(plugin.app, file); + expect(takeSnapshot).toHaveBeenCalledWith( + plugin.app, + file, + "# Trip\nTrip notes in May", + { images: true }, + ); + expect(linkStatus).not.toHaveBeenCalled(); + + const keys = await deriveLinkKeys(location.key); + expect(await openLinkPayload(id, sealed, keys.content)).toEqual({ + html: snapshot.html, + title: file.basename, + createdAt: looked.takenAt, + }); + }); + + it("protects the payload with the passphrase and stores its salt", async () => { + const { plugin, file } = host(); + const passphrase = "correct horse"; + const published = await publishLink( + plugin, + file, + { + ...options, + passphrase, + }, + snapshot, + ); + const { key } = locationOf(published); + const [, id, sealed, uploadedOptions] = uploadCall(); + assert(published.salt); + const salt = fromBase64Url(published.salt); + const right = await deriveLinkKeys(key, { passphrase, salt }); + + expect(createLink).toHaveBeenCalledTimes(1); + expect(uploadedOptions).toMatchObject({ + gate: right.gate, + salt: published.salt, + }); + expect(right.gate).toMatch(LINK_GATE_PATTERN); + expect(plugin.settings.links).toEqual([published]); + expect(await openLinkPayload(id, sealed, right.content)).toMatchObject({ + html: snapshot.html, + title: file.basename, + }); + + const unprotected = await deriveLinkKeys(key); + await expect( + openLinkPayload(id, sealed, unprotected.content), + ).rejects.toThrow("This link cannot be opened."); + const wrong = await deriveLinkKeys(key, { + passphrase: "wrong horse", + salt, + }); + await expect(openLinkPayload(id, sealed, wrong.content)).rejects.toThrow( + "This link cannot be opened.", + ); + }); + + it("publishes an empty title when the title is disabled", async () => { + const { plugin, file } = host(); + const published = await publishLink( + plugin, + file, + { + ...options, + showTitle: false, + }, + snapshot, + ); + const [, id, sealed] = uploadCall(); + const { content } = await deriveLinkKeys(locationOf(published).key); + + expect(published.showTitle).toBe(false); + expect(await openLinkPayload(id, sealed, content)).toMatchObject({ + title: "", + html: snapshot.html, + }); + }); + + it("refuses a snapshot whose sealed bytes exceed the size cap", async () => { + const { plugin, file, saveSettings } = host(); + const large = { + ...snapshot, + html: randomBytes(LINK_MAX_SEALED_BYTES + 1024 * 1024).toString("base64"), + }; + + await expect(publishLink(plugin, file, options, large)).rejects.toThrow( + TOO_LARGE, + ); + + expect(createLink).not.toHaveBeenCalled(); + expect(plugin.settings.links).toEqual([]); + expect(saveSettings).not.toHaveBeenCalled(); + }); + + it("refuses a snapshot too large to open, however well it packs", async () => { + const { plugin, file } = host(); + const large = { ...snapshot, html: "a".repeat(40 * 1024 * 1024) }; + + await expect(publishLink(plugin, file, options, large)).rejects.toThrow( + TOO_LARGE, + ); + + expect(createLink).not.toHaveBeenCalled(); + }); +}); + +describe("updateLink", () => { + it.each([true, false])( + "shares a snapshot across links with matching image flags: %s", + async (sameImages) => { + const { plugin, file } = host(); + const first = record(file); + const second = { ...record(file), images: sameImages }; + plugin.settings.links = [first, second]; + const rendered = new Map>(); + + await Promise.all([ + updateLink(plugin, first, null, rendered), + updateLink(plugin, second, null, rendered), + ]); + + expect(takeSnapshot).toHaveBeenCalledTimes(sameImages ? 1 : 2); + expect(noteText).toHaveBeenCalledTimes(sameImages ? 1 : 2); + expect(rendered.size).toBe(sameImages ? 1 : 2); + expect(takeSnapshot).toHaveBeenCalledWith( + plugin.app, + file, + expect.any(String), + { images: true }, + ); + if (!sameImages) { + expect(takeSnapshot).toHaveBeenCalledWith( + plugin.app, + file, + expect.any(String), + { images: false }, + ); + } + expect(replaceLink).toHaveBeenCalledTimes(2); + }, + ); + + it("refuses an update of a detached note", async () => { + const { plugin, file } = host(); + const published = { ...record(file), detached: true }; + plugin.settings.links = [published]; + await expect(updateLink(plugin, published, null)).rejects.toThrow( + "The note was deleted.", + ); + expect(replaceLink).not.toHaveBeenCalled(); + expect(takeSnapshot).not.toHaveBeenCalled(); + }); + it("uploads with update=true and keeps the same id and URL key", async () => { + const { plugin, file, getFileByPath } = host(); + const published = record(file); + plugin.settings.links = [published]; + const url = published.url; + const updated = { ...snapshot, html: "

Updated note

" }; + vi.mocked(takeSnapshot).mockResolvedValueOnce(updated); + + await updateLink(plugin, published, null); + + expect(getFileByPath).toHaveBeenCalledWith(published.path); + expect(replaceLink).toHaveBeenCalledTimes(1); + const call = vi.mocked(replaceLink).mock.calls[0]; + assert(call); + const [uploadedAdmin, id, sealed, gate] = call; + expect(uploadedAdmin).toEqual(admin); + expect(id).toBe(published.id); + expect(gate).toBe((await deriveLinkKeys(locationOf(published).key)).gate); + expect(published.url).toBe(url); + expect(plugin.settings.links).toEqual([ + { ...published, publishedAt: expect.any(Number) }, + ]); + expect(plugin.settings.links[0]?.publishedAt).toBeGreaterThan( + published.publishedAt, + ); + const { content } = await deriveLinkKeys(locationOf(published).key); + expect(await openLinkPayload(id, sealed, content)).toMatchObject({ + title: file.basename, + html: updated.html, + }); + }); + + it("refuses an update when the note is missing", async () => { + const { plugin, file, getFileByPath } = host(); + const published = record(file); + plugin.settings.links = [published]; + getFileByPath.mockReturnValueOnce(null); + + await expect(updateLink(plugin, published, null)).rejects.toThrow( + `The note is no longer at "${published.path}".`, + ); + + expect(replaceLink).not.toHaveBeenCalled(); + expect(takeSnapshot).not.toHaveBeenCalled(); + expect(plugin.settings.links).toEqual([published]); + }); + + it("requires a passphrase to update a salted record", async () => { + const { plugin, file } = host(); + const published = { ...record(file), salt: toBase64Url(newLinkSalt()) }; + plugin.settings.links = [published]; + + await expect(updateLink(plugin, published, null)).rejects.toThrow( + "Enter the link's passphrase.", + ); + + expect(replaceLink).not.toHaveBeenCalled(); + }); + + it("updates the note where it is now, not where the list showed it", async () => { + const { plugin, file, getFileByPath } = host(); + const shown = record(file); + plugin.settings.links = [{ ...shown, path: "moved/Trip.md" }]; + + await updateLink(plugin, shown, null); + + expect(getFileByPath).toHaveBeenCalledExactlyOnceWith("moved/Trip.md"); + }); + + it("refuses a link stopped meanwhile", async () => { + const { plugin, file } = host(); + + await expect(updateLink(plugin, record(file), null)).rejects.toThrow( + "This link is no longer shared.", + ); + + expect(replaceLink).not.toHaveBeenCalled(); + }); + + it("refuses a second update of a link while one uploads", async () => { + const { plugin, file } = host(); + const published = record(file); + plugin.settings.links = [published]; + let finish = () => {}; + vi.mocked(replaceLink).mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)), + ); + + const first = updateLink(plugin, published, null); + await expect(updateLink(plugin, published, null)).rejects.toThrow( + "This link is already updating.", + ); + await vi.waitFor(() => expect(replaceLink).toHaveBeenCalled()); + finish(); + await first; + + await updateLink(plugin, published, null); + expect(replaceLink).toHaveBeenCalledTimes(2); + }); +}); + +describe("revokeLinkRecord", () => { + it("revokes at the relay before removing and saving the record", async () => { + const { plugin, file, saveSettings } = host(); + const published = record(file); + const other = record(file); + plugin.settings.links = [published, other]; + vi.mocked(revokeLink).mockImplementationOnce(async () => { + expect(plugin.settings.links).toEqual([published, other]); + expect(saveSettings).not.toHaveBeenCalled(); + }); + + await revokeLinkRecord(plugin, published); + + expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, published.id); + expect(plugin.settings.links).toEqual([other]); + expect(saveSettings).toHaveBeenCalledTimes(1); + }); + + it("keeps the record when revoking fails", async () => { + const { plugin, file, saveSettings } = host(); + const published = record(file); + plugin.settings.links = [published]; + const failure = new Error("Relay unavailable"); + vi.mocked(revokeLink).mockRejectedValueOnce(failure); + + await expect(revokeLinkRecord(plugin, published)).rejects.toBe(failure); + + expect(revokeLink).toHaveBeenCalledExactlyOnceWith(admin, published.id); + expect(plugin.settings.links).toEqual([published]); + expect(saveSettings).not.toHaveBeenCalled(); + }); +}); + +describe("a relay that changes meanwhile", () => { + it("publishes a given snapshot as it is, without rendering again", async () => { + const { plugin, file } = host(); + const looked = { + ...snapshot, + html: "

What the owner saw

", + images: false, + }; + + await publishLink(plugin, file, options, looked); + + expect(takeSnapshot).not.toHaveBeenCalled(); + const [, id, sealed] = uploadCall(); + const key = locationOf(plugin.settings.links[0] as LinkRecord).key; + const { content } = await deriveLinkKeys(key); + expect((await openLinkPayload(id, sealed, content)).html).toBe( + "

What the owner saw

", + ); + expect(plugin.settings.links[0]?.publishedAt).toBe(looked.takenAt); + expect(plugin.settings.links[0]?.images).toBe(false); + expect((await openLinkPayload(id, sealed, content)).createdAt).toBe( + looked.takenAt, + ); + }); + + it("stores the note's path as it is once the upload ends", async () => { + const { plugin, file } = host(); + vi.mocked(createLink).mockImplementationOnce(async () => { + file.path = "moved/Trip.md"; + return 2_000_000_000; + }); + + const published = await publishLink(plugin, file, options, snapshot); + + expect(published.path).toBe("moved/Trip.md"); + expect(plugin.settings.links).toEqual([published]); + }); + + it("sends the upload to the relay its address names", async () => { + const { plugin, file } = host(); + vi.mocked(createLink).mockImplementationOnce(async () => { + plugin.settings.relayUrl = "https://other.example"; + return 2_000_000_000; + }); + + const published = await publishLink(plugin, file, options, snapshot); + + expect(published.url.startsWith(`${relay}/s/`)).toBe(true); + expect(uploadCall()[0]).toEqual(admin); + }); + + it("refuses to publish with no relay set", async () => { + const { plugin, file } = host(); + plugin.settings.relaySecret = ""; + + await expect(publishLink(plugin, file, options, snapshot)).rejects.toThrow( + "Set up the relay", + ); + expect(createLink).not.toHaveBeenCalled(); + }); + + it("does not revoke, update or ask about a link of another relay", async () => { + const { plugin, file, saveSettings } = host(); + const published = record(file); + plugin.settings.links = [published]; + plugin.settings.relayUrl = "https://other.example"; + + await expect(revokeLinkRecord(plugin, published)).rejects.toThrow( + "another relay", + ); + await expect(updateLink(plugin, published, null)).rejects.toThrow( + "another relay", + ); + await expect(linkStatusOf(plugin, published)).rejects.toThrow( + "another relay", + ); + + expect(revokeLink).not.toHaveBeenCalled(); + expect(replaceLink).not.toHaveBeenCalled(); + expect(linkStatus).not.toHaveBeenCalled(); + expect(plugin.settings.links).toEqual([published]); + expect(saveSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/links/record.test.ts b/packages/plugin/tests/links/record.test.ts new file mode 100644 index 00000000..4ad3501c --- /dev/null +++ b/packages/plugin/tests/links/record.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { + detachedLinks, + type LinkRecord, + noteName, + onRelay, + parseLinkRecord, + renamedLinks, +} from "@/links/record"; + +const record: LinkRecord = { + id: "abcdefghijklmnopqrstuv", + url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", + path: "a/b/note.md", + showTitle: true, + detached: false, + createdAt: 1_760_000_000_000, + publishedAt: 1_760_000_000_000, + expires: null, + maxViews: null, + salt: null, + images: true, +}; + +const relay = { relayUrl: "https://relay.example", relaySecret: "secret" }; + +describe("parseLinkRecord", () => { + it.each([ + record, + { ...record, expires: 2_000_000_000, maxViews: 5, salt: "salt" }, + { ...record, showTitle: false, images: false, detached: true }, + ])("keeps a complete record %#", (value) => { + expect(parseLinkRecord(value)).toEqual(value); + }); + + it.each([ + null, + undefined, + "link", + 42, + true, + [], + {}, + { id: "id" }, + { url: "url" }, + { id: 1, url: "url" }, + { id: "id", url: null }, + ])("drops a value without a string id and url %#", (value) => { + expect(parseLinkRecord(value)).toBeNull(); + }); + + it.each([ + [{ title: "" }, false], + [{ title: "Trip" }, true], + [{ title: "", showTitle: true }, true], + [{ title: "Trip", showTitle: false }, false], + ] as const)( + "migrates the original title choice, with an explicit showTitle winning %#", + (choice, showTitle) => { + const { showTitle: _showTitle, ...original } = record; + expect(parseLinkRecord({ ...original, ...choice })?.showTitle).toBe( + showTitle, + ); + }, + ); + + it("repairs an incomplete record without losing its address", () => { + expect(parseLinkRecord({ id: record.id, url: record.url })).toEqual({ + ...record, + path: "", + detached: true, + createdAt: 0, + publishedAt: 0, + }); + }); + + it.each([ + ["path", false, { path: "", detached: true }], + ["showTitle", 1, { showTitle: true }], + ["createdAt", "today", { createdAt: 0 }], + ["publishedAt", "today", { publishedAt: 0 }], + ["expires", "never", { expires: null }], + ["maxViews", "unlimited", { maxViews: null }], + ["salt", 1, { salt: null }], + ["images", "yes", { images: true }], + ["detached", "yes", { detached: false }], + ] as const)("repairs an invalid %s", (field, value, repaired) => { + expect(parseLinkRecord({ ...record, [field]: value })).toEqual({ + ...record, + ...repaired, + }); + }); + + it("only requires id and url", () => { + for (const field of Object.keys(record)) { + const incomplete = { ...record }; + Reflect.deleteProperty(incomplete, field); + if (field === "id" || field === "url") { + expect(parseLinkRecord(incomplete), field).toBeNull(); + } else { + expect(parseLinkRecord(incomplete), field).toMatchObject({ + id: record.id, + url: record.url, + }); + } + } + }); +}); + +describe("detachedLinks", () => { + it("detaches an exact note and its folder without touching shared prefixes", () => { + const nested = { ...record, id: "nested", path: "a/b/sub/note.md" }; + const other = { ...record, id: "other", path: "a/bc/note.md" }; + expect(detachedLinks([record], record.path)).toEqual([ + { ...record, detached: true }, + ]); + expect(detachedLinks([record, nested, other], "a/b")).toEqual([ + { ...record, detached: true }, + { ...nested, detached: true }, + other, + ]); + expect(record.detached).toBe(false); + }); + + it("skips already detached records on detaches and renames", () => { + const detached = { ...record, detached: true }; + expect(detachedLinks([detached], "a/b")).toBeNull(); + expect(renamedLinks([detached], "a/b", "moved")).toBeNull(); + expect(detachedLinks([record, detached], "a/b")?.[1]).toBe(detached); + expect(renamedLinks([record, detached], "a/b", "moved")?.[1]).toBe( + detached, + ); + expect(detachedLinks([record], "elsewhere")).toBeNull(); + }); +}); + +describe("noteName", () => { + it("uses the last known filename without its markdown extension", () => { + expect(noteName(record)).toBe("note"); + expect(noteName({ ...record, path: "moved/Trip.md", detached: true })).toBe( + "Trip", + ); + }); +}); + +describe("renamedLinks", () => { + it("moves an exact note path and keeps every other field", () => { + const next = renamedLinks([record], record.path, "moved.md"); + + expect(next).toEqual([{ ...record, path: "moved.md" }]); + expect(record.path).toBe("a/b/note.md"); + }); + + it("moves a folder and all nested note paths", () => { + const nested = { ...record, id: "nested", path: "a/b/sub/note.md" }; + + expect(renamedLinks([record, nested], "a/b", "x/y")).toEqual([ + { ...record, path: "x/y/note.md" }, + { ...nested, path: "x/y/sub/note.md" }, + ]); + }); + + it("does not mistake a shared prefix for a folder boundary", () => { + const unrelated = { ...record, id: "other", path: "a/bc/note.md" }; + const next = renamedLinks([record, unrelated], "a/b", "moved"); + + expect(next).toEqual([{ ...record, path: "moved/note.md" }, unrelated]); + expect(next?.[1]).toBe(unrelated); + expect(renamedLinks([unrelated], "a/b", "moved")).toBeNull(); + }); + + it("returns null when nothing moved", () => { + expect(renamedLinks([record], "elsewhere", "moved")).toBeNull(); + expect(renamedLinks([], "a/b", "moved")).toBeNull(); + }); +}); + +describe("onRelay", () => { + it.each([relay.relayUrl, `${relay.relayUrl}/`])( + "accepts the same origin at %s", + (relayUrl) => { + expect(onRelay(record, { ...relay, relayUrl })).toBe(true); + }, + ); + + it("rejects another origin", () => { + expect( + onRelay(record, { ...relay, relayUrl: "https://other.example" }), + ).toBe(false); + }); + + it.each([ + { ...relay, relayUrl: "" }, + { ...relay, relaySecret: "" }, + { ...relay, relayUrl: "not a URL" }, + ])("rejects missing or invalid relay settings %#", (settings) => { + expect(onRelay(record, settings)).toBe(false); + }); + + it("rejects an invalid record URL", () => { + expect(onRelay({ ...record, url: "not a URL" }, relay)).toBe(false); + }); +}); diff --git a/packages/plugin/tests/links/sanitize.test.ts b/packages/plugin/tests/links/sanitize.test.ts new file mode 100644 index 00000000..d23c1705 --- /dev/null +++ b/packages/plugin/tests/links/sanitize.test.ts @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { URL as NodeURL } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { type SanitizeReport, sanitizeRendered } from "@/links/sanitize"; + +const fixture = readFileSync( + new NodeURL("./fixtures/rendered-note.html", import.meta.url), + "utf8", +); + +function render(html: string): HTMLDivElement { + const root = document.createElement("div"); + const parsed = new DOMParser().parseFromString(html, "text/html"); + root.append(...Array.from(parsed.body.childNodes)); + return root; +} + +function emptyParagraphs(root: HTMLElement): HTMLParagraphElement[] { + return Array.from(root.querySelectorAll("p")).filter( + (paragraph) => + paragraph.childElementCount === 0 && !paragraph.textContent?.trim(), + ); +} + +describe("sanitizeRendered on Obsidian output", () => { + let root: HTMLDivElement; + let report: SanitizeReport; + + beforeEach(() => { + root = render(fixture); + report = sanitizeRendered(root); + }); + + it("drops private properties and non-image embeds", () => { + expect(root.querySelector("pre.frontmatter")).toBeNull(); + expect(root.textContent).not.toContain("hunter2"); + expect(root.textContent).not.toContain("Secret frontmatter"); + expect(root.querySelector(".internal-embed")).toBeNull(); + expect(root.textContent).not.toContain("Other note body, private."); + expect(root.textContent).not.toContain("missing.png"); + expect(root.querySelector("audio")).toBeNull(); + expect(report.embedsDropped).toBe(3); + }); + + it("collects vault images without their private URLs", () => { + expect(report.images.map((image) => image.link)).toEqual([ + "img.png", + "img.png", + ]); + for (const { img } of report.images) { + expect(root.contains(img)).toBe(true); + expect(img.hasAttribute("src")).toBe(false); + expect(img.hasAttribute("alt")).toBe(false); + } + expect(report.images[1]?.img.getAttribute("width")).toBe("100"); + expect( + root.querySelector('img[alt="ext image"]')?.getAttribute("src"), + ).toBe("https://example.com/pic.png"); + }); + + it("unwraps vault links and turns tags into spans", () => { + expect(root.querySelector("a.internal-link")).toBeNull(); + expect(root.querySelector("a.tag")).toBeNull(); + expect(root.querySelectorAll("span.tag")).toHaveLength(1); + expect(root.querySelector("span.tag")?.textContent).toBe("#tag"); + const links = Array.from(root.querySelectorAll("p")).find((paragraph) => + paragraph.textContent?.startsWith("Links:"), + ); + expect(links?.textContent).toContain( + "Links: Other, an alias, Other > Section, text,", + ); + expect(links?.querySelectorAll("a")).toHaveLength(2); + }); + + it("keeps external links and footnotes", () => { + const external = Array.from(root.querySelectorAll("a.external-link")); + expect(external.map((anchor) => anchor.getAttribute("href"))).toEqual([ + "https://example.com/page", + "https://auto.example.com", + ]); + for (const anchor of external) { + expect(anchor.getAttribute("target")).toBe("_blank"); + expect(anchor.getAttribute("rel")).toBe("noopener nofollow"); + } + const footnotes = root.querySelectorAll('a[href^="#fn"]'); + expect(footnotes).toHaveLength(2); + expect(root.querySelector('a[href^="#fn-"]')).not.toBeNull(); + for (const anchor of Array.from(footnotes)) { + expect(anchor.hasAttribute("target")).toBe(false); + expect(anchor.hasAttribute("rel")).toBe(false); + } + expect(root.querySelector("section.footnotes")?.textContent).toContain( + "The footnote text.", + ); + }); + + it("keeps callouts, math, tables, code and task lists", () => { + const contents = root.querySelectorAll(".callout-content"); + expect(contents).toHaveLength(4); + for (const content of Array.from(contents)) { + expect(content.hasAttribute("style")).toBe(false); + } + expect(root.querySelector(".callout.is-collapsed")).not.toBeNull(); + expect(root.querySelectorAll(".callout-icon svg")).toHaveLength(4); + expect(root.querySelectorAll("mjx-container")).toHaveLength(2); + expect(root.querySelector("table")?.textContent).toContain("1"); + expect(root.querySelector("pre.language-ts code")?.textContent).toContain( + "const answer", + ); + const checkboxes = root.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes).toHaveLength(2); + for (const checkbox of Array.from(checkboxes)) { + expect(checkbox.hasAttribute("disabled")).toBe(true); + } + expect( + root.querySelectorAll('input[type="checkbox"][checked]'), + ).toHaveLength(1); + expect( + root.querySelector(".contains-task-list .is-checked"), + ).not.toBeNull(); + }); + + it("leaves guarded Mermaid as source and removes renderer artifacts", () => { + expect(root.querySelector(".mermaid-wrapper")).toBeNull(); + expect(root.querySelector("pre.language-mermaid")?.textContent).toContain( + "graph", + ); + expect(report.mermaidAsSource).toBe(1); + expect(root.querySelector("button")).toBeNull(); + expect(emptyParagraphs(root)).toEqual([]); + expect(root.querySelector(".node-insert-event")).toBeNull(); + expect(root.querySelector("[data-href]")).toBeNull(); + }); + + it("is idempotent even before vault images are inlined", () => { + const html = root.outerHTML; + expect(sanitizeRendered(root)).toEqual({ + images: [], + embedsDropped: 0, + imagesDropped: 0, + mermaidAsSource: 0, + }); + expect(root.outerHTML).toBe(html); + for (const { img } of report.images) expect(root.contains(img)).toBe(true); + }); +}); + +describe("sanitizeRendered edge cases", () => { + it("removes executable elements, handlers and Obsidian attributes, including on the root", () => { + const root = + render(`

safe

+ text + `); + root.setAttribute("onclick", "alert(1)"); + root.className = "node-insert-event"; + sanitizeRendered(root); + expect( + root.querySelector( + "audio, video, iframe, object, embed, script, style, button", + ), + ).toBeNull(); + expect(root.textContent).not.toContain("alert(1)"); + expect(root.hasAttribute("onclick")).toBe(false); + expect(root.hasAttribute("class")).toBe(false); + const paragraph = root.querySelector("p"); + expect(paragraph?.getAttributeNames()).toEqual([]); + const span = root.querySelector("span"); + expect(span?.className).toBe("kept"); + expect(span?.getAttribute("data-heading")).toBe("Title"); + expect(span?.getAttribute("data-custom")).toBe("value"); + expect(span?.getAttribute("dir")).toBe("rtl"); + expect(span?.hasAttribute("onmouseover")).toBe(false); + }); + + it("leaves rendered Mermaid SVG untouched", () => { + const root = render( + '
', + ); + const wrapper = root.querySelector(".mermaid-wrapper"); + const html = wrapper?.outerHTML; + expect(sanitizeRendered(root).mermaidAsSource).toBe(0); + expect(root.querySelector(".mermaid-wrapper")).toBe(wrapper); + expect(wrapper?.outerHTML).toBe(html); + }); + + it("drops unsafe image sources outside embeds and keeps HTTPS and image data", () => { + const root = render( + '', + ); + expect(sanitizeRendered(root)).toEqual({ + images: [], + embedsDropped: 0, + imagesDropped: 4, + mermaidAsSource: 0, + }); + expect( + Array.from(root.querySelectorAll("img"), (img) => + img.getAttribute("src"), + ), + ).toEqual(["HTTPS://example.com/x.png", "DATA:IMAGE/png;base64,aGVsbG8="]); + }); + + it("unwraps non-web links without changing allowed external links", () => { + const root = render( + '

script vault relative missing internal

httpmailsection', + ); + const external = Array.from( + root.querySelectorAll('a[href^="http://"], a[href^="mailto:"]'), + (anchor) => anchor.outerHTML, + ); + sanitizeRendered(root); + expect(root.querySelector("p")?.textContent).toBe( + "script vault relative missing internal", + ); + expect(root.querySelector("p a")).toBeNull(); + expect( + Array.from( + root.querySelectorAll('a[href^="http://"], a[href^="mailto:"]'), + (anchor) => anchor.outerHTML, + ), + ).toEqual(external); + expect( + root.querySelector('a[href="#section"]')?.getAttributeNames(), + ).toEqual(["href"]); + }); + + it("keeps allowed link schemes regardless of case", () => { + const root = render( + 'webmail', + ); + sanitizeRendered(root); + expect(root.querySelectorAll("a")).toHaveLength(2); + }); + + it("does not collect images from dropped note transclusions", () => { + const root = render( + '
localmissing', + ); + const report = sanitizeRendered(root); + expect(report.embedsDropped).toBe(2); + expect(report.images.map((image) => image.link)).toEqual([""]); + expect(root.querySelectorAll("img")).toHaveLength(1); + expect(root.querySelector(".internal-embed")).toBeNull(); + }); + + it("keeps an image's own description but not the vault path Obsidian puts in alt", () => { + const root = render( + 'Private/Client/logo.pngb.pngOur logo', + ); + sanitizeRendered(root); + expect( + Array.from(root.querySelectorAll("img"), (img) => + img.getAttribute("alt"), + ), + ).toEqual([null, null, "Our logo"]); + }); + + it("removes whitespace paragraphs but preserves paragraphs with elements", () => { + const root = render("

\n\t


text

"); + sanitizeRendered(root); + expect(emptyParagraphs(root)).toEqual([]); + expect(root.querySelectorAll("p")).toHaveLength(2); + expect(root.querySelector("p br")).not.toBeNull(); + }); +}); diff --git a/packages/plugin/tests/links/shared-links.test.ts b/packages/plugin/tests/links/shared-links.test.ts new file mode 100644 index 00000000..63e079d3 --- /dev/null +++ b/packages/plugin/tests/links/shared-links.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; + +import { noteLinks, noteLinksText } from "@/links/note-links"; +import type { LinkRecord } from "@/links/record"; +import { SharedLinks } from "@/links/shared-links"; +import type { PluginHost } from "@/plugin/host"; + +const PUBLISHED = 1_760_000_000_000; + +function link(id: string, path: string, extra?: Partial) { + return { + id, + url: `https://relay.example/s/${id}#key`, + path, + showTitle: true, + detached: false, + createdAt: PUBLISHED, + publishedAt: PUBLISHED, + expires: null, + maxViews: null, + salt: null, + images: true, + ...extra, + } satisfies LinkRecord; +} + +function store(links: LinkRecord[]) { + const settings = { links }; + const save = vi.fn(async () => {}); + const listener = vi.fn(); + const shared = new SharedLinks(() => settings, save); + shared.subscribe(listener); + return { settings, save, listener, shared }; +} + +describe("SharedLinks", () => { + it("detaches a note or folder and excludes its links from of", async () => { + const a = link("a", "d/x.md"); + const b = link("b", "d/sub/y.md"); + const other = link("c", "different/z.md"); + const { shared, save, listener } = store([a, b, other]); + await shared.detach(a.path); + expect(shared.of(a.path)).toEqual([]); + expect(shared.all()).toEqual([{ ...a, detached: true }, b, other]); + await shared.detach("d"); + expect(shared.of(b.path)).toEqual([]); + expect(shared.all()).toEqual([ + { ...a, detached: true }, + { ...b, detached: true }, + other, + ]); + await shared.detach("d"); + expect(save).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(2); + }); + it("saves and tells subscribers on every change", async () => { + const { settings, save, listener, shared } = store([link("a", "x.md")]); + + await shared.add(link("b", "x.md")); + await shared.published("a", PUBLISHED + 5); + await shared.remove("b"); + + expect(settings.links).toEqual([ + link("a", "x.md", { publishedAt: PUBLISHED + 5 }), + ]); + expect(save).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it("moves links with their note and ignores unrelated renames", async () => { + const { settings, save, listener, shared } = store([link("a", "d/x.md")]); + + await shared.move("other.md", "else.md"); + expect(save).not.toHaveBeenCalled(); + + await shared.move("d", "e"); + expect(settings.links[0]?.path).toBe("e/x.md"); + expect(shared.of("e/x.md")).toHaveLength(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("tells subscribers when a link expires, while anyone listens", () => { + vi.useFakeTimers({ now: PUBLISHED }); + try { + const soon = link("a", "x.md", { expires: PUBLISHED / 1000 + 60 }); + const { listener } = store([soon]); + vi.advanceTimersByTime(60_000); + expect(listener).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + const later = link("b", "x.md", { expires: PUBLISHED / 1000 + 600 }); + const idle = new SharedLinks( + () => ({ links: [later] }), + async () => {}, + ); + const unsubscribe = idle.subscribe(() => {}); + expect(vi.getTimerCount()).toBe(1); + unsubscribe(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("reads the settings object current at each call", async () => { + let settings = { links: [link("a", "x.md")] }; + const shared = new SharedLinks( + () => settings, + async () => {}, + ); + settings = { links: [] }; + expect(shared.all()).toEqual([]); + await shared.add(link("b", "y.md")); + expect(settings.links).toHaveLength(1); + }); +}); + +describe("noteLinks", () => { + function host(links: LinkRecord[], mtime: number) { + const { shared } = store(links); + return { + sharedLinks: shared, + app: { vault: { getFileByPath: () => ({ stat: { mtime } }) } }, + } as unknown as PluginHost; + } + + it("is null for a note without live links", () => { + const expired = link("a", "x.md", { expires: 1 }); + expect(noteLinks(host([expired], PUBLISHED), "x.md")).toBeNull(); + expect(noteLinks(host([], PUBLISHED), "x.md")).toBeNull(); + }); + + it("is stale once the note changed after a publish", () => { + const links = [link("a", "x.md"), link("b", "x.md")]; + expect(noteLinks(host(links, PUBLISHED), "x.md")?.stale).toBe(false); + const changed = noteLinks(host(links, PUBLISHED + 1), "x.md"); + expect(changed).toMatchObject({ stale: true }); + expect(changed && noteLinksText(changed)).toBe( + "Shared by 2 links. Changed since it was shared: click to update.", + ); + }); + + it("names the expiry of a single link", () => { + const links = noteLinks(host([link("a", "x.md")], PUBLISHED), "x.md"); + expect(links && noteLinksText(links)).toBe( + "Shared by link. Never expires. Click to manage.", + ); + }); +}); diff --git a/packages/plugin/tests/settings/security-section.test.ts b/packages/plugin/tests/settings/security-section.test.ts new file mode 100644 index 00000000..2910beb0 --- /dev/null +++ b/packages/plugin/tests/settings/security-section.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginHost } from "@/plugin/host"; +import { isStorageConfigured } from "@/settings/model"; +import { renderSecuritySection } from "@/settings/sections/security"; +import { askNewPassphrase, notifyInfo, reportError } from "@/ui"; + +vi.mock("@/settings/model", () => ({ isStorageConfigured: vi.fn(() => true) })); +vi.mock("@/crypto/passphrase-cache", () => ({ + clearCachedPassphrase: vi.fn(), +})); +vi.mock("@/ui", () => ({ + askNewPassphrase: vi.fn(), + notifyInfo: vi.fn(), + notifyError: vi.fn(), + reportError: vi.fn(), +})); +vi.mock("obsidian", () => ({ + Setting: class { + name = ""; + desc = ""; + buttons: TestButton[] = []; + constructor() { + rows.push(this); + } + setName(name: string) { + this.name = name; + return this; + } + setDesc(desc: string) { + this.desc = desc; + return this; + } + setHeading() { + return this; + } + addToggle() { + return this; + } + addButton(build: (button: TestButton) => void) { + const button = new TestButton(); + build(button); + this.buttons.push(button); + return this; + } + }, +})); + +const rows: { name: string; desc: string; buttons: TestButton[] }[] = []; + +class TestButton { + text = ""; + disabled = false; + click: () => Promise = async () => {}; + setButtonText(text: string) { + this.text = text; + return this; + } + setDestructive() { + return this; + } + setDisabled(disabled: boolean) { + this.disabled = disabled; + return this; + } + onClick(click: () => Promise) { + this.click = click; + return this; + } +} + +function form(loaded = false) { + const passphrase = { + has: () => loaded, + prompt: vi.fn(async () => true), + forget: vi.fn(async () => {}), + rotate: vi.fn(async () => 2), + }; + const rerender = vi.fn(); + renderSecuritySection( + {} as HTMLElement, + { settings: {}, passphrase } as unknown as PluginHost, + rerender, + ); + return { passphrase, rerender }; +} + +function button(text: string) { + const found = rows.flatMap((row) => row.buttons).find((b) => b.text === text); + if (!found) throw new Error(`No ${text} button`); + return found; +} + +describe("passphrase settings", () => { + beforeEach(() => { + rows.length = 0; + vi.clearAllMocks(); + vi.mocked(isStorageConfigured).mockReturnValue(true); + vi.mocked(askNewPassphrase).mockResolvedValue("new-passphrase"); + }); + + it("distinguishes an unloaded session from a missing saved passphrase", () => { + form(); + const row = rows.find((row) => row.name === "Passphrase on this device"); + expect(row?.desc).toContain("Not loaded for this session"); + expect(row?.desc).not.toContain("not set"); + expect(row?.desc).toContain("does not change the vault passphrase"); + }); + + it("can forget a saved passphrase before it is loaded", async () => { + const { passphrase, rerender } = form(); + const forget = button("Forget"); + expect(forget.disabled).toBe(false); + await forget.click(); + expect(passphrase.forget).toHaveBeenCalledOnce(); + expect(rerender).toHaveBeenCalledOnce(); + }); + + it("entering a passphrase does not rotate the vault key", async () => { + const { passphrase, rerender } = form(true); + await button("Enter…").click(); + expect(passphrase.prompt).toHaveBeenCalledWith(true); + expect(passphrase.rotate).not.toHaveBeenCalled(); + expect(rerender).toHaveBeenCalledOnce(); + }); + + it("checks current storage settings before asking for passphrases", async () => { + const { passphrase } = form(); + vi.mocked(isStorageConfigured).mockReturnValue(false); + await button("Change…").click(); + expect(passphrase.prompt).not.toHaveBeenCalled(); + expect(askNewPassphrase).not.toHaveBeenCalled(); + expect(notifyInfo).toHaveBeenCalledWith( + "Configure a storage backend first.", + ); + }); + + it("asks for the current passphrase before a new one and disables duplicate clicks", async () => { + const { passphrase } = form(); + vi.mocked(askNewPassphrase).mockImplementation(async () => { + expect(passphrase.prompt).toHaveBeenCalledWith(false); + expect(button("Change…").disabled).toBe(true); + return "new-passphrase"; + }); + await button("Change…").click(); + expect(passphrase.rotate).toHaveBeenCalledWith("new-passphrase"); + expect(button("Change…").disabled).toBe(false); + }); + + it("cancelling the current passphrase stops the change", async () => { + const { passphrase } = form(); + passphrase.prompt.mockResolvedValue(false); + await button("Change…").click(); + expect(askNewPassphrase).not.toHaveBeenCalled(); + expect(passphrase.rotate).not.toHaveBeenCalled(); + expect(button("Change…").disabled).toBe(false); + }); + + it("refreshes the loaded status even when the new passphrase is cancelled", async () => { + const { passphrase, rerender } = form(); + vi.mocked(askNewPassphrase).mockResolvedValue(null); + await button("Change…").click(); + expect(passphrase.rotate).not.toHaveBeenCalled(); + expect(rerender).toHaveBeenCalledOnce(); + }); + + it("reports rotation failures and re-enables the action", async () => { + const { passphrase } = form(); + const error = new Error("offline"); + passphrase.rotate.mockRejectedValue(error); + await button("Change…").click(); + expect(reportError).toHaveBeenCalledWith(error); + expect(button("Change…").disabled).toBe(false); + }); +}); diff --git a/packages/plugin/tests/settings/transfer-controller.test.ts b/packages/plugin/tests/settings/transfer-controller.test.ts index 21dd3e55..9d00c713 100644 --- a/packages/plugin/tests/settings/transfer-controller.test.ts +++ b/packages/plugin/tests/settings/transfer-controller.test.ts @@ -2,10 +2,12 @@ import { InMemoryAdapter } from "@tests/helpers/in-memory-adapter"; import type { App } from "obsidian"; import { describe, expect, it, vi } from "vitest"; import { PassphraseManager } from "@/core/passphrase-manager"; +import type { LinkRecord } from "@/links"; import { type MdsyncSettings, mergeSettings } from "@/settings/model"; import { createSettingsTransferUrl } from "@/settings/transfer"; import { SettingsTransferController } from "@/settings/transfer-controller"; import { defaultS3Config } from "@/storage"; +import { confirmSettingsTransferImport } from "@/ui"; vi.mock("@/ui", () => ({ confirmSettingsTransferImport: vi.fn(async () => true), @@ -73,3 +75,47 @@ describe("settings transfer passphrase cache", () => { }, ); }); + +describe("settings import", () => { + it("keeps a link published while the import was being confirmed", async () => { + const token = await createSettingsTransferUrl(mergeSettings({}), "pass"); + const settings = mergeSettings({}); + const adapter = new InMemoryAdapter().asDataAdapter(); + const manager = new PassphraseManager( + async () => "pass", + adapter, + ".obsidian", + settings, + ); + const link: LinkRecord = { + id: "one", + url: "https://relay.example/s/one", + path: "note.md", + showTitle: true, + detached: false, + createdAt: 1, + publishedAt: 1, + expires: null, + maxViews: null, + salt: null, + images: false, + }; + vi.mocked(confirmSettingsTransferImport).mockImplementationOnce( + async () => { + settings.links = [link]; + return true; + }, + ); + const transfer = new SettingsTransferController({ + app: {} as App, + settings, + passphrase: manager, + saveSettings: async () => {}, + onSettingsReplaced: () => {}, + }); + + expect(await transfer.importFrom(token)).toBe(true); + expect(settings.links).toEqual([link]); + manager.dispose(); + }); +}); diff --git a/packages/plugin/tests/storage/link-broker.test.ts b/packages/plugin/tests/storage/link-broker.test.ts new file mode 100644 index 00000000..16a7c93a --- /dev/null +++ b/packages/plugin/tests/storage/link-broker.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createLink, + linkStatus, + type NewLink, + replaceLink, + revokeLink, +} from "@/storage/adapters/link-broker"; +import { StorageHttpError } from "@/storage/adapters/util"; +import { StorageRequestError } from "@/storage/types"; + +interface Recorded { + url: string; + method: string; + headers: Record; + body?: string | ArrayBuffer; + throw: boolean; +} + +interface Reply { + status: number; + text?: string; +} + +const requests: Recorded[] = []; +let replies: (Reply | Error)[] = []; + +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal>()), + requestUrl: (params: Recorded) => { + requests.push(params); + const reply = replies.shift() ?? { status: 200 }; + if (reply instanceof Error) return Promise.reject(reply); + const text = reply.text ?? ""; + return Promise.resolve({ + status: reply.status, + text, + arrayBuffer: new ArrayBuffer(0), + json: text.startsWith("{") ? JSON.parse(text) : {}, + headers: {}, + }); + }, +})); + +const admin = { relayUrl: "https://relay.example", secret: "secret" }; +const ID = "abcdefghijklmnopqrstuv"; +const URL = `https://relay.example/link/${ID}`; +const sealed = new Uint8Array([1, 2, 3]); +const options: NewLink = { + maxViews: null, + ttl: null, + gate: "G".repeat(43), + salt: null, +}; +const actions = { + put: () => createLink(admin, ID, sealed, options), + status: () => linkStatus(admin, ID), + revoke: () => revokeLink(admin, ID), +}; + +beforeEach(() => { + requests.length = 0; + replies = []; +}); + +describe("the relay's share links", () => { + it("creates with limits, expiry, protection and only the sealed bytes", async () => { + const bytes = new Uint8Array([99, 1, 2, 3, 99]).subarray(1, 4); + + replies = [{ status: 200, text: '{"stored":true,"expires":2000000000}' }]; + expect( + await createLink(admin, ID, bytes, { + maxViews: 5, + ttl: 3600, + gate: "gate", + salt: "salt", + }), + ).toBe(2_000_000_000); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + url: `${URL}?maxViews=5&ttl=3600`, + method: "PUT", + headers: { + "X-Mdsync-Admin": "secret", + "Content-Type": "application/octet-stream", + "X-Mdsync-Gate": "gate", + "X-Mdsync-Salt": "salt", + }, + throw: false, + }); + expect(requests[0]?.body).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(requests[0]?.body as ArrayBuffer)).toEqual(sealed); + }); + + it("accepts an explicit null expiry and omits limits and salt, but sends its gate", async () => { + replies = [{ status: 200, text: '{"expires":null}' }]; + expect(await actions.put()).toBeNull(); + expect(requests).toHaveLength(1); + + expect(requests[0]).toMatchObject({ + url: URL, + method: "PUT", + }); + expect(requests[0]?.headers).toEqual({ + "X-Mdsync-Admin": "secret", + "Content-Type": "application/octet-stream", + "X-Mdsync-Gate": options.gate, + }); + }); + + it("replaces a standing link using only its gate", async () => { + await replaceLink(admin, ID, sealed, options.gate); + + expect(requests[0]).toMatchObject({ + url: `${URL}?update=1`, + headers: { "X-Mdsync-Gate": options.gate }, + method: "PUT", + }); + expect(new Uint8Array(requests[0]?.body as ArrayBuffer)).toEqual(sealed); + }); + + it("updates without limits using only the update query", async () => { + await replaceLink(admin, ID, sealed, options.gate); + expect(requests[0]?.headers).not.toHaveProperty("X-Mdsync-Salt"); + + expect(requests[0]?.url).toBe(`${URL}?update=1`); + }); + + it.each([false, true])( + "parses status with protection %s", + async (protectedLink) => { + const status = { + views: 2, + maxViews: protectedLink ? 5 : null, + expires: protectedLink ? 2_000_000_000 : null, + protected: protectedLink, + size: 123, + }; + replies = [{ status: 200, text: JSON.stringify(status) }]; + + expect(await actions.status()).toEqual(status); + expect(requests[0]).toMatchObject({ + url: `${URL}/status`, + method: "GET", + headers: { "X-Mdsync-Admin": "secret" }, + body: undefined, + throw: false, + }); + }, + ); + + it("returns null when status says the link is gone", async () => { + replies = [{ status: 404, text: '{"error":"gone"}' }]; + + expect(await actions.status()).toBeNull(); + }); + + it.each(["put", "status", "revoke"] as const)( + "asks to redeploy an older relay on %s", + async (action) => { + for (const text of ["Not Found", '{"error":"not_found"}']) { + replies = [{ status: 404, text }]; + const refused = actions[action](); + + await expect(refused).rejects.toBeInstanceOf(StorageRequestError); + await expect(refused).rejects.toMatchObject({ + userMessage: + "This relay is out of date for share links. Redeploy it with the Deploy Relay workflow.", + }); + } + }, + ); + + it("revokes a creation without an expires key and refuses the older relay", async () => { + replies = [{ status: 200, text: '{"stored":true}' }]; + const refused = createLink(admin, ID, sealed, { ...options, ttl: 3600 }); + await expect(refused).rejects.toBeInstanceOf(StorageRequestError); + await expect(refused).rejects.toMatchObject({ + userMessage: + "This relay is out of date for share links. Redeploy it with the Deploy Relay workflow.", + }); + expect(requests.map(({ method, url }) => ({ method, url }))).toEqual([ + { method: "PUT", url: `${URL}?ttl=3600` }, + { method: "DELETE", url: URL }, + ]); + }); + + it("revokes with an idempotent DELETE", async () => { + replies = [ + { status: 200, text: '{"revoked":true}' }, + { status: 200, text: '{"revoked":true}' }, + ]; + + await actions.revoke(); + await actions.revoke(); + + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request).toMatchObject({ + url: URL, + method: "DELETE", + headers: { "X-Mdsync-Admin": "secret" }, + body: undefined, + throw: false, + }); + } + }); + + it.each(["put", "status", "revoke"] as const)( + "names a refused admin secret on %s", + async (action) => { + replies = [{ status: 401, text: '{"error":"unauthorized"}' }]; + + await expect(actions[action]()).rejects.toMatchObject({ + userMessage: + "The relay did not accept its secret. Check the relay settings.", + }); + }, + ); + + it("explains an oversized note", async () => { + replies = [{ status: 413, text: '{"error":"too_large"}' }]; + + await expect(actions.put()).rejects.toMatchObject({ + userMessage: "This note is too large to share as a link.", + }); + }); + + it("explains a mismatched gate on replacement", async () => { + replies = [{ status: 403, text: '{"error":"gate"}' }]; + await expect( + replaceLink(admin, ID, sealed, options.gate), + ).rejects.toMatchObject({ + userMessage: "That is not this link's passphrase.", + }); + }); + + it("asks for a new link when an update has ended", async () => { + replies = [{ status: 404, text: '{"error":"gone"}' }]; + + await expect( + replaceLink(admin, ID, sealed, options.gate), + ).rejects.toMatchObject({ + userMessage: "This link has ended. Create a new one.", + }); + }); + + it.each([409, 500])("keeps HTTP %s as an HTTP error", async (status) => { + replies = [{ status, text: '{"error":"exists"}' }]; + const refused = actions.put(); + + await expect(refused).rejects.toBeInstanceOf(StorageHttpError); + await expect(refused).rejects.toMatchObject({ status }); + }); + + it("propagates a network failure unchanged", async () => { + const failure = new Error("Network failure"); + replies = [failure]; + + await expect(actions.put()).rejects.toBe(failure); + }); + + it("strips the relay URL's trailing slash", async () => { + replies = [{ status: 200, text: '{"expires":null}' }]; + await createLink( + { ...admin, relayUrl: `${admin.relayUrl}/` }, + ID, + sealed, + options, + ); + + expect(requests[0]?.url).toBe(URL); + }); +}); diff --git a/packages/plugin/tests/stubs/obsidian.ts b/packages/plugin/tests/stubs/obsidian.ts index 2e2415eb..2b8d2606 100644 --- a/packages/plugin/tests/stubs/obsidian.ts +++ b/packages/plugin/tests/stubs/obsidian.ts @@ -32,6 +32,10 @@ export function normalizePath(path: string): string { export function setIcon(_el: unknown, _icon: string): void {} +export function loadMathJax(): Promise { + return Promise.resolve(); +} + class Stub {} export class Notice extends Stub {} diff --git a/packages/plugin/tests/sync/keyfile.test.ts b/packages/plugin/tests/sync/keyfile.test.ts index b29c685f..70efc781 100644 --- a/packages/plugin/tests/sync/keyfile.test.ts +++ b/packages/plugin/tests/sync/keyfile.test.ts @@ -102,7 +102,7 @@ describe("passphrase strength", () => { it("opens the key with a passphrase typed in another Unicode form", async () => { const storage = new FakeStorage(); - const composed = "пароль на йоту длиннее"; + const composed = "mot de passe déjà plus long"; const { contentKey } = await resolveContentKey(storage, composed); const blob = await encryptBytes(contentKey, new Uint8Array([1])); diff --git a/packages/plugin/tests/ui/file-explorer-decorations.test.ts b/packages/plugin/tests/ui/file-explorer-decorations.test.ts index 36dbfc53..4fafbad3 100644 --- a/packages/plugin/tests/ui/file-explorer-decorations.test.ts +++ b/packages/plugin/tests/ui/file-explorer-decorations.test.ts @@ -1,13 +1,29 @@ -import { ALEX, SAM } from "@tests/helpers/explorer-host"; -import { describe, expect, it, vi } from "vitest"; +import { ALEX, host, SAM } from "@tests/helpers/explorer-host"; +import { Platform, setIcon } from "obsidian"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + import type { PluginHost } from "@/plugin/host"; +import { badgeActivation } from "@/ui/explorer/file-explorer-badges"; import { type PathDecoration, renderDecoration, sameDecoration, } from "@/ui/explorer/file-explorer-decorations"; import type { ShareMark } from "@/ui/explorer/file-explorer-marks"; +import { openManageLinks } from "@/ui/links"; + +vi.mock("@/ui/links", () => ({ openManageLinks: vi.fn() })); +vi.mock("obsidian", async (original) => ({ + ...(await original()), + setIcon: vi.fn(), +})); +const PUBLISHED = { + path: "notes/published.md", + count: 1, + stale: false, + text: "Shared by link. Click to manage.", +}; const SHARE: ShareMark = { root: "Team", kind: "owned", here: 1 }; describe("comparing decorations", () => { @@ -18,6 +34,7 @@ describe("comparing decorations", () => { skipped: "Not synced", unseen: { count: 1, file: "Team/a.md" }, share: SHARE, + published: PUBLISHED, people: [ALEX], }; const differing: [string, PathDecoration][] = [ @@ -37,6 +54,20 @@ describe("comparing decorations", () => { { ...full, share: { ...SHARE, root: "Other" } }, ], ["a dropped share", { ...full, share: undefined }], + [ + "a stale publication", + { ...full, published: { ...PUBLISHED, stale: true } }, + ], + ["another link count", { ...full, published: { ...PUBLISHED, count: 2 } }], + [ + "another tooltip", + { ...full, published: { ...PUBLISHED, text: "Updated" } }, + ], + [ + "another note", + { ...full, published: { ...PUBLISHED, path: "other.md" } }, + ], + ["a revoked publication", { ...full, published: undefined }], ["a person gone idle", { ...full, people: [{ ...ALEX, idle: true }] }], ["a renamed person", { ...full, people: [{ ...ALEX, name: "Al" }] }], [ @@ -56,6 +87,7 @@ describe("comparing decorations", () => { const copy: PathDecoration = { ...full, share: { ...SHARE }, + published: { ...PUBLISHED }, unseen: { count: 1, file: "Team/a.md" }, people: [{ ...ALEX }], }; @@ -78,3 +110,116 @@ describe("comparing decorations", () => { expect(addClass).not.toHaveBeenCalledWith("mdsync-has-path-badge"); }); }); + +class BadgeElement { + readonly classes = new Set(); + readonly children: BadgeElement[] = []; + private attributes: Record = {}; + + constructor(private readonly parent?: BadgeElement) {} + + createSpan({ + cls, + attr = {}, + }: { + cls: string; + attr?: Record; + }) { + const child = new BadgeElement(this); + for (const name of cls.split(" ")) child.addClass(name); + child.attributes = attr; + this.children.push(child); + return child; + } + + addClass(name: string) { + this.classes.add(name); + } + + getAttribute(name: string) { + return this.attributes[name] ?? null; + } + + closest(selector: string): BadgeElement | null { + if (this.classes.has(selector.slice(1))) return this; + return this.parent?.closest(selector) ?? null; + } +} + +function publishedBadge(stale = false) { + const target = new BadgeElement(); + const plugin = host([], []); + renderDecoration( + target as unknown as HTMLElement, + { published: { ...PUBLISHED, stale } }, + plugin, + ); + return { target, plugin, badge: target.children[0] as BadgeElement }; +} + +describe("published badges", () => { + beforeEach(() => { + vi.stubGlobal("Element", BadgeElement); + vi.mocked(openManageLinks).mockClear(); + vi.mocked(setIcon).mockClear(); + }); + + afterEach(() => { + Platform.isMobile = false; + vi.unstubAllGlobals(); + }); + + it.each([false, true])("renders an accessible globe, stale %s", (stale) => { + const { target, badge } = publishedBadge(stale); + + expect(target.classes.has("mdsync-has-path-badge")).toBe(true); + expect(badge.classes.has("mdsync-path-badge")).toBe(true); + expect(badge.classes.has("mdsync-published-badge")).toBe(true); + expect(badge.classes.has("is-stale")).toBe(stale); + expect(badge.getAttribute("role")).toBe("button"); + expect(badge.getAttribute("tabindex")).toBe("0"); + expect(badge.getAttribute("aria-label")).toBe(PUBLISHED.text); + expect(badge.getAttribute("data-path")).toBe(PUBLISHED.path); + expect(setIcon).toHaveBeenCalledWith(badge.children[0], "globe"); + }); + + it.each([ + [false, "click", undefined], + [false, "keydown", "Enter"], + [false, "keydown", " "], + [true, "click", undefined], + ] as const)("opens management on mobile %s, %s %s", (mobile, type, key) => { + Platform.isMobile = mobile; + const { plugin, badge } = publishedBadge(); + const event = { + type, + ...(key ? { key } : {}), + target: badge.children[0], + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + badgeActivation(plugin)(event as unknown as MouseEvent | KeyboardEvent); + + expect(openManageLinks).toHaveBeenCalledWith(plugin, PUBLISHED.path); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("leaves unrelated keys alone", () => { + const { plugin, badge } = publishedBadge(); + const event = { + type: "keydown", + key: "ArrowDown", + target: badge, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + badgeActivation(plugin)(event as unknown as KeyboardEvent); + + expect(openManageLinks).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin/tests/ui/file-explorer-indicators.test.ts b/packages/plugin/tests/ui/file-explorer-indicators.test.ts index 847132b2..3c4bd500 100644 --- a/packages/plugin/tests/ui/file-explorer-indicators.test.ts +++ b/packages/plugin/tests/ui/file-explorer-indicators.test.ts @@ -1,6 +1,8 @@ -import { ALEX, host } from "@tests/helpers/explorer-host"; -import type { Plugin } from "obsidian"; +import { ALEX, host, LINK } from "@tests/helpers/explorer-host"; +import type { Plugin, TAbstractFile } from "obsidian"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SharedLinks } from "@/links"; import type { PluginHost } from "@/plugin/host"; import type { SyncController } from "@/sync/controller"; import { type Space, VAULT_SPACE } from "@/sync/space"; @@ -40,31 +42,43 @@ function tree() { const ignoreEvents = listeners(); const peopleEvents = listeners(); const fileItems = Object.fromEntries( - [IGNORED, LATER, ALEX.note as string].map((path) => [ + [IGNORED, LATER, LINK.path, ALEX.note as string].map((path) => [ path, { selfEl: new FakeElement(path) }, ]), ); const view = { containerEl: new FakeElement(), fileItems }; const base = host(SPACES, [ALEX]); + base.settings.ignoreSymlinks = false; + const cleanup: (() => void)[] = []; + const vaultEvents = new Map void>(); + const getFileByPath = vi.fn(base.app.vault.getFileByPath); + const registerDomEvent = vi.fn(); const plugin = Object.assign(base, { ignoreState: { ignoredPaths, subscribe: ignoreEvents.subscribe }, realtime: { people: { ...base.realtime.people, subscribe: peopleEvents.subscribe }, }, - settings: { ...base.settings, ignoreSymlinks: false }, app: { ...base.app, - vault: { ...base.app.vault, adapter: {}, on: () => ({}) }, + vault: { + ...base.app.vault, + getFileByPath, + adapter: {}, + on: (name: string, listener: (file: TAbstractFile) => void) => { + vaultEvents.set(name, listener); + return {}; + }, + }, workspace: { getLeavesOfType: () => [{ view }], on: () => ({}), onLayoutReady: () => {}, }, }, - register: () => {}, + register: (callback: () => void) => cleanup.push(callback), registerEvent: () => {}, - registerDomEvent: () => {}, + registerDomEvent, }) as unknown as Plugin & PluginHost; const statuses = new Map(); const controller = { @@ -74,7 +88,20 @@ function tree() { } as unknown as SyncController; const handle = registerFileExplorerIndicators(plugin, controller); handle.refresh(true); - return { ignored, ignoredPaths, ignoreEvents, peopleEvents }; + return { + plugin, + ignored, + ignoredPaths, + ignoreEvents, + peopleEvents, + getFileByPath, + registerDomEvent, + modify: (path: string) => + vaultEvents.get("modify")?.({ path } as TAbstractFile), + dispose: () => { + for (const callback of cleanup) callback(); + }, + }; } function paintedPaths(): string[] { @@ -107,6 +134,68 @@ afterEach(() => { }); describe("the tree's indicators", () => { + it("captures badge clicks and key activation before the tree handles them", () => { + const { registerDomEvent } = tree(); + + for (const type of ["click", "keydown"]) { + expect(registerDomEvent).toHaveBeenCalledWith( + document, + type, + expect.any(Function), + { capture: true }, + ); + } + }); + + it("repaints after link changes without rebuilding the base", async () => { + const { plugin, ignoredPaths } = tree(); + frame(); + vi.mocked(renderDecoration).mockClear(); + + await plugin.sharedLinks.add(LINK); + frame(); + + expect(paintedPaths()).toEqual([LINK.path]); + expect(ignoredPaths).toHaveBeenCalledTimes(1); + }); + + it("unsubscribes from link changes on teardown", () => { + const unsubscribe = vi.fn(); + const subscribe = vi + .spyOn(SharedLinks.prototype, "subscribe") + .mockReturnValue(unsubscribe); + const { dispose } = tree(); + subscribe.mockRestore(); + + dispose(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("repaints stale notes only for modifications of linked paths", async () => { + const { plugin, modify, getFileByPath } = tree(); + await plugin.sharedLinks.add(LINK); + frame(); + vi.mocked(renderDecoration).mockClear(); + getFileByPath.mockClear(); + + modify(IGNORED); + frame(); + expect(getFileByPath).not.toHaveBeenCalled(); + + getFileByPath.mockReturnValue({ + path: LINK.path, + stat: { mtime: LINK.publishedAt + 1 }, + } as ReturnType); + modify(LINK.path); + frame(); + + expect(paintedPaths()).toEqual([LINK.path]); + expect(vi.mocked(renderDecoration).mock.calls[0]?.[1]).toMatchObject({ + published: { stale: true }, + }); + }); + it("paint ignored files on the first frame", () => { tree(); diff --git a/packages/plugin/tests/ui/file-explorer-marks.test.ts b/packages/plugin/tests/ui/file-explorer-marks.test.ts index 80b6d89f..d7ae3787 100644 --- a/packages/plugin/tests/ui/file-explorer-marks.test.ts +++ b/packages/plugin/tests/ui/file-explorer-marks.test.ts @@ -1,8 +1,8 @@ -import { ALEX, host, LAPTOP, SAM } from "@tests/helpers/explorer-host"; +import { ALEX, host, LAPTOP, LINK, SAM } from "@tests/helpers/explorer-host"; import { describe, expect, it, vi } from "vitest"; import { type Space, VAULT_SPACE } from "@/sync/space"; -import { presenceMarks } from "@/ui/explorer/file-explorer-marks"; +import { presenceMarks, shareMarks } from "@/ui/explorer/file-explorer-marks"; describe("presence in the file explorer", () => { const spaces: Space[] = [ @@ -54,6 +54,37 @@ describe("presence in the file explorer", () => { expect(lastEdit).not.toHaveBeenCalled(); }); + it.each([false, true])("marks published notes with indicators %s", (on) => { + const plugin = host(spaces, [], [], [LINK, { ...LINK, id: "second" }]); + const marks = on ? presenceMarks(plugin, () => false) : shareMarks(plugin); + + expect(marks.get(LINK.path)?.published).toEqual({ + path: LINK.path, + count: 2, + stale: false, + text: "Shared by 2 links. Click to manage.", + }); + }); + + it("marks a note modified after publication as stale", () => { + const plugin = host(spaces, [], [], [LINK]); + vi.spyOn(plugin.app.vault, "getFileByPath").mockReturnValue({ + path: LINK.path, + stat: { mtime: LINK.publishedAt + 1 }, + } as ReturnType); + + const mark = shareMarks(plugin).get(LINK.path)?.published; + + expect(mark?.stale).toBe(true); + expect(mark?.text).toContain("Changed since it was shared"); + }); + + it("omits notes whose only link expired", () => { + const plugin = host(spaces, [], [], [{ ...LINK, expires: 1 }]); + + expect(shareMarks(plugin).has(LINK.path)).toBe(false); + }); + it("leaves the vault's own devices out of the tree", () => { const marks = presenceMarks(host(spaces, [LAPTOP]), () => false); diff --git a/packages/plugin/tests/ui/link-actions.test.ts b/packages/plugin/tests/ui/link-actions.test.ts new file mode 100644 index 00000000..d5ba9506 --- /dev/null +++ b/packages/plugin/tests/ui/link-actions.test.ts @@ -0,0 +1,244 @@ +import type { Command, Plugin } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerCommands } from "@/commands"; +import { type LinkRecord, SharedLinks, updateLink } from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { notifyError, notifyInfo } from "@/ui/common"; +import { updateSharedLinks } from "@/ui/links/link-actions"; +import { openPromptModal } from "@/ui/modals"; + +vi.mock("@/links", async (original) => ({ + ...(await original()), + updateLink: vi.fn(async () => {}), +})); +vi.mock("@/ui/common", () => ({ notifyInfo: vi.fn(), notifyError: vi.fn() })); +vi.mock("@/ui/modals", () => ({ openPromptModal: vi.fn() })); +vi.mock("@/ui", async () => ({ + ...(await import("@/ui/common")), + ...(await import("@/ui/links/link-actions")), + canRebuild: vi.fn(), + deepCleanOrphanedObjects: vi.fn(), + isLinkable: vi.fn(), + openDiffView: vi.fn(), + openInvite: vi.fn(), + openManageLinks: vi.fn(), + openShareLink: vi.fn(), + openShareWindow: vi.fn(), + openSourceControlDeleted: vi.fn(), + openSourceControlHistory: vi.fn(), + openSourceControlView: vi.fn(), + openWhereMenu: vi.fn(), + rebuildLiveNote: vi.fn(), + resetRemoteStorage: vi.fn(), + runWithNotice: vi.fn(), + toggleAuthors: vi.fn(), + verifyRemoteIntegrity: vi.fn(), +})); + +const RECORD: LinkRecord = { + id: "one", + url: "https://relay.example/one", + path: "note.md", + showTitle: true, + detached: false, + createdAt: 1, + publishedAt: 1, + expires: null, + maxViews: null, + salt: null, + images: false, +}; + +function host(records: LinkRecord[] = [RECORD]) { + let file: { path: string } | null = { path: RECORD.path }; + const settings = { + links: records, + relayUrl: "https://relay.example", + relaySecret: "secret", + }; + const plugin = { + settings, + sharedLinks: new SharedLinks( + () => settings, + async () => {}, + ), + app: { + workspace: { getActiveFile: () => file }, + vault: { getFileByPath: () => ({ stat: { mtime: 1 } }) }, + }, + addCommand: (command: Command) => commands.push(command), + } as unknown as Plugin & PluginHost; + const commands: Command[] = []; + registerCommands(plugin, () => false); + const command = commands.find(({ id }) => id === "update-note-links"); + if (!command?.checkCallback) throw new Error("Missing update command"); + return { + plugin, + check: command.checkCallback, + setFile: (next: typeof file) => { + file = next; + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(updateLink).mockReset().mockResolvedValue(undefined); + vi.mocked(openPromptModal).mockResolvedValue("passphrase"); +}); + +describe("updateSharedLinks", () => { + it("updates an unprotected link without asking and keeps the view-limit copy", async () => { + const { plugin } = host(); + const record = { ...RECORD, maxViews: 5 }; + await updateSharedLinks(plugin, [record]); + expect(openPromptModal).not.toHaveBeenCalled(); + expect(updateLink).toHaveBeenCalledWith( + plugin, + record, + null, + expect.any(Map), + ); + expect(notifyInfo).toHaveBeenCalledOnce(); + expect(notifyInfo).toHaveBeenCalledWith( + "The link now shows the note as it is. Views already used stay used.", + ); + }); + + it("asks for a protected link's passphrase and skips a cancelled prompt", async () => { + const { plugin } = host(); + const record = { ...RECORD, salt: "salt" }; + await updateSharedLinks(plugin, [record]); + expect(openPromptModal).toHaveBeenCalledWith( + expect.objectContaining({ title: "Passphrase", confirmLabel: "Update" }), + ); + expect(updateLink).toHaveBeenCalledWith( + plugin, + record, + "passphrase", + expect.any(Map), + ); + vi.mocked(updateLink).mockClear(); + vi.mocked(notifyInfo).mockClear(); + vi.mocked(openPromptModal).mockResolvedValueOnce(null); + await updateSharedLinks(plugin, [record]); + expect(updateLink).not.toHaveBeenCalled(); + expect(notifyInfo).not.toHaveBeenCalled(); + }); + + it("continues after errors and cancellation, shares a render map and reports only updated links", async () => { + const { plugin } = host(); + const failed = { ...RECORD, id: "failed" }; + const cancelled = { ...RECORD, id: "cancelled", salt: "salt", maxViews: 1 }; + const protectedLink = { + ...RECORD, + id: "protected", + salt: "salt", + maxViews: 5, + }; + const error = new Error("offline"); + vi.mocked(updateLink).mockRejectedValueOnce(error); + vi.mocked(openPromptModal).mockResolvedValueOnce(null); + + await updateSharedLinks(plugin, [failed, cancelled, protectedLink, RECORD]); + + const calls = vi.mocked(updateLink).mock.calls; + expect(calls.map(([, record]) => record.id)).toEqual([ + "failed", + "protected", + "one", + ]); + expect(calls[1]?.[2]).toBe("passphrase"); + const rendered = calls[0]?.[3]; + expect(rendered).toBeInstanceOf(Map); + expect(calls.every((call) => call[3] === rendered)).toBe(true); + expect(notifyError).toHaveBeenCalledExactlyOnceWith( + "Could not change the link", + error, + ); + expect(notifyInfo).toHaveBeenCalledExactlyOnceWith( + "2 links now show the note as it is. Views already used stay used.", + ); + }); + + it("does not mention views for failed or cancelled limited links", async () => { + const { plugin } = host(); + vi.mocked(updateLink).mockRejectedValueOnce(new Error("offline")); + vi.mocked(openPromptModal).mockResolvedValueOnce(null); + await updateSharedLinks(plugin, [ + { ...RECORD, id: "failed", maxViews: 5 }, + { ...RECORD, id: "cancelled", salt: "salt", maxViews: 5 }, + RECORD, + ]); + expect(notifyInfo).toHaveBeenCalledExactlyOnceWith( + "The link now shows the note as it is.", + ); + }); + + it.each([{ records: [] }, { records: [RECORD] }])( + "sends no success notice when nothing updates %#", + async ({ records }) => { + const { plugin } = host(); + vi.mocked(updateLink).mockRejectedValueOnce(new Error("offline")); + await updateSharedLinks(plugin, records); + expect(notifyInfo).not.toHaveBeenCalled(); + }, + ); +}); + +describe("update-note-links command", () => { + it("requires an active note with live links on this relay", () => { + const { check, plugin, setFile } = host(); + expect(check(true)).toBe(true); + expect(updateLink).not.toHaveBeenCalled(); + setFile(null); + expect(check(true)).toBe(false); + setFile({ path: "private.md" }); + expect(check(true)).toBe(false); + setFile({ path: RECORD.path }); + plugin.settings.relayUrl = "https://other.example"; + expect(check(true)).toBe(false); + expect(host([{ ...RECORD, expires: 1 }]).check(true)).toBe(false); + }); + + it("updates in turn, skipping other relays, expired links and cancelled prompts", async () => { + const cancelled = { ...RECORD, id: "cancelled", salt: "salt" }; + const protectedLink = { ...RECORD, id: "protected", salt: "salt" }; + const { check } = host([ + cancelled, + protectedLink, + RECORD, + { ...RECORD, id: "expired", expires: 1 }, + { ...RECORD, id: "other", url: "https://other.example/one" }, + ]); + vi.mocked(openPromptModal).mockResolvedValueOnce(null); + const order: string[] = []; + vi.mocked(updateLink).mockImplementation(async (_plugin, record) => { + order.push(`start:${record.id}`); + await Promise.resolve(); + order.push(`end:${record.id}`); + }); + expect(check(false)).toBe(true); + await vi.waitFor(() => expect(updateLink).toHaveBeenCalledTimes(2)); + expect(openPromptModal).toHaveBeenCalledTimes(2); + expect(order).toEqual([ + "start:protected", + "end:protected", + "start:one", + "end:one", + ]); + }); + + it("reports errors like the modal and continues to the next link", async () => { + const error = new Error("offline"); + vi.mocked(updateLink).mockRejectedValueOnce(error); + const { check } = host([RECORD, { ...RECORD, id: "two" }]); + check(false); + await vi.waitFor(() => expect(updateLink).toHaveBeenCalledTimes(2)); + expect(notifyError).toHaveBeenCalledWith( + "Could not change the link", + error, + ); + }); +}); diff --git a/packages/plugin/tests/ui/link-menu.test.ts b/packages/plugin/tests/ui/link-menu.test.ts new file mode 100644 index 00000000..f798d6e6 --- /dev/null +++ b/packages/plugin/tests/ui/link-menu.test.ts @@ -0,0 +1,29 @@ +import { type App, type CachedMetadata, TFile, TFolder } from "obsidian"; +import { describe, expect, it } from "vitest"; + +import { isLinkable } from "@/ui/links/link-menu"; + +function appWith(cache: CachedMetadata | null): App { + return { metadataCache: { getFileCache: () => cache } } as unknown as App; +} + +function file(name: string): TFile { + return Object.assign(new TFile(), { + path: name, + name, + extension: name.split(".").pop(), + }); +} + +describe("isLinkable", () => { + it("offers notes and leaves out drawings, by name or by front matter", () => { + const drawing = appWith({ frontmatter: { "excalidraw-plugin": "parsed" } }); + + expect(isLinkable(appWith({}), file("trip.md"))).toBe(true); + expect(isLinkable(appWith(null), file("trip.md"))).toBe(true); + expect(isLinkable(drawing, file("sketch.md"))).toBe(false); + expect(isLinkable(appWith(null), file("sketch.excalidraw.md"))).toBe(false); + expect(isLinkable(appWith({}), file("photo.png"))).toBe(false); + expect(isLinkable(appWith({}), new TFolder())).toBe(false); + }); +}); diff --git a/packages/plugin/tests/ui/manage-links-modal.test.ts b/packages/plugin/tests/ui/manage-links-modal.test.ts new file mode 100644 index 00000000..862ac74d --- /dev/null +++ b/packages/plugin/tests/ui/manage-links-modal.test.ts @@ -0,0 +1,236 @@ +import type { LinkStatus } from "@mdsync/protocol"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { type LinkRecord, linkStatusOf, SharedLinks } from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { updateSharedLinks } from "@/ui/links/link-actions"; +import { ManageLinksModal } from "@/ui/links/manage-links-modal"; + +vi.mock("@/links", async (original) => ({ + ...(await original()), + linkStatusOf: vi.fn(async () => null), +})); +vi.mock("@/ui/links/link-actions", () => ({ + updateSharedLinks: vi.fn(async () => {}), +})); +vi.mock("@/ui/modals", () => ({ openConfirmModal: vi.fn() })); +vi.mock("@/ui/common", async () => ({ + serial: (await import("@/ui/common/serial")).serial, + copyText: vi.fn(), + notifyError: vi.fn(), +})); +vi.mock("obsidian", () => ({ + Modal: class { + modalEl = { addClass: () => {} }; + titleEl = { setText: () => {} }; + contentEl = { + empty: () => { + rows.length = 0; + }, + createEl: () => {}, + }; + constructor(readonly app: object) {} + }, + Setting: class { + name = ""; + desc = ""; + descEl = { createDiv: () => {} }; + buttons: Button[] = []; + constructor() { + rows.push(this); + } + setName(name: string) { + this.name = name; + return this; + } + setDesc(desc: string) { + this.desc = desc; + return this; + } + addButton(build: (button: Button) => void) { + const button = new Button(); + build(button); + this.buttons.push(button); + return this; + } + addExtraButton(build: (button: Button) => void) { + return this.addButton(build); + } + }, +})); + +class Button { + text = ""; + click: () => Promise = async () => {}; + setButtonText(text: string) { + this.text = text; + return this; + } + setTooltip(text: string) { + this.text = text; + return this; + } + setIcon() { + return this; + } + setDisabled() { + return this; + } + onClick(click: () => Promise) { + this.click = click; + return this; + } +} + +const rows: { name: string; desc: string; buttons: Button[] }[] = []; +const RECORD: LinkRecord = { + id: "one", + url: "https://relay.example/one", + path: "note.md", + showTitle: true, + detached: false, + createdAt: 1, + publishedAt: 1, + expires: null, + maxViews: null, + salt: null, + images: false, +}; +const LIVE: LinkStatus = { + views: 0, + maxViews: null, + expires: null, + protected: false, + size: 1, +}; + +function host( + mtime: number | null, + records = [RECORD], + path: string | null = RECORD.path, +) { + const settings = { + links: records, + relayUrl: "https://relay.example", + relaySecret: "secret", + }; + const sharedLinks = new SharedLinks( + () => settings, + async () => {}, + ); + const note = mtime === null ? null : { path: RECORD.path, stat: { mtime } }; + const plugin = { + settings, + sharedLinks, + app: { + vault: { getFileByPath: () => note, on: () => ({}), offref: () => {} }, + }, + } as unknown as PluginHost; + new ManageLinksModal(plugin, path ?? undefined).onOpen(); + return { plugin, sharedLinks, note }; +} + +beforeEach(() => { + rows.length = 0; + vi.clearAllMocks(); + vi.mocked(linkStatusOf).mockResolvedValue(null); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ManageLinksModal", () => { + it("names a detached row and hides Update while retaining Copy and Stop", async () => { + vi.mocked(linkStatusOf).mockResolvedValue(LIVE); + host(2, [{ ...RECORD, detached: true }], null); + await vi.waitFor(() => expect(rows[0]?.desc).toContain("never expires")); + expect(rows[0]?.name).toBe("note"); + expect(rows[0]?.desc).toBe("The note was deleted. 0 views · never expires"); + expect(rows[0]?.buttons.map(({ text }) => text)).toEqual([ + "Copy link", + "Stop sharing", + ]); + }); + it("draws a record with a damaged address and still offers its removal", () => { + host(null, [{ ...RECORD, url: "not a URL" }, RECORD]); + expect(rows).toHaveLength(2); + expect(rows[0]?.desc).toContain("address is damaged"); + expect(rows[0]?.buttons.map(({ text }) => text)).toEqual([ + "Remove from the list", + ]); + }); + + it("starts changed notes with the stale line and keeps the status copy", async () => { + host(2, [RECORD, { ...RECORD, id: "other", path: "other.md" }]); + expect(rows).toHaveLength(1); + expect(rows[0]?.desc).toBe("Changed since it was shared. Checking…"); + await vi.waitFor(() => expect(rows[0]?.desc).toContain("Ended:")); + expect(rows[0]?.desc).toMatch(/^Changed since it was shared\./); + }); + + it("does not mark unchanged or missing notes as stale", () => { + host(1); + expect(rows[0]?.desc).toBe("Checking…"); + host(null); + expect(rows[0]?.desc).toBe("Checking…"); + }); + + it("uses the shared update flow and reloads only that record's status", async () => { + vi.mocked(linkStatusOf).mockResolvedValue(LIVE); + const other = { ...RECORD, id: "other" }; + const { plugin } = host(2, [RECORD, other]); + await vi.waitFor(() => + expect(rows.every(({ desc }) => desc.includes("never expires"))).toBe( + true, + ), + ); + expect(linkStatusOf).toHaveBeenCalledTimes(2); + vi.mocked(linkStatusOf).mockClear(); + vi.mocked(updateSharedLinks).mockImplementationOnce(async () => { + expect(linkStatusOf).not.toHaveBeenCalled(); + }); + await rows[0]?.buttons.find(({ text }) => text === "Update")?.click(); + expect(updateSharedLinks).toHaveBeenCalledExactlyOnceWith(plugin, [RECORD]); + expect(linkStatusOf).toHaveBeenCalledExactlyOnceWith(plugin, RECORD); + }); + + it("turns a link that expires while open into a remove-only row", async () => { + const now = 1_900_000_000_000; + vi.useFakeTimers({ now, toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.mocked(linkStatusOf).mockResolvedValue(LIVE); + host(1, [{ ...RECORD, expires: now / 1000 + 60 }]); + const actions = () => rows[0]?.buttons.map(({ text }) => text); + await vi.waitFor(() => expect(actions()).toContain("Update")); + + vi.advanceTimersByTime(60_000); + + expect(actions()).toEqual(["Remove from the list"]); + expect(rows[0]?.desc).toContain("Ended:"); + expect(linkStatusOf).toHaveBeenCalledTimes(1); + }); + + it("follows a rename while open and updates the moved link", async () => { + vi.mocked(linkStatusOf).mockResolvedValue(LIVE); + const { plugin, sharedLinks, note } = host(1); + if (note) note.path = "moved.md"; + await sharedLinks.move(RECORD.path, "moved.md"); + expect(rows).toHaveLength(1); + await rows[0]?.buttons.find(({ text }) => text === "Update")?.click(); + expect(updateSharedLinks).toHaveBeenCalledWith(plugin, [ + { + ...RECORD, + path: "moved.md", + }, + ]); + }); + + it("removes an ended link through SharedLinks", async () => { + const { sharedLinks } = host(1); + await vi.waitFor(() => expect(rows[0]?.desc).toContain("Ended:")); + await rows[0]?.buttons + .find(({ text }) => text === "Remove from the list") + ?.click(); + expect(sharedLinks.all()).toEqual([]); + }); +}); diff --git a/packages/plugin/tests/ui/note-link-action.test.ts b/packages/plugin/tests/ui/note-link-action.test.ts new file mode 100644 index 00000000..ae753a3a --- /dev/null +++ b/packages/plugin/tests/ui/note-link-action.test.ts @@ -0,0 +1,158 @@ +import { FileView, type Plugin } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import { type LinkRecord, SharedLinks } from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { openManageLinks } from "@/ui/links/link-menu"; +import { registerNoteLinkActions } from "@/ui/links/note-link-action"; + +vi.mock("@/ui/links/link-menu", () => ({ openManageLinks: vi.fn() })); + +const RECORD: LinkRecord = { + id: "one", + url: "https://relay.example/one", + path: "note.md", + showTitle: true, + detached: false, + createdAt: 1, + publishedAt: 1, + expires: null, + maxViews: null, + salt: null, + images: false, +}; + +class Action { + parentElement: object | null = {}; + classes = new Set(); + attrs = new Map(); + constructor(readonly click: () => void) {} + addClass(name: string) { + this.classes.add(name); + } + toggleClass(name: string, enabled: boolean) { + if (enabled) this.classes.add(name); + else this.classes.delete(name); + } + setAttr(name: string, value: string) { + this.attrs.set(name, value); + } + remove() { + this.parentElement = null; + } +} + +function host() { + const file = { path: "note.md", extension: "md", stat: { mtime: 1 } }; + const actions: Action[] = []; + const addAction = vi.fn((_icon: string, title: string, click: () => void) => { + const action = new Action(click); + action.setAttr("aria-label", title); + actions.push(action); + return action; + }); + const view = Object.assign(Object.create(FileView.prototype), { + file, + addAction, + }); + const leaves = [{ view }]; + const events = new Map void>(); + const on = (name: string, callback: (file?: { path: string }) => void) => { + events.set(name, callback); + return {}; + }; + const cleanup: (() => void)[] = []; + const settings = { links: [{ ...RECORD }] }; + const sharedLinks = new SharedLinks( + () => settings, + async () => {}, + ); + const plugin = { + sharedLinks, + app: { + workspace: { + iterateAllLeaves: vi.fn((visit: (leaf: object) => void) => + leaves.forEach(visit), + ), + on, + onLayoutReady: (ready: () => void) => ready(), + }, + vault: { on, getFileByPath: () => file }, + }, + register: (dispose: () => void) => cleanup.push(dispose), + registerEvent: vi.fn(), + } as unknown as Plugin & PluginHost; + registerNoteLinkActions(plugin); + return { + plugin, + sharedLinks, + file, + view, + leaves, + actions, + addAction, + events, + cleanup, + }; +} + +describe("a note's share link action", () => { + it("updates its stale mark and tooltip without adding another action", async () => { + const { plugin, sharedLinks, file, actions, addAction, events } = host(); + const action = actions[0]; + expect(action?.classes.has("mdsync-published-action")).toBe(true); + expect(action?.attrs.get("aria-label")).toContain("Click to manage."); + file.stat.mtime = 2; + events.get("modify")?.(file); + expect(action?.classes.has("is-stale")).toBe(true); + expect(action?.attrs.get("aria-label")).toContain("click to update."); + await sharedLinks.published(RECORD.id, 2); + expect(action?.classes.has("is-stale")).toBe(false); + expect(addAction).toHaveBeenCalledOnce(); + action?.click(); + expect(openManageLinks).toHaveBeenCalledWith(plugin, file.path); + await sharedLinks.remove(RECORD.id); + expect(action?.parentElement).toBeNull(); + }); + + it("does not refresh for an unshared note's modify", () => { + const { plugin, events } = host(); + const visit = vi.mocked(plugin.app.workspace.iterateAllLeaves); + visit.mockClear(); + events.get("modify")?.({ path: "private.md" }); + expect(visit).not.toHaveBeenCalled(); + }); + + it("replaces the action for another file and removes it for unshared notes", async () => { + const { sharedLinks, view, actions, events } = host(); + await sharedLinks.add({ ...RECORD, id: "two", path: "other.md" }); + view.file = { path: "other.md", extension: "md" }; + events.get("file-open")?.(); + expect(actions[0]?.parentElement).toBeNull(); + expect(actions[1]?.parentElement).not.toBeNull(); + view.file = { path: "private.md", extension: "md" }; + events.get("file-open")?.(); + expect(actions[1]?.parentElement).toBeNull(); + }); + + it("refreshes renamed notes and removes actions on closed leaves and unload", async () => { + const { sharedLinks, file, actions, leaves, events, cleanup } = host(); + file.path = "renamed.md"; + await sharedLinks.move("note.md", file.path); + events.get("rename")?.(); + expect(actions[0]?.parentElement).toBeNull(); + expect(actions[1]?.parentElement).not.toBeNull(); + leaves.length = 0; + events.get("layout-change")?.(); + expect(actions[1]?.parentElement).toBeNull(); + for (const dispose of cleanup) dispose(); + events.get("file-open")?.(); + expect(actions).toHaveLength(2); + }); + + it("removes every action on unload", () => { + const { actions, cleanup } = host(); + for (const dispose of cleanup) dispose(); + expect(actions[0]?.parentElement).toBeNull(); + }); +}); diff --git a/packages/plugin/tests/ui/preview-frame.test.ts b/packages/plugin/tests/ui/preview-frame.test.ts new file mode 100644 index 00000000..852c1c8f --- /dev/null +++ b/packages/plugin/tests/ui/preview-frame.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { previewDocument } from "@/ui/links/preview-frame"; + +describe("previewDocument", () => { + it("wraps the note under a policy that allows no script and no remote style", () => { + const page = previewDocument("

Hello

", false); + expect(page).toContain("

Hello

"); + expect(page).toContain("default-src 'none'"); + expect(page).toContain("img-src data: https:"); + expect(page).not.toContain("script-src"); + expect(page).toContain("color-scheme:light"); + }); + + it("follows a dark theme", () => { + expect(previewDocument("", true)).toContain("color-scheme:dark"); + }); +}); diff --git a/packages/plugin/tests/ui/row-decorator.test.ts b/packages/plugin/tests/ui/row-decorator.test.ts index da4ea471..de6e0bc4 100644 --- a/packages/plugin/tests/ui/row-decorator.test.ts +++ b/packages/plugin/tests/ui/row-decorator.test.ts @@ -1,4 +1,4 @@ -import { ALEX, host, SAM } from "@tests/helpers/explorer-host"; +import { ALEX, host, LINK, SAM } from "@tests/helpers/explorer-host"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Person } from "@/presence/people"; @@ -98,6 +98,26 @@ function settle(): void { beforeEach(settle); describe("the tree's decoration layers", () => { + it.each([false, true])( + "keeps published badges with indicators %s", + async (on) => { + const { apply, plugin } = setup([]); + await plugin.sharedLinks.add(LINK); + + apply(true, on); + + expect(paintedPaths()).toContain(LINK.path); + expect( + vi + .mocked(renderDecoration) + .mock.calls.find( + ([target]) => + (target as unknown as { path: string }).path === LINK.path, + )?.[1], + ).toMatchObject({ published: { count: 1, stale: false } }); + }, + ); + it("keeps the share badges alone when indicators are off", () => { const { apply, plugin } = setup([ALEX], ["Team/a.md"]); diff --git a/packages/protocol/src/bytes.ts b/packages/protocol/src/bytes.ts index 7e322150..3c9806dc 100644 --- a/packages/protocol/src/bytes.ts +++ b/packages/protocol/src/bytes.ts @@ -9,6 +9,28 @@ export function toHex(bytes: ArrayBuffer | Uint8Array): string { .join(""); } +/** Sealed notes run to megabytes; spreading one into `fromCharCode` would overflow the stack. */ +const BASE64_CHUNK = 0x8000; + +export function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let at = 0; at < bytes.length; at += BASE64_CHUNK) { + binary += String.fromCharCode(...bytes.subarray(at, at + BASE64_CHUNK)); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Throws on anything that is not base64url. */ +export function fromBase64Url(text: string): Uint8Array { + if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("invalid base64url"); + const padded = text.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "=")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + export class Writer { private readonly parts: Uint8Array[] = []; private length = 0; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 190631fc..e9fc1f6d 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,7 +1,9 @@ -export { toHex } from "./bytes"; +export { fromBase64Url, toBase64Url, toHex } from "./bytes"; export * from "./codec"; export * from "./frames"; export { deriveChannelGrant, GRANT_TTL_S, grantExpiry } from "./grant"; +export * from "./link"; +export { LINK_HREF_ALLOWED, LINK_IMAGE_SRC_ALLOWED } from "./link-content"; export { isShareId, SIGN_BATCH_MAX, diff --git a/packages/protocol/src/link-content.ts b/packages/protocol/src/link-content.ts new file mode 100644 index 00000000..1b33b2f7 --- /dev/null +++ b/packages/protocol/src/link-content.ts @@ -0,0 +1,2 @@ +export const LINK_HREF_ALLOWED = /^(https?:|mailto:|#)/i; +export const LINK_IMAGE_SRC_ALLOWED = /^(data:image\/|https:\/\/)/i; diff --git a/packages/protocol/src/link.ts b/packages/protocol/src/link.ts new file mode 100644 index 00000000..c32a1a08 --- /dev/null +++ b/packages/protocol/src/link.ts @@ -0,0 +1,306 @@ +/** + * Share links: a note's rendered HTML sealed so the relay holds only ciphertext. The plugin seals, the viewer + * opens, the relay checks the gate; this file is the one definition of the format. + */ + +import { fromBase64Url, toBase64Url } from "./bytes"; + +const subtle = crypto.subtle; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +/** Worker typings widen `encode` to `Uint8Array`; the bytes are always a plain ArrayBuffer. */ +const utf8 = (text: string) => encoder.encode(text) as Uint8Array; + +const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; +export const LINK_KEY_BYTES = 16; +export const LINK_SALT_BYTES = 16; +export const LINK_GATE_BYTES = 32; +/** The gate as it travels: base64url of {@link LINK_GATE_BYTES}. */ +export const LINK_GATE_PATTERN = /^[A-Za-z0-9_-]{43}$/; +export const LINK_MAX_SEALED_BYTES = 6 * 1024 * 1024; +export const LINK_MAX_VIEWS = 10_000; +export const LINK_MAX_TTL_S = 365 * 24 * 60 * 60; +export const LINK_KDF_ITERATIONS = 600_000; +/** Request headers the owner sends with a protected link; response headers of an open. */ +export const LINK_HEADERS = { + gate: "X-Mdsync-Gate", + salt: "X-Mdsync-Salt", + viewsLeft: "X-Mdsync-Views-Left", + expires: "X-Mdsync-Expires", +} as const; +/** Deflate ratios reach 1000x; an opened payload past this is refused, not inflated, so none is sealed. */ +const MAX_PLAINTEXT_BYTES = 32 * 1024 * 1024; + +const SEAL_VERSION = 1; +const IV_BYTES = 12; +const TAG_BYTES = 16; +const CONTENT_INFO = "mdsync-link-content-v1"; +const GATE_INFO = "mdsync-link-gate-v1"; + +export interface LinkPayload { + title: string; + html: string; + createdAt: number; +} + +/** What anyone may ask of a link before opening it: `GET /link//meta`. */ +export interface LinkMeta { + protected: boolean; + salt: string | null; +} + +/** Where a link stands, for its owner: `GET /link//status`. */ +export interface LinkStatus { + views: number; + maxViews: number | null; + expires: number | null; + protected: boolean; + size: number; +} + +/** A creation never inherits a counter; only an update, which needs a link still standing, keeps one. */ +export type LinkPutMode = "create" | "update"; + +export interface LinkProtection { + passphrase: string; + salt: Uint8Array; +} + +export interface LinkKeys { + content: CryptoKey; + /** What the relay checks before it serves or updates a link; the fragment key alone makes it when unprotected. */ + gate: string; +} + +export function isLinkId(id: string): boolean { + return LINK_ID_PATTERN.test(id); +} + +export function newLinkId(): string { + return toBase64Url(crypto.getRandomValues(new Uint8Array(16))); +} + +export function newLinkKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(LINK_KEY_BYTES)); +} + +export function newLinkSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(LINK_SALT_BYTES)); +} + +/** + * The key from the URL fragment, plus the stretched passphrase when there is one, feeds both outputs: without + * the fragment the relay cannot test a passphrase guess, even offline, and an id alone opens nothing. + */ +export async function deriveLinkKeys( + key: Uint8Array, + protection?: LinkProtection, +): Promise { + const stretched = protection ? await stretch(protection) : new Uint8Array(0); + const material = new Uint8Array(key.length + stretched.length); + material.set(key); + material.set(stretched, key.length); + const base = await subtle.importKey("raw", material, "HKDF", false, [ + "deriveKey", + "deriveBits", + ]); + const content = await subtle.deriveKey( + hkdf(CONTENT_INFO), + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + const gate = await subtle.deriveBits( + hkdf(GATE_INFO), + base, + LINK_GATE_BYTES * 8, + ); + return { content, gate: toBase64Url(new Uint8Array(gate)) }; +} + +export async function sealLinkPayload( + id: string, + payload: LinkPayload, + content: CryptoKey, +): Promise> { + const plain = utf8(JSON.stringify(payload)); + if (plain.length > MAX_PLAINTEXT_BYTES) { + throw new RangeError("This note is too large to open once shared."); + } + const packed = await transform( + plain, + new CompressionStream("deflate"), + Number.POSITIVE_INFINITY, + ); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const sealed = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: aad(id) }, + content, + packed, + ); + const out = new Uint8Array(1 + IV_BYTES + sealed.byteLength); + out[0] = SEAL_VERSION; + out.set(iv, 1); + out.set(new Uint8Array(sealed), 1 + IV_BYTES); + return out; +} + +/** Throws one error for a wrong key, a bad blob and a malformed payload alike. */ +export async function openLinkPayload( + id: string, + sealed: Uint8Array, + content: CryptoKey, +): Promise { + try { + if ( + sealed.length < 1 + IV_BYTES + TAG_BYTES || + sealed[0] !== SEAL_VERSION + ) { + throw new Error("not a link blob"); + } + const packed = await subtle.decrypt( + { + name: "AES-GCM", + iv: sealed.slice(1, 1 + IV_BYTES), + additionalData: aad(id), + }, + content, + sealed.slice(1 + IV_BYTES), + ); + const text = await transform( + new Uint8Array(packed), + new DecompressionStream("deflate"), + MAX_PLAINTEXT_BYTES, + ); + return payloadOf(JSON.parse(decoder.decode(text))); + } catch { + throw new Error("This link cannot be opened."); + } +} + +/** + * `/s/#[/]`: everything after the `#` stays in the browser. The anchor names a place in + * the note; base64url has no `/`, so it cannot be mistaken for the key. + */ +export function linkUrl( + relay: string, + id: string, + key: Uint8Array, + anchor?: string, +): string { + const place = anchor ? `/${encodeURIComponent(anchor)}` : ""; + return `${relay.replace(/\/+$/, "")}/s/${id}#${toBase64Url(key)}${place}`; +} + +/** Reads a link's id from the path, its key and anchor from the fragment; null for anything else. */ +export function parseLinkLocation( + pathname: string, + hash: string, +): { id: string; key: Uint8Array; anchor: string | null } | null { + const id = /^\/s\/([^/]+)\/?$/.exec(pathname)?.[1]; + if (!id || !isLinkId(id)) return null; + const [encodedKey = "", ...place] = hash.replace(/^#/, "").split("/"); + try { + const key = fromBase64Url(encodedKey); + if (key.length !== LINK_KEY_BYTES) return null; + return { id, key, anchor: decodeAnchor(place.join("/")) }; + } catch { + return null; + } +} + +/** A mistyped `%` leaves the anchor as typed rather than losing the key with it. */ +function decodeAnchor(encoded: string): string | null { + if (!encoded) return null; + try { + return decodeURIComponent(encoded); + } catch { + return encoded; + } +} + +async function stretch({ + passphrase, + salt, +}: LinkProtection): Promise { + // One passphrase typed on two systems may differ in Unicode form or stray spaces from pasting. + const base = await subtle.importKey( + "raw", + utf8(passphrase.trim().normalize("NFC")), + "PBKDF2", + false, + ["deriveBits"], + ); + const bits = await subtle.deriveBits( + { + name: "PBKDF2", + salt: new Uint8Array(salt), + iterations: LINK_KDF_ITERATIONS, + hash: "SHA-256", + }, + base, + 256, + ); + return new Uint8Array(bits); +} + +function hkdf(info: string) { + return { + name: "HKDF" as const, + hash: "SHA-256" as const, + salt: new Uint8Array(0), + info: utf8(info), + }; +} + +/** Binds a blob to its link, so the relay cannot serve one link's note under another's id. */ +function aad(id: string): Uint8Array { + return utf8(`link:${id}`); +} + +function payloadOf(value: unknown): LinkPayload { + const { title, html, createdAt } = (value ?? {}) as Partial; + if ( + typeof title !== "string" || + typeof html !== "string" || + typeof createdAt !== "number" + ) { + throw new Error("malformed payload"); + } + return { title, html, createdAt }; +} + +/** Runs bytes through a (de)compression stream, stopping once the output passes `limit`. */ +async function transform( + input: Uint8Array, + stream: CompressionStream | DecompressionStream, + limit: number, +): Promise> { + const writer = stream.writable.getWriter(); + // A bomb makes the write reject once the reader quits; the refusal below reports it. + void writer + .write(input) + .then(() => writer.close()) + .catch(() => undefined); + const reader = stream.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > limit) { + await reader.cancel(); + throw new Error("payload too large"); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; +} diff --git a/packages/protocol/tests/link.test.ts b/packages/protocol/tests/link.test.ts new file mode 100644 index 00000000..14721b4e --- /dev/null +++ b/packages/protocol/tests/link.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveLinkKeys, + fromBase64Url, + isLinkId, + LINK_GATE_PATTERN, + LINK_HREF_ALLOWED, + LINK_IMAGE_SRC_ALLOWED, + LINK_KEY_BYTES, + type LinkPayload, + linkUrl, + newLinkId, + newLinkKey, + newLinkSalt, + openLinkPayload, + parseLinkLocation, + sealLinkPayload, + toBase64Url, +} from "../src/index"; + +const payload: LinkPayload = { + title: "Trip notes", + html: "

Lisbon in May ✈

".repeat(50), + createdAt: 1_760_000_000_000, +}; +const bomb = { ...payload, html: "a".repeat(40 * 1024 * 1024) }; + +describe("base64url", () => { + it("round-trips bytes of every length", () => { + for (let length = 0; length < 40; length++) { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + expect(fromBase64Url(toBase64Url(bytes))).toEqual(bytes); + } + }); + + it("refuses text that is not base64url", () => { + expect(() => fromBase64Url("ab+/")).toThrow(); + expect(() => fromBase64Url("a b")).toThrow(); + }); +}); + +describe("published content URLs", () => { + it.each([ + ["https://example.com/note", true], + ["HTTP://example.com/note", true], + ["mailto:a@b.c", true], + ["#heading", true], + ["Notes/Other.md", false], + ["//example.com/note", false], + ["app://local/note", false], + ["javascript:alert(1)", false], + ] as const)("allows href %s: %s", (href, allowed) => { + expect(LINK_HREF_ALLOWED.test(href)).toBe(allowed); + }); + + it.each([ + ["data:image/png;base64,AAAA", true], + ["HTTPS://example.com/i.png", true], + ["http://example.com/i.png", false], + ["//example.com/i.png", false], + ["data:text/html;base64,AAAA", false], + ["Attachments/i.png", false], + ["app://local/i.png", false], + ] as const)("allows image src %s: %s", (src, allowed) => { + expect(LINK_IMAGE_SRC_ALLOWED.test(src)).toBe(allowed); + }); +}); + +describe("link ids", () => { + it("accepts what newLinkId makes and nothing else", () => { + expect(isLinkId(newLinkId())).toBe(true); + expect(isLinkId("short")).toBe(false); + expect(isLinkId(`${newLinkId()}x`)).toBe(false); + expect(isLinkId("../".repeat(8))).toBe(false); + }); +}); + +describe("seal and open", () => { + it("round-trips a payload", async () => { + const id = newLinkId(); + const { content } = await deriveLinkKeys(newLinkKey()); + const sealed = await sealLinkPayload(id, payload, content); + expect(await openLinkPayload(id, sealed, content)).toEqual(payload); + }); + + it("compresses repeated markup", async () => { + const { content } = await deriveLinkKeys(newLinkKey()); + const sealed = await sealLinkPayload(newLinkId(), payload, content); + expect(sealed.length).toBeLessThan(payload.html.length / 4); + }); + + it("seals the same payload differently each time", async () => { + const id = newLinkId(); + const { content } = await deriveLinkKeys(newLinkKey()); + const [a, b] = await Promise.all([ + sealLinkPayload(id, payload, content), + sealLinkPayload(id, payload, content), + ]); + expect(a).not.toEqual(b); + }); + + it("refuses a different key", async () => { + const id = newLinkId(); + const sealed = await sealLinkPayload( + id, + payload, + (await deriveLinkKeys(newLinkKey())).content, + ); + const other = (await deriveLinkKeys(newLinkKey())).content; + await expect(openLinkPayload(id, sealed, other)).rejects.toThrow(); + }); + + it("refuses the blob under another link's id", async () => { + const { content } = await deriveLinkKeys(newLinkKey()); + const sealed = await sealLinkPayload(newLinkId(), payload, content); + await expect( + openLinkPayload(newLinkId(), sealed, content), + ).rejects.toThrow(); + }); + + it("refuses a tampered or truncated blob", async () => { + const id = newLinkId(); + const { content } = await deriveLinkKeys(newLinkKey()); + const sealed = await sealLinkPayload(id, payload, content); + const flipped = sealed.slice(); + flipped.set([(flipped.at(-1) ?? 0) ^ 1], flipped.length - 1); + await expect(openLinkPayload(id, flipped, content)).rejects.toThrow(); + await expect( + openLinkPayload(id, sealed.slice(0, 20), content), + ).rejects.toThrow(); + const relabelled = sealed.slice(); + relabelled[0] = 2; + await expect(openLinkPayload(id, relabelled, content)).rejects.toThrow(); + }); + + it("refuses to seal a payload too large to open", async () => { + const { content } = await deriveLinkKeys(newLinkKey()); + await expect( + sealLinkPayload(newLinkId(), bomb, content), + ).rejects.toBeInstanceOf(RangeError); + }); + + it("refuses a payload that inflates past the limit", async () => { + const id = newLinkId(); + const { content } = await deriveLinkKeys(newLinkKey()); + const sealed = await sealByHand(id, bomb, content); + expect(sealed.length).toBeLessThan(1024 * 1024); + await expect(openLinkPayload(id, sealed, content)).rejects.toThrow(); + }); +}); + +describe("passphrase", () => { + const protection = (passphrase: string, salt = newLinkSalt()) => ({ + passphrase, + salt, + }); + + it("derives the gate from key, passphrase and salt", async () => { + const key = newLinkKey(); + const salt = newLinkSalt(); + const a = await deriveLinkKeys(key, protection("correct horse", salt)); + const same = await deriveLinkKeys(key, protection("correct horse", salt)); + expect(a.gate).toMatch(LINK_GATE_PATTERN); + expect(same.gate).toBe(a.gate); + const gates = await Promise.all([ + deriveLinkKeys(key, protection("wrong horse", salt)), + deriveLinkKeys(newLinkKey(), protection("correct horse", salt)), + deriveLinkKeys(key, protection("correct horse")), + ]); + for (const other of gates) expect(other.gate).not.toBe(a.gate); + }); + + it("derives an unprotected gate from the fragment key", async () => { + const key = newLinkKey(); + const open = await deriveLinkKeys(key); + const protectedKeys = await deriveLinkKeys(key, protection("pass")); + const other = await deriveLinkKeys(newLinkKey()); + expect(open.gate).toMatch(LINK_GATE_PATTERN); + expect(open.gate).not.toBe(protectedKeys.gate); + expect(open.gate).not.toBe(other.gate); + }); + + it("trims surrounding spaces before deriving keys", async () => { + const id = newLinkId(); + const key = newLinkKey(); + const salt = newLinkSalt(); + const trimmed = await deriveLinkKeys(key, protection("pass", salt)); + const padded = await deriveLinkKeys(key, protection(" pass ", salt)); + expect(padded.gate).toBe(trimmed.gate); + const sealed = await sealLinkPayload(id, payload, trimmed.content); + expect(await openLinkPayload(id, sealed, padded.content)).toEqual(payload); + }); + + it("opens only with the same key and passphrase", async () => { + const id = newLinkId(); + const key = newLinkKey(); + const salt = newLinkSalt(); + const sealed = await sealLinkPayload( + id, + payload, + (await deriveLinkKeys(key, protection("correct horse", salt))).content, + ); + const right = await deriveLinkKeys(key, protection("correct horse", salt)); + expect(await openLinkPayload(id, sealed, right.content)).toEqual(payload); + for (const wrong of [ + await deriveLinkKeys(key, protection("wrong horse", salt)), + await deriveLinkKeys(key), + ]) { + await expect( + openLinkPayload(id, sealed, wrong.content), + ).rejects.toThrow(); + } + }); + + it("treats Unicode forms of one passphrase alike", async () => { + const key = newLinkKey(); + const salt = newLinkSalt(); + const composed = await deriveLinkKeys( + key, + protection("caf\u00e9 au lait", salt), + ); + const decomposed = await deriveLinkKeys( + key, + protection("cafe\u0301 au lait", salt), + ); + expect(decomposed.gate).toBe(composed.gate); + }); +}); + +describe("link url", () => { + it("carries the key in the fragment and parses back", () => { + const id = newLinkId(); + const key = newLinkKey(); + const url = new URL(linkUrl("https://relay.example.com//", id, key)); + expect(url.pathname).toBe(`/s/${id}`); + expect(url.search).toBe(""); + expect(parseLinkLocation(url.pathname, url.hash)).toEqual({ + id, + key, + anchor: null, + }); + }); + + it("carries an anchor after the key", () => { + const id = newLinkId(); + const key = newLinkKey(); + const url = new URL( + linkUrl("https://relay.example.com", id, key, "été 1/plan"), + ); + expect(url.hash).toBe( + `#${toBase64Url(key)}/${encodeURIComponent("été 1/plan")}`, + ); + expect(parseLinkLocation(url.pathname, url.hash)).toEqual({ + id, + key, + anchor: "été 1/plan", + }); + expect( + parseLinkLocation(url.pathname, `#${toBase64Url(key)}/100%`)?.anchor, + ).toBe("100%"); + expect( + parseLinkLocation(url.pathname, `#${toBase64Url(key)}/`)?.anchor, + ).toBeNull(); + }); + + it("rejects a path or key that does not fit", () => { + const id = newLinkId(); + const key = toBase64Url(newLinkKey()); + expect(parseLinkLocation("/s/short", `#${key}`)).toBeNull(); + expect(parseLinkLocation(`/x/${id}`, `#${key}`)).toBeNull(); + expect(parseLinkLocation(`/s/${id}`, "")).toBeNull(); + expect(parseLinkLocation(`/s/${id}`, `#${key}AA`)).toBeNull(); + expect(parseLinkLocation(`/s/${id}`, "#!!")).toBeNull(); + expect(newLinkKey()).toHaveLength(LINK_KEY_BYTES); + }); +}); + +/** Seals past the size cap, as a hostile relay or an old plugin could. */ +async function sealByHand(id: string, value: LinkPayload, key: CryptoKey) { + const json = new Blob([JSON.stringify(value)]).stream(); + const packed = await new Response( + json.pipeThrough(new CompressionStream("deflate")), + ).arrayBuffer(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const additionalData = new TextEncoder().encode(`link:${id}`); + const sealed = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData }, + key, + packed, + ); + return new Uint8Array([1, ...iv, ...new Uint8Array(sealed)]); +} diff --git a/packages/relay/package.json b/packages/relay/package.json index d17a4fdb..14336f55 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", + "dev": "pnpm --filter mdsync-viewer run build && wrangler dev", + "deploy": "pnpm --filter mdsync-viewer run build && wrangler deploy", "typecheck": "tsc -noEmit", "test": "vitest run --passWithNoTests" }, diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index eb0a8779..fb945b6a 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -5,13 +5,17 @@ import { } from "./google-oauth"; import { handleHubRequest } from "./hub/access"; import { Hub } from "./hub/durable-object"; +import { Link } from "./link/durable-object"; +import { handleLinkRequest } from "./link/routes"; +import { handleShellRequest, type ShellEnv } from "./link/shell"; +import type { LinkEnv } from "./link/stub"; import { ADMIN_HEADER, isAdmin } from "./secret"; import { handleShareRequest } from "./share/broker"; import type { ShareEnv } from "./share/kv"; -export interface Env extends ShareEnv, GoogleOAuthEnv {} +export interface Env extends ShareEnv, GoogleOAuthEnv, LinkEnv, ShellEnv {} -export { Hub }; +export { Hub, Link }; const CORS_PREFLIGHT_HEADERS = { "Access-Control-Allow-Origin": "*", @@ -20,7 +24,7 @@ const CORS_PREFLIGHT_HEADERS = { } as const; const NOT_FOUND = - "Not found. Use /hub for realtime sync, /share/* for shared folders, or /auth for Google Drive."; + "Not found. Use /hub for realtime sync, /share/* for shared folders, /link/* for share links, or /auth for Google Drive."; export default { async fetch( @@ -38,6 +42,12 @@ export default { const shareResponse = await handleShareRequest(request, env, url); if (shareResponse) return shareResponse; + const shellResponse = await handleShellRequest(request, env, url); + if (shellResponse) return shellResponse; + + const linkResponse = await handleLinkRequest(request, env, url); + if (linkResponse) return linkResponse; + const hubResponse = await handleHubRequest(request, env, url); if (hubResponse) return hubResponse; diff --git a/packages/relay/src/link/client.ts b/packages/relay/src/link/client.ts new file mode 100644 index 00000000..5e5542ac --- /dev/null +++ b/packages/relay/src/link/client.ts @@ -0,0 +1,43 @@ +import { fingerprint } from "../secret"; + +const IPV6_GROUPS = 8; +/** One household or office holds a whole /64, so that is one client. */ +const IPV6_PREFIX_GROUPS = 4; +const CLIENT_HEX = 16; + +/** Who is asking, as an opaque id: the link remembers wrong guesses per client, never an address. */ +export async function clientOf(request: Request): Promise { + // Fail closed in one shared bucket if missing; Cloudflare always sets this header. + const address = request.headers.get("CF-Connecting-IP") ?? ""; + return (await fingerprint(networkOf(address))).slice(0, CLIENT_HEX); +} + +export function networkOf(address: string): string { + if (!address.includes(":")) return address; + const [head = "", tail] = address + .toLowerCase() + .replace( + /(\d+)\.(\d+)\.(\d+)\.(\d+)$/, + (_tail, a, b, c, d) => + `${(Number(a) * 256 + Number(b)).toString(16)}:${(Number(c) * 256 + Number(d)).toString(16)}`, + ) + .split("::"); + const front = head === "" ? [] : head.split(":"); + const back = tail === undefined || tail === "" ? [] : tail.split(":"); + const gap = Math.max(IPV6_GROUPS - front.length - back.length, 0); + const groups = [...front, ...Array(gap).fill("0"), ...back].map((group) => + group.replace(/^0+(?=.)/, ""), + ); + if ( + ["0:0:0:0:0:ffff", "64:ff9b:0:0:0:0"].includes(groups.slice(0, 6).join(":")) + ) { + return groups + .slice(6) + .flatMap((group) => { + const value = Number.parseInt(group, 16); + return [value >> 8, value & 255]; + }) + .join("."); + } + return groups.slice(0, IPV6_PREFIX_GROUPS).join(":"); +} diff --git a/packages/relay/src/link/durable-object.ts b/packages/relay/src/link/durable-object.ts new file mode 100644 index 00000000..03e3d19f --- /dev/null +++ b/packages/relay/src/link/durable-object.ts @@ -0,0 +1,57 @@ +/** One share link: its sealed blob, view counter and gate. See store.ts for the rules. */ + +import { DurableObject } from "cloudflare:workers"; +import type { LinkMeta, LinkPutMode, LinkStatus } from "@mdsync/protocol"; + +import { + type LinkSettings, + LinkStore, + type OpenResult, + type PutResult, +} from "./store"; +import type { LinkEnv } from "./stub"; + +export class Link extends DurableObject { + private readonly store = new LinkStore(this.ctx.storage.sql, Date.now, () => + this.release(), + ); + + async put( + blob: ArrayBuffer, + settings: LinkSettings, + mode: LinkPutMode, + ): Promise { + const result = this.store.put(blob, settings, mode); + if (result !== "stored" || mode === "update") return result; + if (settings.expires === null) await this.ctx.storage.deleteAlarm(); + else await this.ctx.storage.setAlarm(settings.expires * 1000); + return result; + } + + open(gate: string | null, client: string): OpenResult { + return this.store.open(gate, client); + } + + status(): LinkStatus | null { + return this.store.status(); + } + + meta(): LinkMeta | null { + return this.store.meta(); + } + + revoke(): void { + this.store.revoke(); + } + + /** The expiry: a link nobody opened again still gives its storage back. */ + alarm(): void { + this.store.revoke(); + } + + /** Unlike deleting rows, this frees the file; the store tolerates the tables being gone. */ + private release(): void { + void this.ctx.storage.deleteAlarm(); + void this.ctx.storage.deleteAll(); + } +} diff --git a/packages/relay/src/link/routes.ts b/packages/relay/src/link/routes.ts new file mode 100644 index 00000000..e456530a --- /dev/null +++ b/packages/relay/src/link/routes.ts @@ -0,0 +1,233 @@ +/** + * Share link routes. The owner's plugin stores, updates, inspects and revokes behind the relay secret; + * the viewer asks `meta` and then `open`. The relay only ever holds sealed bytes. + */ + +import { + isLinkId, + LINK_GATE_PATTERN, + LINK_HEADERS, + LINK_MAX_SEALED_BYTES, + LINK_MAX_TTL_S, + LINK_MAX_VIEWS, +} from "@mdsync/protocol"; + +import { fingerprint, isAdmin, type SecretEnv } from "../secret"; +import { + adminUnauthorized, + badRequest, + json, + jsonError, + methodNotAllowed, + readJsonObject, +} from "../share/http"; +import { clientOf } from "./client"; +import type { LinkSettings } from "./store"; +import { type LinkEnv, linkStub } from "./stub"; + +export interface LinkRouteEnv extends SecretEnv, LinkEnv {} + +const ROUTE = /^\/link\/([^/]+)(?:\/(meta|status|open))?$/; +const SALT_PATTERN = /^[A-Za-z0-9_-]{22}$/; +/** The open route is public and its body is one gate, so anything bigger is not parsed. */ +const MAX_OPEN_BODY_BYTES = 1024; +const GONE_TEXT = "This link has expired or reached its view limit."; + +/** Returns null when the path is not a link route, so index.ts can fall through. */ +export async function handleLinkRequest( + request: Request, + env: LinkRouteEnv, + url: URL, +): Promise { + if (!url.pathname.startsWith("/link/")) return null; + const match = ROUTE.exec(url.pathname); + const id = match?.[1] ?? ""; + if (!match || !isLinkId(id)) + return jsonError(404, "not_found", "Unknown link route"); + const { method } = request; + + switch (match[2]) { + case undefined: + if (method === "PUT") return storeLink(request, env, id, url); + if (method === "DELETE") return revokeLink(request, env, id); + return methodNotAllowed("DELETE, PUT"); + case "status": + return method === "GET" + ? linkStatus(request, env, id) + : methodNotAllowed("GET"); + case "meta": + return method === "GET" ? linkMeta(env, id) : methodNotAllowed("GET"); + default: + return method === "POST" + ? openLink(request, env, id) + : methodNotAllowed("POST"); + } +} + +async function storeLink( + request: Request, + env: LinkRouteEnv, + id: string, + url: URL, +): Promise { + if (!(await isAdmin(request, env))) return adminUnauthorized(); + const parsed = await readSettings(request, url); + if (!parsed.ok) return badRequest(parsed.reason); + if (Number(request.headers.get("Content-Length")) > LINK_MAX_SEALED_BYTES) { + return tooLarge(); + } + const blob = await request.arrayBuffer(); + if (blob.byteLength > LINK_MAX_SEALED_BYTES) return tooLarge(); + if (blob.byteLength === 0) return badRequest("The body is empty"); + const mode = url.searchParams.get("update") === "1" ? "update" : "create"; + // On the relay's clock, so a device with the wrong time cannot misdate it, and after a slow upload. + const expires = + parsed.ttl === null ? null : Math.floor(Date.now() / 1000) + parsed.ttl; + const settings = { ...parsed.settings, expires }; + const stored = await linkStub(env, id).put(blob, settings, mode); + if (stored === "gone") return gone(); + if (stored === "mismatch") return fresh(wrongGate()); + if (stored === "exists") { + return fresh( + jsonError(409, "exists", "A link with this id already exists"), + ); + } + // An update keeps the limits it was created with, so only a creation has an expiry to report. + return fresh( + json({ + stored: true, + size: blob.byteLength, + ...(mode === "create" && { expires: settings.expires }), + }), + ); +} + +async function revokeLink( + request: Request, + env: LinkRouteEnv, + id: string, +): Promise { + if (!(await isAdmin(request, env))) return adminUnauthorized(); + await linkStub(env, id).revoke(); + return fresh(json({ revoked: true })); +} + +async function linkStatus( + request: Request, + env: LinkRouteEnv, + id: string, +): Promise { + if (!(await isAdmin(request, env))) return adminUnauthorized(); + const status = await linkStub(env, id).status(); + return status ? fresh(json(status)) : gone(); +} + +/** Counts nothing, so the viewer can learn whether to ask for a passphrase without spending a view. */ +async function linkMeta(env: LinkRouteEnv, id: string): Promise { + const meta = await linkStub(env, id).meta(); + return meta ? fresh(json(meta)) : gone(); +} + +async function openLink( + request: Request, + env: LinkRouteEnv, + id: string, +): Promise { + const body = await readJsonObject(request, MAX_OPEN_BODY_BYTES); + const presented = + typeof body?.gate === "string" && LINK_GATE_PATTERN.test(body.gate) + ? await fingerprint(body.gate) + : null; + const result = await linkStub(env, id).open( + presented, + await clientOf(request), + ); + if (result.ok) { + const headers = new Headers({ + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + }); + if (result.viewsLeft !== null) { + headers.set(LINK_HEADERS.viewsLeft, String(result.viewsLeft)); + } + if (result.expires !== null) { + headers.set(LINK_HEADERS.expires, String(result.expires)); + } + return new Response(result.blob, { headers }); + } + if (result.reason === "gone") return gone(); + const response = + result.reason === "gate" + ? wrongGate(401) + : jsonError(429, result.reason, "Too many wrong passphrases."); + if (result.retryAfter !== null) { + response.headers.set("Retry-After", String(result.retryAfter)); + } + return fresh(response); +} + +/** The expiry is a span, kept apart from the settings: it starts when the body has arrived, not before. */ +async function readSettings( + request: Request, + url: URL, +): Promise< + | { ok: true; settings: Omit; ttl: number | null } + | { ok: false; reason: string } +> { + const maxViews = optionalInt( + url.searchParams.get("maxViews"), + 1, + LINK_MAX_VIEWS, + ); + const ttl = optionalInt(url.searchParams.get("ttl"), 1, LINK_MAX_TTL_S); + const gate = request.headers.get(LINK_HEADERS.gate); + const salt = request.headers.get(LINK_HEADERS.salt); + if (maxViews === "invalid") return refused("Invalid maxViews"); + if (ttl === "invalid") return refused("Invalid ttl"); + if (gate === null || !LINK_GATE_PATTERN.test(gate)) { + return refused("Invalid gate"); + } + if (salt !== null && !SALT_PATTERN.test(salt)) return refused("Invalid salt"); + return { + ok: true, + ttl, + settings: { maxViews, gate: await fingerprint(gate), salt }, + }; +} + +function refused(reason: string) { + return { ok: false, reason } as const; +} + +function optionalInt( + text: string | null, + min: number, + max: number, +): number | null | "invalid" { + if (text === null || text === "") return null; + const value = Number(text); + return Number.isInteger(value) && value >= min && value <= max + ? value + : "invalid"; +} + +function wrongGate(status = 403): Response { + return jsonError(status, "gate", "Wrong passphrase."); +} + +function gone(): Response { + return fresh(jsonError(404, "gone", GONE_TEXT)); +} + +function tooLarge(): Response { + return jsonError( + 413, + "too_large", + "The note is too large to share as a link", + ); +} + +function fresh(response: Response): Response { + response.headers.set("Cache-Control", "no-store"); + return response; +} diff --git a/packages/relay/src/link/shell.ts b/packages/relay/src/link/shell.ts new file mode 100644 index 00000000..e48a404b --- /dev/null +++ b/packages/relay/src/link/shell.ts @@ -0,0 +1,64 @@ +/** `/s/`: the viewer's one HTML page, with the headers that keep a shared note inside its sandbox. */ + +import { isLinkId } from "@mdsync/protocol"; + +export interface ShellEnv { + ASSETS: Fetcher; +} + +const SHELL_PATH = /^\/s\/([^/]+)\/?$/; +/** Where the viewer's build lands inside the assets directory. */ +const SHELL_ASSET = "/viewer/"; + +/** + * Scripts and styles only from the relay itself; images from data: (the note's own) and https: (what its + * author linked); no framing, no forms, no base tags. + */ +const POLICY = [ + "default-src 'none'", + "script-src 'self'", + "style-src 'self'", + "style-src-attr 'unsafe-inline'", + "img-src data: https:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", +].join("; "); + +/** Returns null when the path is not a link page, so index.ts can fall through. */ +export async function handleShellRequest( + request: Request, + env: ShellEnv, + url: URL, +): Promise { + const match = SHELL_PATH.exec(url.pathname); + if (!match) return null; + if (!isLinkId(match[1] ?? "")) return notFound(); + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET, HEAD" }, + }); + } + const shell = await env.ASSETS.fetch(new URL(SHELL_ASSET, url)); + if (!shell.ok) { + return new Response("The link viewer is not deployed on this relay.", { + status: 503, + }); + } + const headers = new Headers(shell.headers); + headers.set("Content-Security-Policy", POLICY); + headers.set("Referrer-Policy", "no-referrer"); + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("X-Robots-Tag", "noindex, nofollow"); + headers.set("Cache-Control", "no-cache"); + return new Response(request.method === "HEAD" ? null : shell.body, { + status: 200, + headers, + }); +} + +function notFound(): Response { + return new Response("Not found", { status: 404 }); +} diff --git a/packages/relay/src/link/store.ts b/packages/relay/src/link/store.ts new file mode 100644 index 00000000..b6259e8a --- /dev/null +++ b/packages/relay/src/link/store.ts @@ -0,0 +1,285 @@ +/** + * One share link's state in its Durable Object's SQLite: the sealed blob in chunks, the view counter, the + * gate. Every method is synchronous, so a view is counted and a spent link erased in one turn, and two + * opens never both take the last view. + */ + +import { + LINK_MAX_SEALED_BYTES, + type LinkMeta, + type LinkPutMode, + type LinkStatus, +} from "@mdsync/protocol"; + +import type { Sql, SqlValue } from "../hub/store"; + +/** SQLite caps a value at 2 MB. */ +const CHUNK_BYTES = 1024 * 1024; +const FAILS_BEFORE_COOLDOWN = 5; +const COOLDOWN_START_S = 60; +const COOLDOWN_MAX_S = 60 * 60; +/** Clients remembered per link; the least recently seen go first. */ +const THROTTLED_CLIENTS_MAX = 128; + +export interface LinkSettings { + maxViews: number | null; + /** Unix seconds. */ + expires: number | null; + /** Hash of the gate every link has; opening or updating needs the gate itself. */ + gate: string; + /** Set exactly when the link has a passphrase. */ + salt: string | null; +} + +export type OpenResult = + | { + ok: true; + blob: ArrayBuffer; + viewsLeft: number | null; + expires: number | null; + } + | { ok: false; reason: "gone" } + | { ok: false; reason: "gate"; retryAfter: number | null } + | { ok: false; reason: "cooldown"; retryAfter: number }; + +export type PutResult = "stored" | "exists" | "gone" | "mismatch"; + +interface Row extends LinkSettings { + views: number; + size: number; +} + +interface Throttle { + fails: number; + lockedUntil: number; +} + +export class LinkStore { + constructor( + private readonly sql: Sql, + private readonly now: () => number = Date.now, + /** Runs once a link is erased, to hand its storage back. */ + private readonly onEnd: () => void = () => undefined, + ) {} + + /** + * A creation takes `settings`; an update replaces the blob only, after the gate proves the same key and + * passphrase, and keeps the limits and the counter. + */ + put(blob: ArrayBuffer, settings: LinkSettings, mode: LinkPutMode): PutResult { + if (blob.byteLength === 0 || blob.byteLength > LINK_MAX_SEALED_BYTES) { + throw new RangeError("Link blob has an unsupported size"); + } + // Not `live()` on a creation: erasing an old link here would hand the storage back under the new one. + const old = this.row(); + const standing = old !== null && this.stands(old); + if (mode === "create" && standing) return "exists"; + if (mode === "update") { + if (!old || !standing) { + this.live(); + return "gone"; + } + if (!sameHash(old.gate, settings.gate)) return "mismatch"; + } else { + this.createTables(); + } + this.sql.exec("DELETE FROM chunks"); + this.sql.exec("DELETE FROM throttle"); + for (let at = 0, seq = 0; at < blob.byteLength; at += CHUNK_BYTES) { + this.sql.exec( + "INSERT INTO chunks(seq, data) VALUES(?, ?)", + seq++, + blob.slice(at, at + CHUNK_BYTES), + ); + } + if (mode === "update") { + this.sql.exec("UPDATE link SET size = ? WHERE id = 1", blob.byteLength); + return "stored"; + } + this.sql.exec( + "INSERT OR REPLACE INTO link(id, views, max_views, expires, gate, salt, size) VALUES(1, 0, ?, ?, ?, ?, ?)", + settings.maxViews, + settings.expires, + settings.gate, + settings.salt, + blob.byteLength, + ); + return "stored"; + } + + /** + * Counts a view and hands the blob out; the last permitted view erases the link as it leaves. Wrong + * gates slow down the client that sent them, never the link: anyone holding only the id could lock the + * readers out otherwise. + */ + open(gate: string | null, client: string): OpenResult { + const link = this.live(); + if (!link) return GONE; + const now = this.now(); + const throttle = this.throttleOf(client); + if (throttle.lockedUntil > now) { + return { + ok: false, + reason: "cooldown", + retryAfter: Math.ceil((throttle.lockedUntil - now) / 1000), + }; + } + if (gate === null || !sameHash(link.gate, gate)) { + return this.refuse(client, throttle, now); + } + const blob = this.read(link.size); + const views = link.views + 1; + const viewsLeft = link.maxViews === null ? null : link.maxViews - views; + if (viewsLeft !== null && viewsLeft <= 0) { + this.erase(); + return { ok: true, blob, viewsLeft, expires: link.expires }; + } + this.sql.exec("UPDATE link SET views = ? WHERE id = 1", views); + if (throttle.fails > 0) { + this.sql.exec("DELETE FROM throttle WHERE client = ?", client); + } + return { ok: true, blob, viewsLeft, expires: link.expires }; + } + + status(): LinkStatus | null { + const link = this.live(); + return ( + link && { + views: link.views, + maxViews: link.maxViews, + expires: link.expires, + protected: link.salt !== null, + size: link.size, + } + ); + } + + meta(): LinkMeta | null { + const link = this.live(); + return link && { protected: link.salt !== null, salt: link.salt }; + } + + /** Ends the link now, whatever its state. */ + revoke(): void { + if (this.row()) this.erase(); + } + + /** The row of a link that still stands; one expired or spent is erased on the way. */ + private live(): Row | null { + const link = this.row(); + if (!link) return null; + if (this.stands(link)) return link; + this.erase(); + return null; + } + + private stands(link: Row): boolean { + const expired = link.expires !== null && link.expires * 1000 <= this.now(); + const spent = link.maxViews !== null && link.views >= link.maxViews; + return !expired && !spent; + } + + private refuse(client: string, before: Throttle, now: number): OpenResult { + const fails = before.fails + 1; + const wait = + fails < FAILS_BEFORE_COOLDOWN + ? null + : Math.min( + COOLDOWN_START_S * 2 ** (fails - FAILS_BEFORE_COOLDOWN), + COOLDOWN_MAX_S, + ); + this.sql.exec( + "INSERT OR REPLACE INTO throttle(client, fails, locked_until, seen) VALUES(?, ?, ?, ?)", + client, + fails, + wait === null ? 0 : now + wait * 1000, + now, + ); + // A client still in cooldown ranks by when it ends, or others' later failures would evict its lock. + this.sql.exec( + "DELETE FROM throttle WHERE client IN (SELECT client FROM throttle ORDER BY max(seen, locked_until) DESC LIMIT -1 OFFSET ?)", + THROTTLED_CLIENTS_MAX, + ); + return { ok: false, reason: "gate", retryAfter: wait }; + } + + private throttleOf(client: string): Throttle { + const [row] = this.sql + .exec("SELECT fails, locked_until FROM throttle WHERE client = ?", client) + .toArray(); + return { + fails: Number(row?.fails ?? 0), + lockedUntil: Number(row?.locked_until ?? 0), + }; + } + + private row(): Row | null { + if (!this.hasTable()) return null; + const [row] = this.sql.exec("SELECT * FROM link WHERE id = 1").toArray(); + if (!row) return null; + return { + views: Number(row.views), + maxViews: nullable(row.max_views), + expires: nullable(row.expires), + gate: String(row.gate), + salt: typeof row.salt === "string" ? row.salt : null, + size: Number(row.size), + }; + } + + private read(size: number): ArrayBuffer { + const out = new Uint8Array(size); + let at = 0; + for (const { data } of this.sql + .exec("SELECT data FROM chunks ORDER BY seq") + .toArray()) { + const chunk = new Uint8Array(data as ArrayBuffer); + out.set(chunk, at); + at += chunk.length; + } + return out.buffer; + } + + private createTables(): void { + this.sql.exec( + "CREATE TABLE IF NOT EXISTS link(id INTEGER PRIMARY KEY CHECK(id = 1), views INTEGER NOT NULL, max_views INTEGER, expires INTEGER, gate TEXT NOT NULL, salt TEXT, size INTEGER NOT NULL)", + ); + this.sql.exec( + "CREATE TABLE IF NOT EXISTS chunks(seq INTEGER PRIMARY KEY, data BLOB NOT NULL)", + ); + this.sql.exec( + "CREATE TABLE IF NOT EXISTS throttle(client TEXT PRIMARY KEY, fails INTEGER NOT NULL, locked_until INTEGER NOT NULL, seen INTEGER NOT NULL) WITHOUT ROWID", + ); + } + + /** An id nobody created has no tables, and asking about it must not make any. */ + private hasTable(): boolean { + return ( + this.sql.exec("SELECT 1 FROM sqlite_master WHERE name = 'link'").toArray() + .length > 0 + ); + } + + private erase(): void { + this.sql.exec("DELETE FROM chunks"); + this.sql.exec("DELETE FROM throttle"); + this.sql.exec("DELETE FROM link"); + this.onEnd(); + } +} + +const GONE: OpenResult = { ok: false, reason: "gone" }; + +function nullable(value: SqlValue | undefined): number | null { + return value === null || value === undefined ? null : Number(value); +} + +/** Equal-length hex digests, compared without stopping at the first difference. */ +function sameHash(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let diff = 0; + for (let i = 0; i < left.length; i++) { + diff |= left.charCodeAt(i) ^ right.charCodeAt(i); + } + return diff === 0; +} diff --git a/packages/relay/src/link/stub.ts b/packages/relay/src/link/stub.ts new file mode 100644 index 00000000..f2d36024 --- /dev/null +++ b/packages/relay/src/link/stub.ts @@ -0,0 +1,10 @@ +import type { Link } from "./durable-object"; + +export interface LinkEnv { + LINK: DurableObjectNamespace; +} + +/** One object per link: a view is counted in the link's own turn, and links never queue behind each other. */ +export function linkStub(env: LinkEnv, id: string): DurableObjectStub { + return env.LINK.get(env.LINK.idFromName(id)); +} diff --git a/packages/relay/src/share/http.ts b/packages/relay/src/share/http.ts index 779493e5..8b07b6b8 100644 --- a/packages/relay/src/share/http.ts +++ b/packages/relay/src/share/http.ts @@ -40,14 +40,43 @@ export function bearerOf(request: Request): string { return header.slice("Bearer ".length).trim(); } +/** With `maxBytes`, a larger body is dropped unread rather than buffered whole: null, like any bad body. */ export async function readJsonObject( request: Request, + maxBytes?: number, ): Promise { try { + const text = + maxBytes === undefined + ? await request.text() + : await boundedText(request, maxBytes); // `null` is valid JSON, so the shape has to be checked before it is read. - const parsed: unknown = await request.json(); + const parsed: unknown = JSON.parse(text); return parsed && typeof parsed === "object" ? (parsed as JsonObject) : null; } catch { return null; } } + +/** A chunked body carries no length to trust, so the bytes are counted as they arrive. */ +async function boundedText( + request: Request, + maxBytes: number, +): Promise { + if (Number(request.headers.get("Content-Length")) > maxBytes) { + throw new RangeError("Body too large"); + } + if (!request.body) return ""; + let total = 0; + const counted = request.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + total += chunk.length; + if (total > maxBytes) + controller.error(new RangeError("Body too large")); + else controller.enqueue(chunk); + }, + }), + ); + return new Response(counted).text(); +} diff --git a/packages/relay/tests/link/client.test.ts b/packages/relay/tests/link/client.test.ts new file mode 100644 index 00000000..791e39bd --- /dev/null +++ b/packages/relay/tests/link/client.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { clientOf, networkOf } from "../../src/link/client"; + +const request = (address?: string) => + new Request("https://relay.example.com/link/x/open", { + headers: address ? { "CF-Connecting-IP": address } : {}, + }); + +describe("networkOf", () => { + it("keeps an IPv4 address whole", () => { + expect(networkOf("203.0.113.7")).toBe("203.0.113.7"); + }); + + it.each([ + "::ffff:1.2.3.4", + "::ffff:102:304", + "0:0:0:0:0:FFFF:0102:0304", + "64:ff9b::1.2.3.4", + "64:ff9b::102:304", + "0064:FF9B:0:0:0:0:0102:0304", + ])("groups embedded IPv4 in %s as IPv4", (address) => { + expect(networkOf(address)).toBe("1.2.3.4"); + }); + + it("reduces other IPv6 addresses with dotted tails to their /64", () => { + expect(networkOf("2001:db8:abcd:12::1.2.3.4")).toBe("2001:db8:abcd:12"); + expect(networkOf("2001:db8:abcd:12:0:0:1.2.3.4")).toBe("2001:db8:abcd:12"); + expect(networkOf("::1.2.3.4")).toBe("0:0:0:0"); + expect(networkOf("64:ff9b:1::1.2.3.4")).toBe("64:ff9b:1:0"); + }); + + it("reduces an IPv6 address to its /64, however it is written", () => { + const network = "2001:db8:abcd:12"; + expect(networkOf("2001:db8:abcd:12::1")).toBe(network); + expect(networkOf("2001:0db8:abcd:0012:ffff:ffff:ffff:ffff")).toBe(network); + expect(networkOf("2001:DB8:ABCD:12:1:2:3:4")).toBe(network); + expect(networkOf("2001:db8::")).toBe("2001:db8:0:0"); + expect(networkOf("::1")).toBe("0:0:0:0"); + }); +}); + +describe("clientOf", () => { + it("groups the addresses of one /64 and tells networks apart", async () => { + const a = await clientOf(request("2001:db8:abcd:12::1")); + expect(await clientOf(request("2001:db8:abcd:12::beef"))).toBe(a); + expect(await clientOf(request("2001:db8:abcd:13::1"))).not.toBe(a); + expect(await clientOf(request("203.0.113.7"))).not.toBe(a); + }); + + it("shares IPv4 buckets across mapped and NAT64 forms, not clients", async () => { + const client = await clientOf(request("1.2.3.4")); + for (const prefix of ["::ffff:", "64:ff9b::"]) { + expect(await clientOf(request(`${prefix}1.2.3.4`))).toBe(client); + expect(await clientOf(request(`${prefix}102:304`))).toBe(client); + expect(await clientOf(request(`${prefix}1.2.3.5`))).not.toBe(client); + expect(await clientOf(request(`${prefix}102:305`))).not.toBe(client); + } + }); + + it("is opaque and fixed in length, and has a bucket for no address", async () => { + const client = await clientOf(request("203.0.113.7")); + expect(client).toMatch(/^[0-9a-f]{16}$/); + expect(client).not.toContain("203"); + const missing = await clientOf(request()); + expect(missing).toMatch(/^[0-9a-f]{16}$/); + expect(await clientOf(request())).toBe(missing); + expect(missing).not.toBe(client); + }); +}); diff --git a/packages/relay/tests/link/routes.test.ts b/packages/relay/tests/link/routes.test.ts new file mode 100644 index 00000000..73a24a40 --- /dev/null +++ b/packages/relay/tests/link/routes.test.ts @@ -0,0 +1,497 @@ +import { + LINK_HEADERS, + LINK_MAX_SEALED_BYTES, + LINK_MAX_TTL_S, + LINK_MAX_VIEWS, + type LinkPutMode, + newLinkId, +} from "@mdsync/protocol"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleLinkRequest, type LinkRouteEnv } from "../../src/link/routes"; +import { type LinkSettings, LinkStore } from "../../src/link/store"; +import { fingerprint } from "../../src/secret"; +import { memorySql } from "../helpers/memory-sql"; + +const ADMIN = "admin-secret"; +const GATE = "G".repeat(43); +const SALT = "S".repeat(22); +const BLOB = Uint8Array.from([1, 2, 3, 4, 5]); +const NOW_MS = 1_760_000_000_000; +const NOW_S = NOW_MS / 1000; + +/** The Durable Object namespace as the worker sees it: one real store per id, no network. */ +function makeEnv(): LinkRouteEnv { + const stores = new Map(); + const storeOf = (id: string) => { + let store = stores.get(id); + if (!store) { + store = new LinkStore(memorySql()); + stores.set(id, store); + } + return store; + }; + const LINK = { + idFromName: (name: string) => name, + get: (id: string) => ({ + put: async ( + blob: ArrayBuffer, + settings: LinkSettings, + mode: LinkPutMode, + ) => storeOf(id).put(blob, settings, mode), + open: async (gate: string | null, client: string) => + storeOf(id).open(gate, client), + status: async () => storeOf(id).status(), + meta: async () => storeOf(id).meta(), + revoke: async () => storeOf(id).revoke(), + }), + }; + return { RELAY_SECRET: ADMIN, LINK } as unknown as LinkRouteEnv; +} + +async function call( + env: LinkRouteEnv, + path: string, + init: RequestInit & { admin?: boolean } = {}, +): Promise { + const headers = new Headers(init.headers); + if (init.admin) headers.set("X-Mdsync-Admin", ADMIN); + const url = new URL(`https://relay.example.com${path}`); + const response = await handleLinkRequest( + new Request(url, { ...init, headers }), + env, + url, + ); + if (!response) throw new Error(`not a link route: ${path}`); + return response; +} + +function put( + env: LinkRouteEnv, + id: string, + query = "", + headers: Record = {}, + body: BodyInit = BLOB, +) { + return call(env, `/link/${id}${query}`, { + method: "PUT", + admin: true, + headers: { [LINK_HEADERS.gate]: GATE, ...headers }, + body, + }); +} + +const open = ( + env: LinkRouteEnv, + id: string, + gate: string | null = GATE, + ip = "203.0.113.7", +) => + call(env, `/link/${id}/open`, { + method: "POST", + headers: { "CF-Connecting-IP": ip }, + body: gate === null ? undefined : JSON.stringify({ gate }), + }); + +const protectedHeaders = { + [LINK_HEADERS.gate]: GATE, + [LINK_HEADERS.salt]: SALT, +}; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW_MS); +}); +afterEach(() => vi.useRealTimers()); + +describe("routing", () => { + it("ignores anything outside /link/", async () => { + const url = new URL("https://relay.example.com/share/sign"); + expect( + await handleLinkRequest(new Request(url), makeEnv(), url), + ).toBeNull(); + }); + + it("answers an unknown route or a malformed id with 404", async () => { + const env = makeEnv(); + expect((await call(env, "/link/nope")).status).toBe(404); + expect((await call(env, `/link/${newLinkId()}/other`)).status).toBe(404); + expect((await call(env, `/link/${newLinkId()}/open/extra`)).status).toBe( + 404, + ); + }); + + it("answers a wrong method with 405 and the allowed set", async () => { + const env = makeEnv(); + const id = newLinkId(); + expect((await call(env, `/link/${id}`)).headers.get("Allow")).toBe( + "DELETE, PUT", + ); + expect((await call(env, `/link/${id}/open`)).headers.get("Allow")).toBe( + "POST", + ); + expect( + (await call(env, `/link/${id}/meta`, { method: "POST" })).status, + ).toBe(405); + expect( + (await call(env, `/link/${id}/status`, { method: "POST" })).status, + ).toBe(405); + }); +}); + +describe("owner routes", () => { + it("refuse everything without the relay secret", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id); + const bare = [ + call(env, `/link/${id}`, { method: "PUT", body: BLOB }), + call(env, `/link/${id}`, { method: "DELETE" }), + call(env, `/link/${id}/status`), + ]; + for (const response of await Promise.all(bare)) { + expect(response.status).toBe(401); + } + expect((await open(env, id)).status).toBe(200); + }); + + it("fail closed when the deployment has no secret", async () => { + const env = { ...makeEnv(), RELAY_SECRET: "" } as LinkRouteEnv; + expect((await put(env, newLinkId())).status).toBe(401); + }); + + it("counts the ttl from the end of the upload", async () => { + const env = makeEnv(); + const id = newLinkId(); + const slow = new ReadableStream({ + pull(controller) { + vi.setSystemTime(NOW_MS + 10_000); + controller.enqueue(BLOB); + controller.close(); + }, + }); + const url = new URL(`https://relay.example.com/link/${id}?ttl=5`); + const response = await handleLinkRequest( + new Request(url, { + method: "PUT", + headers: { "X-Mdsync-Admin": ADMIN, [LINK_HEADERS.gate]: GATE }, + body: slow, + duplex: "half", + } as RequestInit), + env, + url, + ); + expect(await response?.json()).toMatchObject({ expires: NOW_S + 15 }); + expect((await open(env, id)).status).toBe(200); + }); + + it("store a link and report its status", async () => { + const env = makeEnv(); + const id = newLinkId(); + const expires = Math.floor(Date.now() / 1000) + 3600; + const stored = await put(env, id, "?maxViews=3&ttl=3600"); + expect(await stored.json()).toEqual({ + stored: true, + size: BLOB.length, + expires, + }); + await open(env, id); + const status = await call(env, `/link/${id}/status`, { admin: true }); + expect(await status.json()).toEqual({ + views: 1, + maxViews: 3, + expires, + protected: false, + size: BLOB.length, + }); + }); + + it("revoke a link, repeatably", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id); + for (let i = 0; i < 2; i++) { + const response = await call(env, `/link/${id}`, { + method: "DELETE", + admin: true, + }); + expect(await response.json()).toEqual({ revoked: true }); + } + expect((await open(env, id)).status).toBe(404); + expect( + (await call(env, `/link/${id}/status`, { admin: true })).status, + ).toBe(404); + }); + + it("never replace a link that stands, unless asked to update it", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id); + const clash = await put(env, id, "", {}, Uint8Array.from([7])); + expect(clash.status).toBe(409); + expect(((await clash.json()) as { error: string }).error).toBe("exists"); + const opened = await open(env, id); + expect(new Uint8Array(await opened.arrayBuffer())).toEqual(BLOB); + }); + + it("update a link in place and keep its views", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=5&ttl=3600"); + await open(env, id); + const updated = await put( + env, + id, + "?update=1&maxViews=1&ttl=60", + {}, + Uint8Array.from([9, 9]), + ); + expect(updated.status).toBe(200); + expect(await updated.json()).toEqual({ stored: true, size: 2 }); + const status = await call(env, `/link/${id}/status`, { admin: true }); + expect(await status.json()).toMatchObject({ + views: 1, + maxViews: 5, + expires: NOW_S + 3600, + }); + const response = await open(env, id); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + Uint8Array.from([9, 9]), + ); + expect(response.headers.get(LINK_HEADERS.viewsLeft)).toBe("3"); + }); +}); + +describe("updating", () => { + it("refuses a wrong gate with 403 and keeps the old blob", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id); + const response = await put( + env, + id, + "?update=1", + { [LINK_HEADERS.gate]: "W".repeat(43) }, + Uint8Array.from([9]), + ); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ error: "gate" }); + expect(new Uint8Array(await (await open(env, id)).arrayBuffer())).toEqual( + BLOB, + ); + }); + it("is 404 for a link that is spent, revoked or was never made", async () => { + const env = makeEnv(); + const spent = newLinkId(); + const revoked = newLinkId(); + await put(env, spent, "?maxViews=1"); + await open(env, spent); + await put(env, revoked); + await call(env, `/link/${revoked}`, { method: "DELETE", admin: true }); + for (const id of [spent, revoked, newLinkId()]) { + expect((await put(env, id, "?update=1")).status).toBe(404); + expect((await open(env, id)).status).toBe(404); + } + }); +}); + +describe("storing validates", () => { + const reasons: [string, string, Record][] = [ + ["a zero view limit", "?maxViews=0", {}], + ["a view limit above the cap", `?maxViews=${LINK_MAX_VIEWS + 1}`, {}], + ["a fractional view limit", "?maxViews=1.5", {}], + ["a text view limit", "?maxViews=many", {}], + ["a zero ttl", "?ttl=0", {}], + ["a ttl past the cap", `?ttl=${LINK_MAX_TTL_S + 1}`, {}], + ["a missing gate", "", {}], + ["a salt without a gate", "", { [LINK_HEADERS.salt]: SALT }], + [ + "a malformed gate", + "", + { [LINK_HEADERS.gate]: "short", [LINK_HEADERS.salt]: SALT }, + ], + [ + "a malformed salt", + "", + { [LINK_HEADERS.gate]: GATE, [LINK_HEADERS.salt]: "short" }, + ], + ]; + + it.each(reasons)("refuses %s", async (_name, query, headers) => { + const env = makeEnv(); + const id = newLinkId(); + expect( + ( + await call(env, `/link/${id}${query}`, { + method: "PUT", + admin: true, + headers: + _name === "a missing gate" || _name === "a salt without a gate" + ? headers + : { [LINK_HEADERS.gate]: GATE, ...headers }, + body: BLOB, + }) + ).status, + ).toBe(400); + expect((await open(env, id)).status).toBe(404); + }); + + it("refuses an empty body and one over the cap", async () => { + const env = makeEnv(); + const id = newLinkId(); + expect((await put(env, id, "", {}, new Uint8Array(0))).status).toBe(400); + const big = new Uint8Array(LINK_MAX_SEALED_BYTES + 1); + expect((await put(env, id, "", {}, big)).status).toBe(413); + expect( + (await put(env, id, "", { "Content-Length": String(big.length) }, "x")) + .status, + ).toBe(413); + expect((await open(env, id)).status).toBe(404); + }); + + it("takes a body of exactly the cap", async () => { + const env = makeEnv(); + const id = newLinkId(); + const body = new Uint8Array(LINK_MAX_SEALED_BYTES); + expect((await put(env, id, "", {}, body)).status).toBe(200); + }); +}); + +describe("opening", () => { + it("refuses an unprotected link without its gate and spends no view", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=1"); + expect((await open(env, id, null)).status).toBe(401); + const status = await call(env, `/link/${id}/status`, { admin: true }); + expect(await status.json()).toMatchObject({ views: 0, protected: false }); + expect((await open(env, id)).status).toBe(200); + }); + + it.each([false, true])( + "refuses an oversized open body with Content-Length %s", + async (withLength) => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=1"); + const body = JSON.stringify({ gate: GATE, padding: "x".repeat(1024) }); + const response = await call(env, `/link/${id}/open`, { + method: "POST", + body, + headers: withLength ? { "Content-Length": String(body.length) } : {}, + }); + expect(response.status).toBe(401); + const status = await call(env, `/link/${id}/status`, { admin: true }); + expect(await status.json()).toMatchObject({ views: 0 }); + expect((await open(env, id)).status).toBe(200); + }, + ); + it("hands out the sealed bytes without caching and says how many views are left", async () => { + const env = makeEnv(); + const id = newLinkId(); + const expires = NOW_S + 3600; + await put(env, id, "?maxViews=2&ttl=3600"); + const first = await open(env, id); + expect(first.status).toBe(200); + expect(first.headers.get("Cache-Control")).toBe("no-store"); + expect(first.headers.get(LINK_HEADERS.viewsLeft)).toBe("1"); + expect(first.headers.get(LINK_HEADERS.expires)).toBe(String(expires)); + expect(new Uint8Array(await first.arrayBuffer())).toEqual(BLOB); + const last = await open(env, id); + expect(last.headers.get(LINK_HEADERS.viewsLeft)).toBe("0"); + expect(last.headers.get(LINK_HEADERS.expires)).toBe(String(expires)); + }); + + it("names no count for an unlimited link", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id); + const response = await open(env, id); + expect(response.headers.has(LINK_HEADERS.viewsLeft)).toBe(false); + expect(response.headers.has(LINK_HEADERS.expires)).toBe(false); + }); + + it("is gone for a spent, expired, revoked or never-made link alike", async () => { + const env = makeEnv(); + const spent = newLinkId(); + const expiring = newLinkId(); + await put(env, spent, "?maxViews=1"); + await open(env, spent); + await put(env, expiring, "?ttl=60"); + vi.setSystemTime(Date.now() + 61_000); + const answers = await Promise.all( + [spent, expiring, newLinkId()].map(async (id) => { + const response = await open(env, id); + return [response.status, await response.json()]; + }), + ); + expect(new Set(answers.map((answer) => JSON.stringify(answer))).size).toBe( + 1, + ); + expect(answers[0]?.[0]).toBe(404); + }); + + it("serves exactly one of many simultaneous opens of a one-view link", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=1"); + const statuses = await Promise.all( + Array.from({ length: 25 }, async () => (await open(env, id)).status), + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + expect(statuses.filter((status) => status === 404)).toHaveLength(24); + }); + + it("reports meta without spending a view", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=1", protectedHeaders); + const meta = await call(env, `/link/${id}/meta`); + expect(await meta.json()).toEqual({ protected: true, salt: SALT }); + expect((await open(env, id, GATE)).status).toBe(200); + expect((await call(env, `/link/${id}/meta`)).status).toBe(404); + }); +}); + +describe("passphrase", () => { + it("stores a hash of the gate, never the gate", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "", protectedHeaders); + const stored = JSON.stringify( + await (await call(env, `/link/${id}/status`, { admin: true })).json(), + ); + expect(stored).not.toContain(GATE); + expect(await fingerprint(GATE)).toHaveLength(64); + }); + + it("opens with the gate, refuses a wrong or missing one, and spends no view on those", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "?maxViews=1", protectedHeaders); + const wrong = await open(env, id, "W".repeat(43)); + expect(wrong.status).toBe(401); + expect(wrong.headers.get("Retry-After")).toBeNull(); + expect(((await wrong.json()) as { error: string }).error).toBe("gate"); + expect((await open(env, id, null)).status).toBe(401); + expect((await open(env, id, "x".repeat(500))).status).toBe(401); + expect((await open(env, id, GATE)).status).toBe(200); + }); + + it("answers 429 with Retry-After during the cooldown", async () => { + const env = makeEnv(); + const id = newLinkId(); + await put(env, id, "", protectedHeaders); + for (let i = 0; i < 4; i++) await open(env, id, "W".repeat(43)); + const fifth = await open(env, id, "W".repeat(43)); + expect(fifth.status).toBe(401); + expect(fifth.headers.get("Retry-After")).toBe("60"); + const locked = await open(env, id, GATE); + expect(locked.status).toBe(429); + expect(locked.headers.get("Retry-After")).toBe("60"); + const other = await open(env, id, GATE, "198.51.100.9"); + expect(other.status).toBe(200); + vi.setSystemTime(Date.now() + 60_000); + expect((await open(env, id, GATE)).status).toBe(200); + }); +}); diff --git a/packages/relay/tests/link/shell.test.ts b/packages/relay/tests/link/shell.test.ts new file mode 100644 index 00000000..f92cdb0e --- /dev/null +++ b/packages/relay/tests/link/shell.test.ts @@ -0,0 +1,82 @@ +import { newLinkId } from "@mdsync/protocol"; +import { describe, expect, it } from "vitest"; + +import { handleShellRequest, type ShellEnv } from "../../src/link/shell"; + +const PAGE = "viewer"; + +function envWith( + response: Response | null = new Response(PAGE, { + headers: { "Content-Type": "text/html", ETag: '"v1"' }, + }), +) { + const asked: string[] = []; + const env = { + ASSETS: { + fetch: async (input: URL) => { + asked.push(input.pathname); + return response ?? new Response("nope", { status: 404 }); + }, + }, + } as unknown as ShellEnv; + return { env, asked }; +} + +function get(path: string, env: ShellEnv, method = "GET") { + const url = new URL(`https://relay.example.com${path}`); + return handleShellRequest(new Request(url, { method }), env, url); +} + +describe("the link page", () => { + it("serves the viewer under security headers", async () => { + const { env, asked } = envWith(); + const response = await get(`/s/${newLinkId()}`, env); + expect(response?.status).toBe(200); + expect(await response?.text()).toBe(PAGE); + expect(asked).toEqual(["/viewer/"]); + const csp = response?.headers.get("Content-Security-Policy") ?? ""; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain("unsafe-eval"); + expect(response?.headers.get("Referrer-Policy")).toBe("no-referrer"); + expect(response?.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(response?.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response?.headers.get("Content-Type")).toBe("text/html"); + }); + + it("answers HEAD without a body", async () => { + const response = await get(`/s/${newLinkId()}`, envWith().env, "HEAD"); + expect(response?.status).toBe(200); + expect(await response?.text()).toBe(""); + }); + + it("takes a trailing slash and leaves other paths alone", async () => { + const { env } = envWith(); + expect((await get(`/s/${newLinkId()}/`, env))?.status).toBe(200); + expect(await get("/share/sign", env)).toBeNull(); + expect(await get("/s", env)).toBeNull(); + expect(await get(`/s/${newLinkId()}/extra`, env)).toBeNull(); + }); + + it("refuses an id that cannot be a link's, without touching the assets", async () => { + const { env, asked } = envWith(); + expect((await get("/s/short", env))?.status).toBe(404); + expect((await get("/s/..%2F..%2Fx", env))?.status).toBe(404); + expect(asked).toEqual([]); + }); + + it("refuses other methods", async () => { + const response = await get(`/s/${newLinkId()}`, envWith().env, "POST"); + expect(response?.status).toBe(405); + expect(response?.headers.get("Allow")).toBe("GET, HEAD"); + }); + + it("says so when the viewer was never deployed", async () => { + const response = await get( + `/s/${newLinkId()}`, + envWith(new Response("", { status: 404 })).env, + ); + expect(response?.status).toBe(503); + }); +}); diff --git a/packages/relay/tests/link/store.test.ts b/packages/relay/tests/link/store.test.ts new file mode 100644 index 00000000..d6896461 --- /dev/null +++ b/packages/relay/tests/link/store.test.ts @@ -0,0 +1,342 @@ +import { LINK_MAX_SEALED_BYTES } from "@mdsync/protocol"; +import { describe, expect, it } from "vitest"; + +import { type LinkSettings, LinkStore } from "../../src/link/store"; +import { memorySql } from "../helpers/memory-sql"; + +const OPEN: LinkSettings = { + maxViews: null, + expires: null, + gate: "ef".repeat(32), + salt: null, +}; +const PROTECTED: LinkSettings = { ...OPEN, gate: "ab".repeat(32), salt: "s" }; +const T0 = 1_760_000_000_000; +const ME = "client-me"; +const RIGHT = PROTECTED.gate as string; +const WRONG = "cd".repeat(32); + +function setup(settings: LinkSettings = OPEN, bytes = 100) { + const sql = memorySql(); + const clock = { now: T0 }; + const ended = { count: 0 }; + const store = new LinkStore( + sql, + () => clock.now, + () => ended.count++, + ); + expect(store.put(blob(bytes, 1), settings, "create")).toBe("stored"); + return { sql, clock, ended, store }; +} + +function blob(length: number, seed: number): ArrayBuffer { + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) bytes[i] = (i * 31 + seed) % 251; + return bytes.buffer; +} + +function served(result: ReturnType): Uint8Array { + if (!result.ok) throw new Error(`refused: ${result.reason}`); + return new Uint8Array(result.blob); +} + +const open = (store: LinkStore, gate: string | null = OPEN.gate, client = ME) => + store.open(gate, client); + +describe("storing and serving", () => { + it("serves back exactly what was stored", () => { + const { store } = setup(); + expect(served(open(store))).toEqual(new Uint8Array(blob(100, 1))); + }); + + it("keeps a blob of several chunks intact", () => { + const size = 2.5 * 1024 * 1024; + const { store } = setup(OPEN, size); + // A byte-for-byte toEqual on megabytes takes seconds. + expect( + Buffer.from(served(open(store))).equals(Buffer.from(blob(size, 1))), + ).toBe(true); + }); + + it("takes a blob of the largest size and refuses larger or empty ones", () => { + const { store } = setup(); + expect(() => + store.put(blob(LINK_MAX_SEALED_BYTES, 2), OPEN, "update"), + ).not.toThrow(); + expect(() => + store.put(blob(LINK_MAX_SEALED_BYTES + 1, 2), OPEN, "update"), + ).toThrow(); + expect(() => store.put(new ArrayBuffer(0), OPEN, "update")).toThrow(); + }); + + it("keeps serving an unlimited link", () => { + const { store } = setup(); + for (let i = 0; i < 20; i++) { + expect(open(store)).toMatchObject({ ok: true, expires: null }); + } + expect(store.status()?.views).toBe(20); + }); +}); + +describe("view limit", () => { + it("serves the last view and erases the link with it", () => { + const expires = T0 / 1000 + 60; + const { store, ended } = setup({ ...OPEN, maxViews: 2, expires }); + expect(open(store)).toMatchObject({ ok: true, viewsLeft: 1, expires }); + expect(open(store)).toMatchObject({ ok: true, viewsLeft: 0, expires }); + expect(ended.count).toBe(1); + expect(open(store)).toEqual({ ok: false, reason: "gone" }); + expect(store.status()).toBeNull(); + expect(store.meta()).toBeNull(); + }); + + it("serves a one-view link once", () => { + const { store } = setup({ ...OPEN, maxViews: 1 }); + const results = Array.from({ length: 5 }, () => open(store)); + expect(results.filter((result) => result.ok)).toHaveLength(1); + }); + + it("counts nothing for status and meta", () => { + const { store } = setup({ ...OPEN, maxViews: 3 }); + store.status(); + store.meta(); + expect(store.status()?.views).toBe(0); + }); +}); + +describe("expiry", () => { + it("is gone once the time passes, and gives its storage back", () => { + const expires = T0 / 1000 + 60; + const { store, clock, ended } = setup({ ...OPEN, expires }); + clock.now += 59_000; + expect(open(store)).toMatchObject({ ok: true, expires }); + clock.now += 1_000; + expect(open(store)).toEqual({ ok: false, reason: "gone" }); + expect(ended.count).toBe(1); + }); +}); + +describe("passphrase gate", () => { + it("refuses a missing gate on an unprotected link without spending a view", () => { + const { store } = setup({ ...OPEN, maxViews: 1 }); + expect(open(store, null)).toEqual({ + ok: false, + reason: "gate", + retryAfter: null, + }); + expect(store.status()).toMatchObject({ views: 0, protected: false }); + expect(open(store).ok).toBe(true); + }); + it("serves only the right gate", () => { + const { store } = setup(PROTECTED); + expect(open(store, WRONG)).toMatchObject({ ok: false, reason: "gate" }); + expect(open(store, null)).toMatchObject({ ok: false, reason: "gate" }); + expect(open(store, RIGHT).ok).toBe(true); + }); + + it("spends no view on a wrong gate", () => { + const { store } = setup({ ...PROTECTED, maxViews: 1 }); + for (let i = 0; i < 3; i++) open(store, WRONG); + expect(store.status()?.views).toBe(0); + expect(open(store, RIGHT).ok).toBe(true); + }); + + it("tells the viewer a passphrase is asked for, with its salt", () => { + const { store } = setup(PROTECTED); + expect(store.meta()).toEqual({ protected: true, salt: "s" }); + expect(store.status()?.protected).toBe(true); + const unprotected = setup().store; + expect(unprotected.meta()).toEqual({ protected: false, salt: null }); + expect(unprotected.status()?.protected).toBe(false); + }); + + it("cools a client down after five wrong guesses, even for the right gate", () => { + const { store, clock } = setup(PROTECTED); + for (let i = 0; i < 4; i++) { + expect(open(store, WRONG)).toEqual({ + ok: false, + reason: "gate", + retryAfter: null, + }); + } + expect(open(store, WRONG)).toEqual({ + ok: false, + reason: "gate", + retryAfter: 60, + }); + clock.now += 10_000; + expect(open(store, RIGHT)).toEqual({ + ok: false, + reason: "cooldown", + retryAfter: 50, + }); + clock.now += 50_000; + expect(open(store, RIGHT).ok).toBe(true); + }); + + it("leaves every other client's access alone", () => { + const { store } = setup(PROTECTED); + for (let i = 0; i < 12; i++) open(store, WRONG, "attacker"); + expect(open(store, RIGHT, "attacker")).toMatchObject({ + reason: "cooldown", + }); + expect(open(store, RIGHT, "reader").ok).toBe(true); + }); + + it("keeps a cooling client's lock when many others fail after it", () => { + const { store, clock } = setup(PROTECTED); + for (let i = 0; i < 5; i++) open(store, WRONG, "locked"); + for (let i = 0; i < 300; i++) { + clock.now += 1; + open(store, WRONG, `other-${i}`); + } + expect(open(store, RIGHT, "locked")).toMatchObject({ reason: "cooldown" }); + }); + + it("doubles the cooldown on each further miss up to an hour", () => { + const { store, clock } = setup(PROTECTED); + const waits: (number | null)[] = []; + for (let i = 0; i < 14; i++) { + const result = open(store, WRONG); + waits.push( + result.ok || result.reason === "gone" ? -1 : result.retryAfter, + ); + clock.now += 3_601_000; + } + expect(waits.slice(4, 9)).toEqual([60, 120, 240, 480, 960]); + expect(waits.at(-1)).toBe(3600); + }); + + it("forgives a client's earlier misses after a good open", () => { + const { store } = setup(PROTECTED); + for (let i = 0; i < 4; i++) open(store, WRONG); + expect(open(store, RIGHT).ok).toBe(true); + for (let i = 0; i < 4; i++) { + expect(open(store, WRONG)).toMatchObject({ retryAfter: null }); + } + }); + + it("remembers a bounded number of clients, the least recent going first", () => { + const { store, sql } = setup(PROTECTED); + for (let i = 0; i < 300; i++) open(store, WRONG, `client-${i}`); + const [{ count }] = sql + .exec("SELECT count(*) AS count FROM throttle") + .toArray() as [{ count: number }]; + expect(count).toBe(128); + }); +}); + +describe("update", () => { + it("replaces the blob and keeps views, limits and expiry", () => { + const expires = T0 / 1000 + 60; + const { store } = setup({ ...OPEN, maxViews: 5, expires }); + open(store); + open(store); + expect(store.put(blob(50, 9), { ...OPEN, maxViews: 1 }, "update")).toBe( + "stored", + ); + expect(store.status()).toMatchObject({ views: 2, maxViews: 5, expires }); + expect(served(open(store))).toEqual(new Uint8Array(blob(50, 9))); + }); + + it("ignores a lowered limit on update", () => { + const { store } = setup({ ...OPEN, maxViews: 5 }); + open(store); + open(store); + store.put(blob(10, 3), { ...OPEN, maxViews: 2 }, "update"); + expect(open(store)).toMatchObject({ ok: true, viewsLeft: 2 }); + }); + + it("never brings back a link that is spent, expired or revoked", () => { + const { store, clock } = setup({ + ...OPEN, + maxViews: 1, + expires: T0 / 1000 + 10, + }); + open(store); + expect(store.put(blob(10, 3), OPEN, "update")).toBe("gone"); + expect(store.status()).toBeNull(); + + const expiring = setup({ ...OPEN, expires: T0 / 1000 + 10 }); + expiring.clock.now += 20_000; + expect(expiring.store.put(blob(10, 3), OPEN, "update")).toBe("gone"); + + const revoked = setup(); + revoked.store.revoke(); + expect(revoked.store.put(blob(10, 3), OPEN, "update")).toBe("gone"); + expect(clock.now).toBe(T0); + }); + + it("clears every client's cooldown", () => { + const { store } = setup(PROTECTED); + for (let i = 0; i < 5; i++) open(store, WRONG); + store.put(blob(10, 3), PROTECTED, "update"); + expect(open(store, RIGHT).ok).toBe(true); + }); + + it("refuses a wrong update gate without changing the blob or settings", () => { + const { store } = setup(PROTECTED); + const before = store.status(); + expect(store.put(blob(10, 3), OPEN, "update")).toBe("mismatch"); + expect(store.status()).toEqual(before); + expect(store.meta()).toEqual({ protected: true, salt: "s" }); + expect(served(open(store, RIGHT))).toEqual(new Uint8Array(blob(100, 1))); + }); + + it("keeps the salt when updating with the right gate", () => { + const { store } = setup(PROTECTED); + expect(store.put(blob(10, 3), { ...PROTECTED, salt: null }, "update")).toBe( + "stored", + ); + expect(store.meta()).toEqual({ protected: true, salt: "s" }); + }); +}); + +describe("creating", () => { + it("never replaces a link that stands", () => { + const { store } = setup({ ...OPEN, maxViews: 5 }); + open(store); + expect(store.put(blob(10, 4), OPEN, "create")).toBe("exists"); + expect(served(open(store))).toEqual(new Uint8Array(blob(100, 1))); + }); + + it("starts at no views once the link before it ended", () => { + const { store, clock } = setup({ ...OPEN, expires: T0 / 1000 + 10 }); + open(store); + clock.now += 20_000; + expect(store.put(blob(10, 3), { ...OPEN, maxViews: 3 }, "create")).toBe( + "stored", + ); + expect(store.status()).toMatchObject({ views: 0, maxViews: 3 }); + }); + + it("can reuse the id of a link that was spent", () => { + const { store } = setup({ ...OPEN, maxViews: 1 }); + open(store); + expect(store.put(blob(10, 4), { ...OPEN, maxViews: 2 }, "create")).toBe( + "stored", + ); + expect(store.status()?.views).toBe(0); + }); +}); + +describe("revoke and unknown links", () => { + it("erases at once and can be repeated", () => { + const { store, ended } = setup(); + store.revoke(); + store.revoke(); + expect(ended.count).toBe(1); + expect(open(store)).toEqual({ ok: false, reason: "gone" }); + }); + + it("makes no tables when asked about a link nobody created", () => { + const sql = memorySql(); + const store = new LinkStore(sql); + expect(open(store)).toEqual({ ok: false, reason: "gone" }); + expect(store.status()).toBeNull(); + expect(store.meta()).toBeNull(); + expect(store.put(blob(10, 1), OPEN, "update")).toBe("gone"); + store.revoke(); + expect(sql.exec("SELECT name FROM sqlite_master").toArray()).toEqual([]); + }); +}); diff --git a/packages/relay/wrangler.toml b/packages/relay/wrangler.toml index d1b426de..6a1007f8 100644 --- a/packages/relay/wrangler.toml +++ b/packages/relay/wrangler.toml @@ -11,9 +11,17 @@ binding = "SHARE_TOKENS" # the worker stays inside the Workers free tier. [durable_objects] bindings = [ - { name = "HUB", class_name = "Hub" } + { name = "HUB", class_name = "Hub" }, + { name = "LINK", class_name = "Link" } ] +# The link viewer (packages/viewer, built to dist/). Only /s/ runs the worker first, to add the +# security headers; the viewer's scripts and styles are served as plain assets. +[assets] +directory = "../viewer/dist" +binding = "ASSETS" +run_worker_first = ["/s/*"] + [[migrations]] tag = "v1" new_sqlite_classes = ["SyncRelay"] @@ -23,6 +31,11 @@ tag = "v2" new_sqlite_classes = ["Hub"] deleted_classes = ["SyncRelay"] +# One object per share link: sealed note, view counter, passphrase gate. +[[migrations]] +tag = "v3" +new_sqlite_classes = ["Link"] + # Secrets (never hardcode; the Deploy Relay workflow uploads them): # - RELAY_SECRET admin auth for shared folders; hub channel grants derive from it # - GDRIVE_CLIENT_ID optional, only for the Google Drive token exchange diff --git a/packages/viewer/build.mjs b/packages/viewer/build.mjs new file mode 100644 index 00000000..df198c80 --- /dev/null +++ b/packages/viewer/build.mjs @@ -0,0 +1,23 @@ +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import esbuild from "esbuild"; + +const root = (path) => fileURLToPath(new URL(path, import.meta.url)); +// The relay serves this folder under /viewer/ (wrangler.toml `assets`). +const out = root("./dist/viewer"); + +rmSync(root("./dist"), { recursive: true, force: true }); +mkdirSync(out, { recursive: true }); + +await esbuild.build({ + entryPoints: [root("./src/main.ts"), root("./src/viewer.css")], + entryNames: "viewer", + outdir: out, + bundle: true, + minify: true, + format: "iife", + target: ["es2022", "chrome111", "safari16.4"], + tsconfig: root("./tsconfig.json"), + logLevel: "info", +}); +copyFileSync(root("./src/index.html"), `${out}/index.html`); diff --git a/packages/viewer/package.json b/packages/viewer/package.json new file mode 100644 index 00000000..2eff1ee3 --- /dev/null +++ b/packages/viewer/package.json @@ -0,0 +1,26 @@ +{ + "name": "mdsync-viewer", + "version": "1.0.0", + "description": "The page a share link opens: decrypts the note in the browser and shows it.", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc -noEmit", + "test": "vitest run" + }, + "dependencies": { + "@mdsync/protocol": "workspace:*", + "dompurify": "^3.4.16", + "idb-keyval": "^6.3.0", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2" + }, + "devDependencies": { + "@types/turndown": "^5.0.6", + "esbuild": "^0.25.12", + "fake-indexeddb": "^6.2.5", + "jsdom": "^30.1.2", + "vitest": "^4.1.11" + } +} diff --git a/packages/viewer/src/anchors.ts b/packages/viewer/src/anchors.ts new file mode 100644 index 00000000..e7d3cf04 --- /dev/null +++ b/packages/viewer/src/anchors.ts @@ -0,0 +1,99 @@ +import { linkUrl, parseLinkLocation } from "@mdsync/protocol"; + +import { copyText } from "./clipboard"; +import { reveal } from "./sections"; + +/** How long an anchor button shows how the copy went. */ +const RESULT_MS = 1500; + +/** + * Places in the note and the addresses to them, `#/`: an anchor is a heading's slug or an + * element's id, as a footnote's. Each heading gets a button that copies its address. Returns the jump. + */ +export function addAnchors( + body: HTMLElement, + headings: HTMLHeadingElement[], +): (anchor: string | null) => void { + const bySlug = new Map(); + for (const heading of headings) { + const slug = uniqueSlug(heading.textContent ?? "", bySlug); + bySlug.set(slug, heading); + heading.append(anchorButton(heading, slug)); + } + const jump = (anchor: string | null) => { + if (anchor === null) return; + const target = bySlug.get(anchor) ?? withId(body, anchor); + if (!target) return; + reveal(target); + target.scrollIntoView({ block: "start" }); + }; + // The bare `#fn-1` would replace the key: opened in a new tab or copied, the link would be dead. + for (const link of body.querySelectorAll('a[href^="#"]')) { + const id = fragmentId(link.getAttribute("href") ?? ""); + // An SVG link's `href` property is read-only. + link.setAttribute("href", addressOf(id) ?? "#"); + link.addEventListener("click", (event) => { + event.preventDefault(); + jump(id); + }); + } + return jump; +} + +/** The anchor the address names after the key. */ +export function currentAnchor(): string | null { + return parseLinkLocation(location.pathname, location.hash)?.anchor ?? null; +} + +function addressOf(anchor: string): string | null { + const link = parseLinkLocation(location.pathname, location.hash); + return link ? linkUrl(location.origin, link.id, link.key, anchor) : null; +} + +function anchorButton( + heading: HTMLHeadingElement, + anchor: string, +): HTMLButtonElement { + const button = heading.ownerDocument.createElement("button"); + button.type = "button"; + button.className = "heading-anchor"; + button.setAttribute("aria-label", "Copy a link to this section"); + button.addEventListener("click", async () => { + const url = addressOf(anchor); + if (!url) return; + history.replaceState(null, "", url); + heading.scrollIntoView({ block: "start", behavior: "smooth" }); + button.dataset.state = (await copyText(url)) ? "copied" : "failed"; + setTimeout(() => { + delete button.dataset.state; + }, RESULT_MS); + }); + return button; +} + +function withId(body: HTMLElement, id: string): Element | undefined { + return Array.from(body.querySelectorAll("[id]")).find( + (node) => node.id === id, + ); +} + +/** An id with a literal `%` is not valid percent-encoding: it is then its own text. */ +function fragmentId(hash: string): string { + try { + return decodeURIComponent(hash.slice(1)); + } catch { + return hash.slice(1); + } +} + +function uniqueSlug(text: string, taken: Map): string { + const base = + text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-|-$/g, "") || "section"; + let slug = base; + for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`; + return slug; +} diff --git a/packages/viewer/src/api.ts b/packages/viewer/src/api.ts new file mode 100644 index 00000000..f6fad30f --- /dev/null +++ b/packages/viewer/src/api.ts @@ -0,0 +1,61 @@ +/** The relay's public link routes, as the viewer sees them. */ + +import { LINK_HEADERS, type LinkMeta } from "@mdsync/protocol"; + +export type OpenOutcome = + | { + kind: "opened"; + sealed: Uint8Array; + viewsLeft: number | null; + expires: number | null; + } + | { kind: "gone" } + | { kind: "gate"; retryAfter: number | null } + | { kind: "cooldown"; retryAfter: number }; + +export interface LinkApi { + /** Null when the link is gone. Counts no view. */ + meta(id: string): Promise; + /** Counts a view when it opens. */ + open(id: string, gate: string): Promise; +} + +export function createApi(fetcher: typeof fetch = fetch): LinkApi { + return { + async meta(id) { + const response = await fetcher(`/link/${id}/meta`); + if (response.status === 404) return null; + if (!response.ok) throw relayError(response); + return (await response.json()) as LinkMeta; + }, + async open(id, gate) { + const response = await fetcher(`/link/${id}/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gate }), + }); + if (response.ok) { + const left = response.headers.get(LINK_HEADERS.viewsLeft); + const expires = Number(response.headers.get(LINK_HEADERS.expires)); + return { + kind: "opened", + sealed: new Uint8Array(await response.arrayBuffer()), + viewsLeft: left === null ? null : Number(left), + expires: Number.isInteger(expires) && expires > 0 ? expires : null, + }; + } + if (response.status === 404) return { kind: "gone" }; + const retry = Number(response.headers.get("Retry-After")); + const retryAfter = Number.isFinite(retry) && retry > 0 ? retry : null; + if (response.status === 401) return { kind: "gate", retryAfter }; + if (response.status === 429) { + return { kind: "cooldown", retryAfter: retryAfter ?? 60 }; + } + throw relayError(response); + }, + }; +} + +function relayError(response: Response): Error { + return new Error(`The relay answered ${response.status}.`); +} diff --git a/packages/viewer/src/cache.ts b/packages/viewer/src/cache.ts new file mode 100644 index 00000000..3842b763 --- /dev/null +++ b/packages/viewer/src/cache.ts @@ -0,0 +1,59 @@ +import { fromBase64Url, toBase64Url } from "@mdsync/protocol"; + +/** What a reload needs to show the note again without spending another view. */ +export interface CachedLink { + sealed: Uint8Array; + protected: boolean; + salt: string | null; + viewsLeft: number | null; + expires: number | null; +} + +export interface LinkCache { + get(id: string): CachedLink | null; + put(id: string, link: CachedLink): void; +} + +const PREFIX = "mdsync-link:"; + +/** The sealed bytes only, in this tab's session storage: the key and passphrase are never written. */ +export function sessionCache( + storage: Storage | null = tabStorage(), +): LinkCache { + return { + get(id) { + try { + const raw = storage?.getItem(PREFIX + id); + if (!raw) return null; + const stored = JSON.parse(raw) as Omit & { + sealed: string; + }; + return { + ...stored, + sealed: fromBase64Url(stored.sealed), + }; + } catch { + return null; + } + }, + put(id, link) { + try { + storage?.setItem( + PREFIX + id, + JSON.stringify({ ...link, sealed: toBase64Url(link.sealed) }), + ); + } catch { + // A note past the storage quota just costs a view on reload. + } + }, + }; +} + +/** Browsers that block site storage throw on merely reading `sessionStorage`; the note opens uncached then. */ +function tabStorage(): Storage | null { + try { + return sessionStorage; + } catch { + return null; + } +} diff --git a/packages/viewer/src/clipboard.ts b/packages/viewer/src/clipboard.ts new file mode 100644 index 00000000..0f3b90af --- /dev/null +++ b/packages/viewer/src/clipboard.ts @@ -0,0 +1,30 @@ +/** How long a copy button shows how the copy went. */ +const RESULT_MS = 1500; + +/** False when the browser refuses: no permission, or a page not served over https. */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +/** Copies what `text` makes and says on `button` how it went, then puts `label` back. */ +export async function copyWithFeedback( + button: HTMLElement, + label: string, + text: () => string, +): Promise { + let copied = false; + try { + copied = await copyText(text()); + } catch { + // A note that cannot be converted just fails to copy. + } + button.textContent = copied ? "Copied" : "Copy failed"; + setTimeout(() => { + button.textContent = label; + }, RESULT_MS); +} diff --git a/packages/viewer/src/code.ts b/packages/viewer/src/code.ts new file mode 100644 index 00000000..699c0579 --- /dev/null +++ b/packages/viewer/src/code.ts @@ -0,0 +1,17 @@ +import { copyWithFeedback } from "./clipboard"; + +const LABEL = "Copy"; + +/** Obsidian's copy button on each code block: the snapshot's own left without its script. */ +export function addCopyButtons(body: HTMLElement): void { + for (const code of Array.from(body.querySelectorAll("pre > code"))) { + const button = body.ownerDocument.createElement("button"); + button.type = "button"; + button.className = "copy-code-button"; + button.textContent = LABEL; + button.addEventListener("click", () => + copyWithFeedback(button, LABEL, () => code.textContent ?? ""), + ); + code.after(button); + } +} diff --git a/packages/viewer/src/expiry.ts b/packages/viewer/src/expiry.ts new file mode 100644 index 00000000..51e888ef --- /dev/null +++ b/packages/viewer/src/expiry.ts @@ -0,0 +1,28 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** "Expires in 5 min", "in 3 hours", then the date once it is a day or more away. */ +export function expiryText(expires: number, nowMs: number): string { + const leftMs = expires * 1000 - nowMs; + if (leftMs <= 0) { + return "Expired. This tab keeps the note until you close it."; + } + if (leftMs < HOUR_MS) { + return `Expires in ${Math.ceil(leftMs / MINUTE_MS)} min.`; + } + if (leftMs < DAY_MS) { + const hours = Math.round(leftMs / HOUR_MS); + return `Expires in ${hours} ${hours === 1 ? "hour" : "hours"}.`; + } + const date = new Date(expires * 1000); + const sameYear = date.getFullYear() === new Date(nowMs).getFullYear(); + const when = date.toLocaleString(undefined, { + year: sameYear ? undefined : "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return `Expires ${when}.`; +} diff --git a/packages/viewer/src/images.ts b/packages/viewer/src/images.ts new file mode 100644 index 00000000..dd57bbdf --- /dev/null +++ b/packages/viewer/src/images.ts @@ -0,0 +1,8 @@ +/** CSS cannot tell a paragraph holding only an image from one that also has text. */ +export function markLoneImages(body: HTMLElement): void { + for (const p of Array.from(body.querySelectorAll("p"))) { + if (p.querySelector("img") && p.textContent?.trim() === "") { + p.classList.add("lone-image"); + } + } +} diff --git a/packages/viewer/src/index.html b/packages/viewer/src/index.html new file mode 100644 index 00000000..c62021fb --- /dev/null +++ b/packages/viewer/src/index.html @@ -0,0 +1,22 @@ + + + + + + + + + Shared note + + + + + +
+ + + diff --git a/packages/viewer/src/keys.ts b/packages/viewer/src/keys.ts new file mode 100644 index 00000000..5ff27c50 --- /dev/null +++ b/packages/viewer/src/keys.ts @@ -0,0 +1,88 @@ +import type { LinkKeys } from "@mdsync/protocol"; +import { + createStore, + del, + get, + promisifyRequest, + set, + type UseStore, +} from "idb-keyval"; + +const MS_PER_S = 1000; + +/** A protected link's keys kept on this browser, so it opens without the passphrase. */ +export interface RememberedKeys { + get(id: string): Promise; + /** `expires` in Unix seconds; null keeps them until the relay says the link is gone. */ + put(id: string, keys: LinkKeys, expires: number | null): Promise; + drop(id: string): Promise; +} + +interface Entry { + keys: LinkKeys; + expires: number | null; +} + +/** + * IndexedDB, because a `CryptoKey` survives it: the derived key is non-extractable and the passphrase is never + * written. Storage the browser blocks just remembers nothing. + */ +export function browserKeys(): RememberedKeys { + let store: UseStore | undefined; + const db = () => { + if (!store) { + store = createStore("mdsync-viewer", "keys"); + void sweep(store).catch(() => undefined); + } + return store; + }; + const drop = async (id: string) => { + try { + await del(id, db()); + } catch { + // Nothing was kept. + } + }; + return { + async get(id) { + try { + const entry = await get(id, db()); + if (!entry) return null; + if (expired(entry.expires)) { + await drop(id); + return null; + } + return entry.keys; + } catch { + return null; + } + }, + async put(id, keys, expires) { + if (expired(expires)) return; + try { + await set(id, { keys, expires } satisfies Entry, db()); + } catch { + // The link still opens; it asks again next time. + } + }, + drop, + }; +} + +/** Erases the keys of every expired link, not only of the links opened again. */ +function sweep(store: UseStore): Promise { + return store("readwrite", (keys) => { + const cursors = keys.openCursor(); + cursors.onsuccess = () => { + const cursor = cursors.result; + if (!cursor) return; + if (expired((cursor.value as Entry).expires)) cursor.delete(); + cursor.continue(); + }; + return promisifyRequest(keys.transaction); + }); +} + +function expired(expires: number | null): boolean { + return expires !== null && expires * MS_PER_S <= Date.now(); +} diff --git a/packages/viewer/src/main.ts b/packages/viewer/src/main.ts new file mode 100644 index 00000000..62aadcaf --- /dev/null +++ b/packages/viewer/src/main.ts @@ -0,0 +1,29 @@ +import { parseLinkLocation } from "@mdsync/protocol"; + +import { createApi } from "./api"; +import { sessionCache } from "./cache"; +import { browserKeys } from "./keys"; +import { LinkSession } from "./session"; +import { createView } from "./view"; + +const root = document.getElementById("app"); +if (root) { + const link = parseLinkLocation(location.pathname, location.hash); + let session: LinkSession | null = null; + const view = createView(root, { + onPassphrase: (passphrase, remember) => + void session?.submit(passphrase, remember), + }); + if (!link) { + view.show({ kind: "invalid" }); + } else { + session = new LinkSession({ + ...link, + api: createApi(), + cache: sessionCache(), + keys: browserKeys(), + show: view.show, + }); + void session.start(); + } +} diff --git a/packages/viewer/src/markdown.ts b/packages/viewer/src/markdown.ts new file mode 100644 index 00000000..d3071a57 --- /dev/null +++ b/packages/viewer/src/markdown.ts @@ -0,0 +1,83 @@ +import TurndownService from "turndown"; +import { gfm } from "turndown-plugin-gfm"; + +/** + * The note as Markdown, made from the sanitized page rather than from the author's file, so nothing the + * snapshot dropped (properties, comments, vault links) can come back. Vault images have no Markdown form here. + */ +export function noteMarkdown(title: string, body: DocumentFragment): string { + const markdown = service.turndown(body).trim(); + return title ? `# ${title}\n\n${markdown}\n` : `${markdown}\n`; +} + +const service = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", +}); +service.use(gfm); +// Mermaid drawings and callout icons. +service.remove((node) => node.localName === "svg"); + +// The library pads a marker to four columns ("- item", "[x] item"); this is the usual one space. +service.addRule("listItem", { + filter: "li", + replacement: (content, node, options) => { + const item = node as HTMLElement; + const list = item.parentElement; + const marker = + list?.localName === "ol" + ? `${Number(list.getAttribute("start") ?? 1) + Array.from(list.children).indexOf(item)}.` + : (options.bulletListMarker ?? "-"); + const text = content + .replace(/^\n+/, "") + .replace(/\n+$/, "\n") + .replace(/^(\[[ x]\])\s+/, "$1 "); + const indented = text.replace( + /\n(?!$)/g, + `\n${" ".repeat(marker.length + 1)}`, + ); + const more = item.nextElementSibling && !text.endsWith("\n") ? "\n" : ""; + return `${marker} ${indented}${more}`; + }, +}); + +service.addRule("math", { + filter: (node) => node.localName === "math", + replacement: (_content, node) => { + const math = node as HTMLElement; + const tex = math.getAttribute("data-tex") ?? math.textContent ?? ""; + return math.getAttribute("display") === "block" + ? `\n\n$$\n${tex}\n$$\n\n` + : `$${tex}$`; + }, +}); + +service.addRule("callout", { + filter: (node) => node.classList.contains("callout"), + replacement: (_content, node) => { + const callout = node as HTMLElement; + const type = callout.getAttribute("data-callout") ?? "note"; + const title = + callout.querySelector(".callout-title-inner")?.textContent?.trim() ?? ""; + const content = callout.querySelector(".callout-content"); + const lines = [ + `[!${type}]${foldMark(callout)}${title ? ` ${title}` : ""}`, + ...(content ? service.turndown(content).trim().split("\n") : []), + ]; + return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`; + }, +}); + +/** Obsidian's `+` (open) and `-` (folded) after a collapsible callout's type. */ +function foldMark(callout: HTMLElement): string { + if (!callout.classList.contains("is-collapsible")) return ""; + return callout.classList.contains("is-collapsed") ? "-" : "+"; +} + +service.addRule("inlinedImage", { + filter: (node) => + node.localName === "img" && + (node.getAttribute("src") ?? "").startsWith("data:"), + replacement: () => "", +}); diff --git a/packages/viewer/src/note.css b/packages/viewer/src/note.css new file mode 100644 index 00000000..9690f0a0 --- /dev/null +++ b/packages/viewer/src/note.css @@ -0,0 +1,456 @@ +/* The rendered note, by Obsidian's class names. Variables are set in viewer.css. */ +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: var(--line-height-tight); +} + +.markdown-rendered * { + scroll-margin-top: var(--jump-margin); +} + +.markdown-rendered > :first-child, +.markdown-rendered > .heading-section:first-child > :first-child { + margin-top: 0; +} + +.markdown-rendered h1 { + font-size: var(--h1-size); +} +.markdown-rendered h2 { + font-size: var(--h2-size); +} +.markdown-rendered h3 { + font-size: var(--h3-size); +} +.markdown-rendered h4 { + font-size: var(--h4-size); +} +.markdown-rendered h5 { + font-size: var(--h5-size); +} +.markdown-rendered h6 { + font-size: var(--h6-size); +} +.markdown-rendered h5, +.markdown-rendered h6 { + color: var(--text-muted); +} + +/* Heading folds: a chevron in the margin, shown on hover as in Obsidian. */ +.heading-section { + --fold-width: 1.25rem; + position: relative; +} + +.heading-fold { + position: absolute; + top: 0; + left: calc(-1 * var(--fold-width)); + display: flex; + align-items: center; + justify-content: center; + width: var(--fold-width); + height: calc(var(--heading-size) * var(--line-height-tight)); + padding: 0; + border: 0; + background: none; + color: var(--text-faint); + cursor: pointer; + opacity: 0; +} + +.heading-fold::before { + content: ""; + width: 0.4rem; + height: 0.4rem; + border: solid currentColor; + border-width: 0 1.5px 1.5px 0; + transform: translateY(-25%) rotate(45deg); +} + +.heading-section.is-collapsed > .heading-fold::before { + transform: rotate(-45deg); +} + +.heading-section > :hover + .heading-fold, +.heading-fold:hover, +.heading-fold:focus-visible, +.heading-section.is-collapsed > .heading-fold { + opacity: 1; +} + +.heading-section.is-collapsed > .heading-children { + display: none; +} + +.heading-section > :first-child { + cursor: pointer; +} + +.heading-anchor { + margin-left: 0.4em; + padding: 0 0.3em; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-faint); + font: inherit; + font-size: 0.7em; + font-weight: normal; + vertical-align: 0.1em; + cursor: pointer; + opacity: 0; +} + +.heading-anchor::before { + content: "#"; +} + +.heading-anchor[data-state="copied"]::before { + content: "Link copied"; +} + +.heading-anchor[data-state="failed"]::before { + content: "Link is in the address bar"; +} + +.heading-anchor:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +:hover > .heading-anchor, +.heading-anchor:focus-visible, +.heading-anchor[data-state] { + opacity: 1; +} + +@media (hover: none) { + .heading-fold, + .heading-anchor { + opacity: 0.6; + } +} + +a { + color: var(--text-accent); +} + +img, +svg, +video { + max-width: 100%; + height: auto; +} + +hr { + border: 0; + border-top: 1px solid var(--background-modifier-border); + margin: 2rem 0; +} + +blockquote { + margin: 1rem 0; + padding: 0.1rem 1rem; + border-left: 3px solid var(--interactive-accent); + color: var(--text-muted); +} + +mark { + background: var(--text-highlight-bg); + color: inherit; + border-radius: 2px; +} + +code, +pre { + font-family: var(--font-monospace); + font-size: var(--code-size); +} + +code { + padding: 0.1em 0.35em; + border-radius: 4px; + background: var(--code-background); +} + +/* The code scrolls inside the block, so the copy button stays in its corner. */ +pre { + position: relative; + padding: 0.9rem 1rem; + border-radius: 8px; + background: var(--code-background); + color: var(--code-normal); + overflow-x: auto; +} + +pre:has(> code) { + padding: 0; + overflow: visible; +} + +pre > code { + display: block; + padding: 0.9rem 1rem; + border-radius: 0; + background: none; + font-size: inherit; + overflow-x: auto; +} + +.copy-code-button { + position: absolute; + top: 0.4rem; + right: 0.4rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-muted); + font: 0.75rem / 1.5 var(--font-text); + cursor: pointer; + opacity: 0; +} + +pre:hover > .copy-code-button, +.copy-code-button:focus-visible { + opacity: 1; +} + +@media (hover: none) { + .copy-code-button { + opacity: 1; + } +} + +table { + border-collapse: collapse; + display: block; + max-width: 100%; + overflow-x: auto; +} + +th, +td { + padding: 0.4rem 0.8rem; + border: 1px solid var(--background-modifier-border); +} + +th { + background: var(--background-secondary); +} + +.contains-task-list { + padding-left: 0; + list-style: none; +} + +/* Obsidian's checkbox: a rounded box, filled with the accent and ticked when done. */ +.markdown-rendered input[type="checkbox"] { + appearance: none; + position: relative; + width: 1.05em; + height: 1.05em; + margin: 0 0.5em 0 0; + border: 1.5px solid var(--text-faint); + border-radius: 4px; + background: none; + vertical-align: -0.15em; +} + +.markdown-rendered input[type="checkbox"]:checked { + border-color: var(--interactive-accent); + background: var(--interactive-accent); +} + +.markdown-rendered input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + top: 0.08em; + left: 0.3em; + width: 0.25em; + height: 0.5em; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.task-list-item.is-checked { + color: var(--text-muted); + text-decoration: line-through; +} + +.tag { + padding: 0.1em 0.5em; + border-radius: 1em; + background: var(--background-secondary); + color: var(--text-accent); + font-size: 0.9em; + text-decoration: none; +} + +math { + font-size: 1.1em; +} + +/* Scrolling sideways makes the box scroll up and down too: the padding holds limits that hang below the line. */ +math[display="block"] { + display: block math; + margin: 0.6rem 0; + padding-block: 0.4em; + overflow-x: auto; +} + +.footnotes { + margin-top: 2rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* Callouts: one rgb triplet per type, as Obsidian does. */ +.callout { + --callout-color: var(--color-blue-rgb); + margin: 1rem 0; + border-radius: 6px; + background: rgba(var(--callout-color), 0.1); + overflow: hidden; +} + +.callout[data-callout="abstract"], +.callout[data-callout="summary"], +.callout[data-callout="tldr"], +.callout[data-callout="tip"], +.callout[data-callout="hint"], +.callout[data-callout="important"] { + --callout-color: var(--color-cyan-rgb); +} + +.callout[data-callout="success"], +.callout[data-callout="check"], +.callout[data-callout="done"] { + --callout-color: var(--color-green-rgb); +} + +.callout[data-callout="question"], +.callout[data-callout="help"], +.callout[data-callout="faq"], +.callout[data-callout="warning"], +.callout[data-callout="caution"], +.callout[data-callout="attention"] { + --callout-color: var(--color-orange-rgb); +} + +.callout[data-callout="failure"], +.callout[data-callout="fail"], +.callout[data-callout="missing"], +.callout[data-callout="danger"], +.callout[data-callout="error"], +.callout[data-callout="bug"] { + --callout-color: var(--color-red-rgb); +} + +.callout[data-callout="example"] { + --callout-color: var(--color-purple-rgb); +} + +.callout[data-callout="quote"], +.callout[data-callout="cite"] { + --callout-color: 158, 158, 158; +} + +.callout-title { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + color: rgb(var(--callout-color)); + font-weight: 600; +} + +.callout.is-collapsible .callout-title { + cursor: pointer; +} + +.callout-icon svg, +.callout-fold svg { + width: 1.1rem; + height: 1.1rem; + display: block; +} + +.callout-fold svg { + transition: transform 0.1s; +} + +.callout.is-collapsed .callout-fold svg { + transform: rotate(-90deg); +} + +.callout-content { + padding: 0 1rem 0.75rem; +} + +.callout-content > :first-child { + margin-top: 0; +} +.callout-content > :last-child { + margin-bottom: 0; +} +.callout.is-collapsed .callout-content { + display: none; +} + +/* Prism tokens, by Obsidian's code colour variables. */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--code-comment); +} +.token.punctuation { + color: var(--code-punctuation); +} +.token.operator, +.token.entity, +.token.url { + color: var(--code-operator); +} +.token.atrule, +.token.attr-value, +.token.keyword { + color: var(--code-keyword); +} +.token.function, +.token.class-name { + color: var(--code-function); +} +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: var(--code-string); +} +.token.boolean, +.token.number, +.token.constant, +.token.symbol { + color: var(--code-value); +} +.token.regex, +.token.important, +.token.variable { + color: var(--code-important); +} +.token.property { + color: var(--code-property); +} +.token.tag, +.token.deleted { + color: var(--code-tag); +} diff --git a/packages/viewer/src/outline.ts b/packages/viewer/src/outline.ts new file mode 100644 index 00000000..c85f5f6c --- /dev/null +++ b/packages/viewer/src/outline.ts @@ -0,0 +1,62 @@ +import { headingLevel, reveal } from "./sections"; + +/** Fewer headings than this need no outline. */ +const MIN_HEADINGS = 3; +/** A heading above this line from the window's top is the one being read. */ +const READING_LINE_PX = 96; + +/** The note's headings as a list that jumps to them and marks the one being read. Listeners end with `signal`. */ +export function createOutline( + headings: HTMLHeadingElement[], + signal: AbortSignal, +): HTMLElement | null { + const first = headings[0]; + if (!first || headings.length < MIN_HEADINGS) return null; + const doc = first.ownerDocument; + const top = Math.min(...headings.map(headingLevel)); + const items = headings.map((heading) => { + const item = doc.createElement("button"); + item.type = "button"; + item.className = "outline-item"; + item.textContent = heading.textContent?.trim() ?? ""; + item.style.setProperty("--depth", String(headingLevel(heading) - top)); + item.addEventListener("click", () => { + reveal(heading); + heading.scrollIntoView({ block: "start" }); + }); + return item; + }); + const list = doc.createElement("nav"); + list.className = "outline"; + list.setAttribute("aria-label", "Outline"); + list.append(...items); + + let frame = 0; + const markCurrent = () => { + frame = 0; + let current = -1; + headings.forEach((heading, index) => { + // A folded heading has no box, and its zero top would count as passed. + if ( + heading.getClientRects().length > 0 && + heading.getBoundingClientRect().top < READING_LINE_PX + ) { + current = index; + } + }); + for (const [index, item] of items.entries()) { + item.classList.toggle("is-active", index === current); + } + }; + const schedule = () => { + if (frame === 0) frame = requestAnimationFrame(markCurrent); + }; + doc.defaultView?.addEventListener("scroll", schedule, { + passive: true, + signal, + }); + // A fold moves the headings without a scroll. + doc.addEventListener("click", schedule, { signal }); + schedule(); + return list; +} diff --git a/packages/viewer/src/sanitize.ts b/packages/viewer/src/sanitize.ts new file mode 100644 index 00000000..673eb8e7 --- /dev/null +++ b/packages/viewer/src/sanitize.ts @@ -0,0 +1,50 @@ +import { LINK_HREF_ALLOWED, LINK_IMAGE_SRC_ALLOWED } from "@mdsync/protocol"; +import DOMPurify from "dompurify"; + +const purify = DOMPurify(window); + +purify.addHook("afterSanitizeAttributes", (node) => { + if (node instanceof Element && node.localName === "a") { + for (const attribute of ["href", "xlink:href"]) { + const href = node.getAttribute(attribute); + if (href !== null && !LINK_HREF_ALLOWED.test(href)) + node.removeAttribute(attribute); + if (/^https?:/i.test(href ?? "")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer nofollow"); + } + } + } + if (node instanceof HTMLImageElement) { + node.removeAttribute("srcset"); + if (!LINK_IMAGE_SRC_ALLOWED.test(node.getAttribute("src") ?? "")) + node.remove(); + } + if (node instanceof HTMLInputElement) { + if (node.type !== "checkbox") node.remove(); + else node.setAttribute("disabled", ""); + } +}); + +/** The note's HTML as a fragment safe to attach: no script, no handlers, no way out of the page. */ +export function sanitizeNote(html: string): DocumentFragment { + // Unsupported, DOMPurify hands the markup back untouched. + if (!purify.isSupported) { + throw new Error("This browser cannot show a shared note safely."); + } + return purify.sanitize(html, { + USE_PROFILES: { html: true, svg: true, mathMl: true }, + FORBID_TAGS: [ + "form", + "button", + "textarea", + "select", + "style", + "link", + "meta", + "base", + ], + FORBID_ATTR: ["formaction", "srcset"], + RETURN_DOM_FRAGMENT: true, + }); +} diff --git a/packages/viewer/src/sections.ts b/packages/viewer/src/sections.ts new file mode 100644 index 00000000..6a6f3bb8 --- /dev/null +++ b/packages/viewer/src/sections.ts @@ -0,0 +1,79 @@ +const FOLDED_CONTENT = ".heading-children, .callout-content"; + +/** + * Nests each top-level heading with the blocks under it into a section that folds, as Obsidian's reading + * view does: the renderer emits the blocks flat. Footnotes stay outside every section. + */ +export function foldSections(body: HTMLElement): HTMLHeadingElement[] { + const tree = body.ownerDocument.createDocumentFragment(); + const open: { level: number; children: HTMLElement }[] = []; + const headings: HTMLHeadingElement[] = []; + for (const node of Array.from(body.childNodes)) { + if (node instanceof Element && node.classList.contains("footnotes")) { + open.length = 0; + } + if (!(node instanceof HTMLHeadingElement)) { + (open.at(-1)?.children ?? tree).append(node); + continue; + } + const level = headingLevel(node); + while ((open.at(-1)?.level ?? 0) >= level) open.pop(); + const children = body.ownerDocument.createElement("div"); + children.className = "heading-children"; + (open.at(-1)?.children ?? tree).append(section(node, children)); + open.push({ level, children }); + headings.push(node); + } + body.append(tree); + return headings; +} + +/** Unfolds every section and callout that hides the element. */ +export function reveal(element: Element): void { + let content = element.closest(FOLDED_CONTENT); + while (content?.parentElement) { + setFolded(content.parentElement, false); + content = content.parentElement.closest(FOLDED_CONTENT); + } +} + +export function headingLevel(heading: HTMLHeadingElement): number { + return Number(heading.tagName.slice(1)); +} + +function section( + heading: HTMLHeadingElement, + children: HTMLElement, +): HTMLElement { + const doc = heading.ownerDocument; + const section = doc.createElement("section"); + section.className = "heading-section"; + // The fold button sits outside the heading, so it takes the heading's line height from here. + section.style.setProperty( + "--heading-size", + `var(--h${headingLevel(heading)}-size)`, + ); + const fold = doc.createElement("button"); + fold.type = "button"; + fold.className = "heading-fold"; + fold.setAttribute("aria-label", "Fold section"); + fold.setAttribute("aria-expanded", "true"); + const toggle = () => + setFolded(section, !section.classList.contains("is-collapsed")); + fold.addEventListener("click", toggle); + heading.addEventListener("click", (event) => { + // Selecting the heading's text, or a link or button in it, must not fold it. + if ((event.target as Element).closest("a, button")) return; + if (doc.getSelection()?.isCollapsed === false) return; + toggle(); + }); + section.append(heading, fold, children); + return section; +} + +function setFolded(section: Element, folded: boolean): void { + section.classList.toggle("is-collapsed", folded); + section + .querySelector(":scope > .heading-fold") + ?.setAttribute("aria-expanded", String(!folded)); +} diff --git a/packages/viewer/src/session.ts b/packages/viewer/src/session.ts new file mode 100644 index 00000000..d05bd0d8 --- /dev/null +++ b/packages/viewer/src/session.ts @@ -0,0 +1,186 @@ +/** One visit to a link: asks the relay, unlocks, and tells the view what to show. */ + +import { + deriveLinkKeys, + fromBase64Url, + type LinkKeys, + type LinkMeta, + type LinkPayload, + openLinkPayload, +} from "@mdsync/protocol"; + +import type { LinkApi } from "./api"; +import type { CachedLink, LinkCache } from "./cache"; +import type { RememberedKeys } from "./keys"; + +export type ViewState = + | { kind: "loading" } + | { kind: "passphrase"; problem?: string } + | { + kind: "content"; + payload: LinkPayload; + viewsLeft: number | null; + expires: number | null; + } + | { kind: "gone" } + | { kind: "invalid" } + | { kind: "error"; message: string }; + +export interface SessionDeps { + id: string; + key: Uint8Array; + api: LinkApi; + cache: LinkCache; + keys: RememberedKeys; + show(state: ViewState): void; +} + +/** How the keys came: typed for once, typed to be kept, or kept from an earlier visit. */ +type Unlock = "once" | "remember" | "remembered"; + +const BROKEN = "This link is damaged: its address may have been cut short."; +const WRONG = "Wrong passphrase."; + +export class LinkSession { + private meta: LinkMeta | null = null; + private cached: CachedLink | null = null; + + constructor(private readonly deps: SessionDeps) {} + + async start(): Promise { + const { id, key, api, cache, keys, show } = this.deps; + show({ kind: "loading" }); + await this.guarded(async () => { + this.cached = cache.get(id); + this.meta = this.cached ?? (await api.meta(id)); + if (!this.meta) { + await keys.drop(id); + return show({ kind: "gone" }); + } + if (!this.meta.protected) { + return this.open(await deriveLinkKeys(key), "once"); + } + const remembered = await keys.get(id); + if (remembered) return this.open(remembered, "remembered"); + show({ kind: "passphrase" }); + }); + } + + async submit(passphrase: string, remember: boolean): Promise { + const meta = this.meta; + if (!passphrase || !meta?.protected) return; + this.deps.show({ kind: "loading" }); + await this.guarded(async () => { + const keys = await deriveLinkKeys( + this.deps.key, + meta.salt !== null + ? { passphrase, salt: fromBase64Url(meta.salt) } + : undefined, + ); + await this.open(keys, remember ? "remember" : "once"); + }); + } + + private async open(keys: LinkKeys, unlock: Unlock): Promise { + const { show } = this.deps; + // A reload costs no view; a wrong passphrase is only known by failing to open. + const cached = this.cached; + const entry = cached ?? (await this.fetch(keys, unlock)); + if (!entry) return; + const payload = await this.read(entry.sealed, keys.content); + if (!payload) { + if (!cached) return show({ kind: "error", message: BROKEN }); + if (!this.meta?.protected) + return show({ kind: "error", message: BROKEN }); + return show({ kind: "passphrase", problem: WRONG }); + } + await this.keep(keys, unlock, entry.expires); + show({ + kind: "content", + payload, + viewsLeft: entry.viewsLeft, + expires: entry.expires, + }); + } + + /** Spends a view; null once it has shown why the relay did not serve. */ + private async fetch( + keys: LinkKeys, + unlock: Unlock, + ): Promise { + const { id, api, cache, show } = this.deps; + const outcome = await api.open(id, keys.gate); + if (outcome.kind === "gone") { + await this.deps.keys.drop(id); + show({ kind: "gone" }); + return null; + } + if (outcome.kind === "gate" || outcome.kind === "cooldown") { + // Without a passphrase only a cut-short address fails the gate. + if (!this.meta?.protected) { + show({ kind: "error", message: BROKEN }); + return null; + } + // Kept keys the relay refuses would otherwise be tried on every visit. + if (outcome.kind === "gate" && unlock === "remembered") { + await this.deps.keys.drop(id); + } + show({ + kind: "passphrase", + problem: outcome.retryAfter ? waitMessage(outcome.retryAfter) : WRONG, + }); + return null; + } + const meta = this.meta as LinkMeta; + const entry: CachedLink = { + sealed: outcome.sealed, + protected: meta.protected, + salt: meta.salt, + viewsLeft: outcome.viewsLeft, + expires: outcome.expires, + }; + cache.put(id, entry); + this.cached = entry; + return entry; + } + + private async keep( + keys: LinkKeys, + unlock: Unlock, + expires: number | null, + ): Promise { + if (unlock === "remember") + await this.deps.keys.put(this.deps.id, keys, expires); + } + + private async read( + sealed: Uint8Array, + content: CryptoKey, + ): Promise { + try { + return await openLinkPayload(this.deps.id, sealed, content); + } catch { + return null; + } + } + + private async guarded(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + this.deps.show({ + kind: "error", + message: + error instanceof Error && error.message + ? error.message + : "The relay could not be reached.", + }); + } + } +} + +function waitMessage(seconds: number): string { + return seconds < 90 + ? `Too many wrong attempts. Try again in ${seconds} seconds.` + : `Too many wrong attempts. Try again in ${Math.ceil(seconds / 60)} minutes.`; +} diff --git a/packages/viewer/src/sidebar.css b/packages/viewer/src/sidebar.css new file mode 100644 index 00000000..b9bedaca --- /dev/null +++ b/packages/viewer/src/sidebar.css @@ -0,0 +1,185 @@ +/* The sidebar: a column left of the note when it fits, else a panel from the left behind a menu button. */ +.link-card { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1.25rem; + padding: 0.75rem; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + color: var(--text-muted); + font-size: 0.8rem; + line-height: var(--line-height-tight); +} + +.link-fact { + margin: 0; +} + +.copy-markdown { + padding: 0; + border: 0; + background: none; + color: var(--text-normal); + font: inherit; + text-align: left; + cursor: pointer; +} + +.copy-markdown:hover { + color: var(--interactive-accent); +} + +.wide-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0; + border: 0; + background: none; + color: var(--text-normal); + font: inherit; + cursor: pointer; +} + +/* A switch: the knob is a gradient layer that slides; the track is the background colour. Sizes are multiples of 4px so they stay whole pixels at 125-200% display scaling, else the knob sits off-centre. */ +.wide-toggle::after { + content: ""; + flex: none; + width: 36px; + height: 20px; + border-radius: 10px; + background: + radial-gradient(circle closest-side, #fff 90%, transparent) no-repeat 4px + 50% / 12px 12px var(--background-modifier-border); + transition: + background-position 0.15s, + background-color 0.15s; +} + +.wide-toggle[aria-pressed="true"]::after { + background-color: var(--interactive-accent); + background-position: calc(100% - 4px) 50%; +} + +.outline { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.outline-item { + padding: 0.25rem 0.5rem 0.25rem calc(0.5rem + var(--depth) * 0.85rem); + border: 0; + border-radius: 4px; + background: none; + color: var(--text-muted); + font: inherit; + font-size: 0.875rem; + line-height: var(--line-height-tight); + text-align: left; + cursor: pointer; +} + +.outline-item:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.outline-item.is-active { + color: var(--text-accent); +} + +/* Three bars that turn into a cross while the panel is open. */ +.sidebar-toggle { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 2; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-primary); + color: var(--text-normal); + box-shadow: var(--shadow); + cursor: pointer; +} + +.sidebar-toggle::before, +.sidebar-toggle::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 1rem; + height: 2px; + margin: -1px 0 0 -0.5rem; + border-radius: 1px; + background: currentColor; + transition: transform 0.2s; +} + +.sidebar-toggle::before { + box-shadow: + 0 -0.35rem currentColor, + 0 0.35rem currentColor; +} + +.sidebar-toggle::after { + opacity: 0; +} + +.sidebar.is-open .sidebar-toggle::before { + box-shadow: none; + transform: rotate(45deg); +} + +.sidebar.is-open .sidebar-toggle::after { + opacity: 1; + transform: rotate(-45deg); +} + +@media (max-width: 65.99rem) { + .sidebar-panel { + position: fixed; + inset: 0 auto 0 0; + z-index: 1; + width: min(80vw, 18rem); + padding: 3.75rem 0.75rem 1.5rem; + overflow-y: auto; + background: var(--background-secondary); + box-shadow: var(--shadow); + transform: translateX(-100%); + visibility: hidden; + transition: + transform 0.2s, + visibility 0.2s; + } + + .sidebar.is-open .sidebar-panel { + transform: none; + visibility: visible; + } + + /* The text already fills a narrow screen. */ + .wide-toggle { + display: none; + } +} + +@media (min-width: 66rem) { + .sidebar { + position: sticky; + top: 2.5rem; + align-self: start; + max-height: calc(100vh - 5rem); + overflow-y: auto; + } + + .sidebar-toggle { + display: none; + } +} diff --git a/packages/viewer/src/sidebar.ts b/packages/viewer/src/sidebar.ts new file mode 100644 index 00000000..b0127ff3 --- /dev/null +++ b/packages/viewer/src/sidebar.ts @@ -0,0 +1,140 @@ +import { copyWithFeedback } from "./clipboard"; +import { expiryText } from "./expiry"; + +const COPY_LABEL = "Copy as Markdown"; +/** The reader's choice of full-width text, kept for every link this relay serves. */ +const WIDE_KEY = "mdsync-viewer-wide"; +const EXPIRY_REFRESH_MS = 30_000; + +export interface LinkFacts { + viewsLeft: number | null; + /** Unix seconds; null never expires. */ + expires: number | null; +} + +/** + * The column left of the note: what is left of the link, the width switch and the outline. On a narrow screen + * it is a panel behind a menu button. Listeners end with `signal`. + */ +export function createSidebar( + reader: HTMLElement, + outline: HTMLElement | null, + facts: LinkFacts, + markdown: () => string, + signal: AbortSignal, +): HTMLElement { + const doc = reader.ownerDocument; + const sidebar = doc.createElement("aside"); + sidebar.className = "sidebar"; + const toggle = button(doc, "sidebar-toggle", ""); + toggle.setAttribute("aria-label", "Menu"); + toggle.setAttribute("aria-expanded", "false"); + const panel = doc.createElement("div"); + panel.className = "sidebar-panel"; + panel.append(card(reader, facts, markdown, signal)); + if (outline) panel.append(outline); + sidebar.append(toggle, panel); + + const setOpen = (open: boolean) => { + sidebar.classList.toggle("is-open", open); + toggle.setAttribute("aria-expanded", String(open)); + }; + toggle.addEventListener("click", () => + setOpen(!sidebar.classList.contains("is-open")), + ); + panel.addEventListener("click", (event) => { + if ((event.target as Element).closest(".outline-item")) setOpen(false); + }); + doc.addEventListener( + "click", + (event) => { + if (!sidebar.contains(event.target as Node)) setOpen(false); + }, + { signal }, + ); + doc.addEventListener( + "keydown", + (event) => { + if (event.key === "Escape") setOpen(false); + }, + { signal }, + ); + return sidebar; +} + +function card( + reader: HTMLElement, + facts: LinkFacts, + markdown: () => string, + signal: AbortSignal, +): HTMLElement { + const doc = reader.ownerDocument; + const card = doc.createElement("div"); + card.className = "link-card"; + const fact = (cls: string, text: string) => { + const line = doc.createElement("p"); + line.className = `link-fact ${cls}`; + line.textContent = text; + card.append(line); + return line; + }; + if (facts.viewsLeft !== null) fact("link-views", viewsText(facts.viewsLeft)); + const { expires } = facts; + if (expires !== null) { + const line = fact("link-expiry", expiryText(expires, Date.now())); + // "In 5 min" has to count down while the page stays open. + const timer = setInterval(() => { + line.textContent = expiryText(expires, Date.now()); + }, EXPIRY_REFRESH_MS); + signal.addEventListener("abort", () => clearInterval(timer)); + } + const wide = button(doc, "wide-toggle", "Wide text"); + const setWide = (on: boolean) => { + reader.classList.toggle("is-wide", on); + wide.setAttribute("aria-pressed", String(on)); + }; + setWide(storedWide()); + wide.addEventListener("click", () => { + const on = !reader.classList.contains("is-wide"); + setWide(on); + storeWide(on); + }); + const copy = button(doc, "copy-markdown", COPY_LABEL); + copy.addEventListener("click", () => + copyWithFeedback(copy, COPY_LABEL, markdown), + ); + card.append(copy, wide); + return card; +} + +function viewsText(viewsLeft: number): string { + if (viewsLeft === 0) { + return "This was the last view of this link. Reloading this tab keeps the note until you close it."; + } + return `This link can be opened ${viewsLeft} more ${viewsLeft === 1 ? "time" : "times"}.`; +} + +function storedWide(): boolean { + try { + return localStorage.getItem(WIDE_KEY) === "1"; + } catch { + return false; + } +} + +function storeWide(on: boolean): void { + try { + if (on) localStorage.setItem(WIDE_KEY, "1"); + else localStorage.removeItem(WIDE_KEY); + } catch { + // Storage blocked: the choice lasts for this page. + } +} + +function button(doc: Document, cls: string, text: string): HTMLButtonElement { + const button = doc.createElement("button"); + button.type = "button"; + button.className = cls; + button.textContent = text; + return button; +} diff --git a/packages/viewer/src/turndown-plugin-gfm.d.ts b/packages/viewer/src/turndown-plugin-gfm.d.ts new file mode 100644 index 00000000..0946517f --- /dev/null +++ b/packages/viewer/src/turndown-plugin-gfm.d.ts @@ -0,0 +1,5 @@ +declare module "turndown-plugin-gfm" { + import type TurndownService from "turndown"; + + export const gfm: TurndownService.Plugin; +} diff --git a/packages/viewer/src/view.ts b/packages/viewer/src/view.ts new file mode 100644 index 00000000..e2a4f3f3 --- /dev/null +++ b/packages/viewer/src/view.ts @@ -0,0 +1,160 @@ +import { addAnchors, currentAnchor } from "./anchors"; +import { addCopyButtons } from "./code"; +import { markLoneImages } from "./images"; +import { noteMarkdown } from "./markdown"; +import { createOutline } from "./outline"; +import { sanitizeNote } from "./sanitize"; +import { foldSections } from "./sections"; +import type { ViewState } from "./session"; +import { createSidebar } from "./sidebar"; + +export interface View { + show(state: ViewState): void; +} + +export interface ViewHandlers { + onPassphrase(passphrase: string, remember: boolean): void; +} + +type Child = Node | string; + +export function createView(root: HTMLElement, handlers: ViewHandlers): View { + const doc = root.ownerDocument; + + function el( + tag: K, + attrs: Record = {}, + ...children: Child[] + ): HTMLElementTagNameMap[K] { + const node = doc.createElement(tag); + for (const [name, value] of Object.entries(attrs)) + node.setAttribute(name, value); + node.append(...children); + return node; + } + + const message = (title: string, text: string, ...extra: Node[]) => + el( + "section", + { class: "message" }, + el("h1", {}, title), + el("p", {}, text), + ...extra, + ); + + // A wrong passphrase draws the form again: the reader's choice must survive it. + let rememberChoice = false; + + function passphraseForm(problem?: string): HTMLElement { + const input = el("input", { + type: "password", + id: "passphrase", + autocomplete: "off", + autocapitalize: "off", + spellcheck: "false", + required: "", + }); + const remember = el("input", { type: "checkbox" }); + remember.checked = rememberChoice; + const form = el( + "form", + { class: "passphrase" }, + el("h1", {}, "This note is protected"), + el("label", { for: "passphrase" }, "Passphrase"), + input, + el("label", { class: "remember" }, remember, "Remember on this browser"), + el("p", { class: "problem", role: "alert" }, problem ?? ""), + el("button", { type: "submit" }, "Open"), + ); + form.addEventListener("submit", (event) => { + event.preventDefault(); + rememberChoice = remember.checked; + handlers.onPassphrase(input.value, rememberChoice); + }); + queueMicrotask(() => input.focus()); + return form; + } + + function content( + state: Extract, + signal: AbortSignal, + ): HTMLElement { + const { title } = state.payload; + const body = el("div", { class: "markdown-rendered" }); + body.append(sanitizeNote(state.payload.html)); + const first = body.firstElementChild; + // A note that opens with its own name as H1 would show it twice. + const named = + first?.tagName === "H1" && first.textContent?.trim() === title; + addCopyButtons(body); + markLoneImages(body); + const headings = foldSections(body); + const outline = createOutline(headings, signal); + const jump = addAnchors(body, headings); + // Jumps once laid out, and again when the address names another anchor. + requestAnimationFrame(() => jump(currentAnchor())); + window.addEventListener("hashchange", () => jump(currentAnchor()), { + signal, + }); + body.addEventListener("click", (event) => { + const target = event.target as Element; + const title = target.closest(".callout-title"); + const callout = title?.closest(".callout.is-collapsible"); + callout?.classList.toggle("is-collapsed"); + }); + doc.title = title || "Shared note"; + const article = el( + "article", + { class: "note" }, + title && !named ? el("h1", { class: "note-title" }, title) : "", + body, + ); + const reader = el("div", { class: "reader" }, article); + reader.prepend( + createSidebar( + reader, + outline, + state, + () => + noteMarkdown( + title && !named ? title : "", + sanitizeNote(state.payload.html), + ), + signal, + ), + ); + return reader; + } + + function render(state: ViewState, signal: AbortSignal): HTMLElement { + switch (state.kind) { + case "loading": + return el("p", { class: "loading" }, "Opening…"); + case "passphrase": + return passphraseForm(state.problem); + case "content": + return content(state, signal); + case "gone": + return message( + "This link is no longer available", + "It has expired, reached its view limit or was removed by its owner.", + ); + case "invalid": + return message( + "This link is incomplete", + "The address is missing its key. Ask the sender for the whole link.", + ); + case "error": + return message("This note could not be opened", state.message); + } + } + + let shown = new AbortController(); + return { + show(state) { + shown.abort(); + shown = new AbortController(); + root.replaceChildren(render(state, shown.signal)); + }, + }; +} diff --git a/packages/viewer/src/viewer.css b/packages/viewer/src/viewer.css new file mode 100644 index 00000000..b6d5539b --- /dev/null +++ b/packages/viewer/src/viewer.css @@ -0,0 +1,230 @@ +@import "./note.css"; +@import "./sidebar.css"; + +/* + * The note's HTML comes from Obsidian's own renderer, so its classes are Obsidian's, and so are the variable + * names here: its SVG (mermaid, math, icons) reads them, and a theme could set them. + */ +:root { + color-scheme: light dark; + --font-text: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-monospace: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --line-height-normal: 1.6; + --line-height-tight: 1.3; + --file-line-width: 46rem; + --outline-width: 14rem; + --h1-size: 1.8rem; + --h2-size: 1.5rem; + --h3-size: 1.25rem; + --h4-size: 1.1rem; + --h5-size: 1rem; + --h6-size: 1rem; + --code-size: 0.875em; + --background-primary: #fff; + --background-secondary: #f5f6f8; + --background-modifier-border: #dcdde1; + --background-modifier-hover: rgba(0, 0, 0, 0.06); + --text-normal: #222; + --text-muted: #5c5c5c; + --text-faint: #8a8a8a; + --text-accent: #6c31e3; + --text-error: #d63a4a; + --interactive-accent: #7852ee; + --text-highlight-bg: rgba(255, 208, 0, 0.4); + --code-background: #f1f2f4; + --code-normal: var(--text-normal); + --code-comment: var(--text-faint); + --code-punctuation: var(--text-muted); + --code-operator: var(--text-muted); + --code-keyword: var(--text-accent); + --code-function: #2a6fdb; + --code-string: #228b5b; + --code-value: #b25d00; + --code-important: #b25d00; + --code-property: #d63a4a; + --code-tag: #d63a4a; + --color-red-rgb: 233, 49, 71; + --color-orange-rgb: 236, 117, 0; + --color-green-rgb: 8, 185, 78; + --color-cyan-rgb: 0, 191, 188; + --color-blue-rgb: 8, 109, 221; + --color-purple-rgb: 120, 82, 238; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.15); +} + +@media (prefers-color-scheme: dark) { + :root { + --background-primary: #1e1e1e; + --background-secondary: #262626; + --background-modifier-border: #3a3a3a; + --background-modifier-hover: rgba(255, 255, 255, 0.08); + --text-normal: #dadada; + --text-muted: #a3a3a3; + --text-faint: #7a7a7a; + --text-accent: #a58cff; + --text-error: #fb464c; + --interactive-accent: #8a6cf5; + --text-highlight-bg: rgba(255, 208, 0, 0.25); + --code-background: #2a2a2a; + --code-function: #6ba4ff; + --code-string: #44cf6e; + --code-value: #e9973f; + --code-important: #e9973f; + --code-property: #ff7b86; + --code-tag: #ff7b86; + --color-red-rgb: 251, 70, 76; + --color-orange-rgb: 233, 151, 63; + --color-green-rgb: 68, 207, 110; + --color-cyan-rgb: 83, 223, 221; + --color-blue-rgb: 2, 122, 255; + --color-purple-rgb: 168, 130, 255; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.5); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--background-primary); + color: var(--text-normal); + font: 16px / var(--line-height-normal) var(--font-text); + overflow-wrap: break-word; +} + +#app { + --jump-margin: 1.5rem; + padding: 2.5rem 1.25rem 4rem; +} + +/* Room for the sidebar's menu button, also above a heading jumped to. */ +@media (max-width: 65.99rem) { + #app { + --jump-margin: 3.75rem; + padding-top: 3.75rem; + } +} + +.loading, +.reader { + max-width: var(--file-line-width); + margin: 0 auto; +} + +/* The sidebar takes a column beside the note's full measure. */ +@media (min-width: 66rem) { + .reader { + display: grid; + grid-template-columns: var(--outline-width) minmax(0, 1fr); + gap: 3rem; + max-width: calc(var(--outline-width) + 3rem + var(--file-line-width)); + } + + .reader.is-wide { + max-width: none; + } + + /* A lone image would hug the left edge of a wide column. */ + .reader.is-wide .lone-image { + text-align: center; + } +} + +.loading { + color: var(--text-muted); +} + +.message, +.passphrase { + max-width: 24rem; + margin: 18vh auto 0; +} + +.message h1, +.passphrase h1 { + font-size: 1.3rem; +} + +.passphrase label { + display: block; + margin: 1.25rem 0 0.4rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.passphrase input[type="password"] { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-secondary); + color: inherit; + font: inherit; +} + +.passphrase input:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; +} + +.passphrase .remember { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + cursor: pointer; +} + +.passphrase .remember input { + margin: 0; + accent-color: var(--interactive-accent); +} + +.passphrase .problem { + min-height: 1.5rem; + margin: 0.5rem 0; + color: var(--text-error); + font-size: 0.9rem; +} + +.passphrase button { + padding: 0.55rem 1.4rem; + border: 0; + border-radius: 6px; + background: var(--interactive-accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +.note-title { + margin: 0 0 1.5rem; + font-size: 2rem; + line-height: 1.25; +} + +@media print { + #app { + padding: 0; + } + .reader { + display: block; + max-width: none; + } + .sidebar, + .heading-fold, + .heading-anchor, + .copy-code-button { + display: none; + } + .heading-section.is-collapsed > .heading-children { + display: block; + } + pre { + white-space: pre-wrap; + } +} diff --git a/packages/viewer/tests/anchors.test.ts b/packages/viewer/tests/anchors.test.ts new file mode 100644 index 00000000..cf666f08 --- /dev/null +++ b/packages/viewer/tests/anchors.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addAnchors, currentAnchor } from "../src/anchors"; +import { foldSections } from "../src/sections"; + +let body: HTMLElement; +const token = "AAAAAAAAAAAAAAAAAAAAAA"; +const address = `/s/${token}#${token}`; +const writeText = vi.fn<(text: string) => Promise>(); +const scrollIntoView = Element.prototype.scrollIntoView; + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + writeText.mockReset().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + Element.prototype.scrollIntoView = vi.fn(); + history.replaceState(null, "", address); + body = document.createElement("div"); + document.body.replaceChildren(body); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + Element.prototype.scrollIntoView = scrollIntoView; + history.replaceState(null, "", "/"); + localStorage.clear(); + document.body.replaceChildren(); +}); + +describe("anchors", () => { + it("adds an accessible button to every heading", () => { + body.innerHTML = "

First

Second

"; + const headings = foldSections(body); + addAnchors(body, headings); + for (const heading of headings) { + const button = heading.lastElementChild as HTMLButtonElement; + expect(button.tagName).toBe("BUTTON"); + expect(button.className).toBe("heading-anchor"); + expect(button.type).toBe("button"); + expect(button.getAttribute("aria-label")).toBe( + "Copy a link to this section", + ); + } + }); + + it("makes unique lowercase slugs with Unicode letters", () => { + body.innerHTML = + "

-- Hello, WORLD! 42 --

Hello world 42

Hello world 42

!!!

Été 東京

Section-2

"; + const headings = foldSections(body); + const jump = addAnchors(body, headings); + for (const [index, slug] of [ + "hello-world-42", + "hello-world-42-2", + "hello-world-42-3", + "section", + "section-2", + "été-東京", + "section-2-2", + ].entries()) { + jump(slug); + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(index + 1); + expect( + vi.mocked(Element.prototype.scrollIntoView).mock.contexts[index], + ).toBe(headings[index]); + expect(Element.prototype.scrollIntoView).toHaveBeenLastCalledWith({ + block: "start", + }); + } + }); + + it("reveals folded ancestors before jumping", () => { + body.innerHTML = "

First

Deep

Body

"; + const headings = foldSections(body); + const jump = addAnchors(body, headings); + (body.querySelector(".heading-fold") as HTMLButtonElement).click(); + expect(body.querySelector(".is-collapsed")).not.toBeNull(); + Element.prototype.scrollIntoView = vi.fn(function (this: Element, options) { + expect(body.querySelector(".is-collapsed")).toBeNull(); + expect(this).toBe(headings[1]); + expect(options).toEqual({ block: "start" }); + }); + jump("deep"); + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it.each([null, "unknown"])("ignores anchor %s", (anchor) => { + body.innerHTML = "

First

"; + addAnchors(body, foldSections(body))(anchor); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + + it.each([ + ["copied", false], + ["failed", true], + ] as const)( + "shows %s after copying a section link", + async (state, rejects) => { + if (rejects) writeText.mockRejectedValueOnce(new Error("Denied")); + body.innerHTML = "

Été 東京

"; + addAnchors(body, foldSections(body)); + const button = body.querySelector(".heading-anchor") as HTMLButtonElement; + const url = `${location.origin}${address}/${encodeURIComponent("été-東京")}`; + button.click(); + await vi.advanceTimersByTimeAsync(0); + expect(location.href).toBe(url); + expect(writeText).toHaveBeenCalledExactlyOnceWith(url); + expect(button.dataset.state).toBe(state); + await vi.advanceTimersByTimeAsync(1499); + expect(button.dataset.state).toBe(state); + await vi.advanceTimersByTimeAsync(1); + expect(button.hasAttribute("data-state")).toBe(false); + }, + ); + + it("shows failure when the clipboard is missing", async () => { + vi.stubGlobal("navigator", {}); + body.innerHTML = "

First

"; + addAnchors(body, foldSections(body)); + const button = body.querySelector(".heading-anchor") as HTMLButtonElement; + button.click(); + await vi.advanceTimersByTimeAsync(0); + expect(button.dataset.state).toBe("failed"); + expect(location.hash).toBe(`#${token}/first`); + await vi.advanceTimersByTimeAsync(1500); + expect(button.hasAttribute("data-state")).toBe(false); + }); + + it("keeps the key in an in-note link and jumps to its target", () => { + body.innerHTML = + '

[1]

  1. Note
'; + addAnchors(body, []); + const link = body.querySelector("a") as HTMLAnchorElement; + expect(link.href).toBe(`${location.origin}${address}/fn-1`); + link.click(); + expect(location.href).toBe(`${location.origin}${address}`); + expect(vi.mocked(Element.prototype.scrollIntoView).mock.contexts).toEqual([ + body.querySelector("#fn-1"), + ]); + }); + + it("rewrites an SVG link too, whose href property is read-only", () => { + body.innerHTML = + 'jump'; + addAnchors(body, []); + expect(body.querySelector("a")?.getAttribute("href")).toBe( + `${location.origin}${address}/spot`, + ); + }); + + it("does not copy without a valid link address", async () => { + history.replaceState(null, "", "/"); + body.innerHTML = "

First

"; + addAnchors(body, foldSections(body)); + const button = body.querySelector(".heading-anchor") as HTMLButtonElement; + button.click(); + await vi.advanceTimersByTimeAsync(0); + expect(writeText).not.toHaveBeenCalled(); + expect(location.pathname).toBe("/"); + expect(button.dataset.state).toBeUndefined(); + }); +}); + +describe("current anchor", () => { + it.each([ + ["/", null], + [address, null], + [`${address}/`, null], + [`${address}/first`, "first"], + [`${address}/${encodeURIComponent("été-東京")}`, "été-東京"], + ] as const)("reads %s", (url, anchor) => { + history.replaceState(null, "", url); + expect(currentAnchor()).toBe(anchor); + }); +}); diff --git a/packages/viewer/tests/api.test.ts b/packages/viewer/tests/api.test.ts new file mode 100644 index 00000000..c4890d3b --- /dev/null +++ b/packages/viewer/tests/api.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { createApi } from "../src/api"; + +const reply = ( + status: number, + init: { body?: BodyInit; headers?: Record } = {}, +) => + (async () => + new Response(init.body ?? null, { + status, + headers: init.headers, + })) as typeof fetch; + +describe("meta", () => { + it("reads whether a passphrase is asked for", async () => { + const meta = { protected: true, salt: "abc" }; + const api = createApi(reply(200, { body: JSON.stringify(meta) })); + expect(await api.meta("id")).toEqual(meta); + }); + + it("is null for a link that is gone and throws for a relay in trouble", async () => { + expect(await createApi(reply(404)).meta("id")).toBeNull(); + await expect(createApi(reply(500)).meta("id")).rejects.toThrow("500"); + }); +}); + +describe("open", () => { + it("returns the bytes, the views left and the expiry", async () => { + const api = createApi( + reply(200, { + body: Uint8Array.from([1, 2, 3]), + headers: { + "X-Mdsync-Views-Left": "2", + "X-Mdsync-Expires": "1760003600", + }, + }), + ); + expect(await api.open("id", "G".repeat(43))).toEqual({ + kind: "opened", + sealed: Uint8Array.from([1, 2, 3]), + viewsLeft: 2, + expires: 1_760_003_600, + }); + }); + + it("leaves the count out for an unlimited link", async () => { + const api = createApi(reply(200, { body: Uint8Array.from([1]) })); + expect(await api.open("id", "G".repeat(43))).toMatchObject({ + viewsLeft: null, + expires: null, + }); + }); + + it.each(["", "0", "-1", "1.5", "later", "NaN", "Infinity", "1e309"])( + "ignores an invalid expiry header %j", + async (expires) => { + const api = createApi( + reply(200, { headers: { "X-Mdsync-Expires": expires } }), + ); + expect(await api.open("id", "G".repeat(43))).toMatchObject({ + expires: null, + }); + }, + ); + + it("sends the gate in the body", async () => { + let sent = ""; + const api = createApi((async (_url: string, init?: RequestInit) => { + sent = String(init?.body); + return new Response(null, { status: 404 }); + }) as typeof fetch); + await api.open("id", "G".repeat(43)); + expect(JSON.parse(sent)).toEqual({ gate: "G".repeat(43) }); + await api.open("id", "U".repeat(43)); + expect(JSON.parse(sent)).toEqual({ gate: "U".repeat(43) }); + }); + + it("tells gone, wrong gate and cooldown apart", async () => { + expect(await createApi(reply(404)).open("id", "G".repeat(43))).toEqual({ + kind: "gone", + }); + expect(await createApi(reply(401)).open("id", "g")).toEqual({ + kind: "gate", + retryAfter: null, + }); + expect( + await createApi(reply(401, { headers: { "Retry-After": "60" } })).open( + "id", + "g", + ), + ).toEqual({ + kind: "gate", + retryAfter: 60, + }); + expect( + await createApi(reply(429, { headers: { "Retry-After": "120" } })).open( + "id", + "g", + ), + ).toEqual({ + kind: "cooldown", + retryAfter: 120, + }); + expect(await createApi(reply(429)).open("id", "g")).toEqual({ + kind: "cooldown", + retryAfter: 60, + }); + }); + + it("throws for any other answer", async () => { + await expect( + createApi(reply(502)).open("id", "G".repeat(43)), + ).rejects.toThrow("502"); + }); +}); diff --git a/packages/viewer/tests/cache.test.ts b/packages/viewer/tests/cache.test.ts new file mode 100644 index 00000000..f80af2a1 --- /dev/null +++ b/packages/viewer/tests/cache.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { sessionCache } from "../src/cache"; + +const link = { + sealed: new Uint8Array([1, 2, 3]), + protected: false, + salt: null, + viewsLeft: 2, + expires: 1_900_000_000, +}; + +const storage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + +afterEach(() => { + if (storage) Object.defineProperty(globalThis, "sessionStorage", storage); + sessionStorage.clear(); +}); + +describe("session cache", () => { + it.each([link.expires, null])("round-trips an expiry of %s", (expires) => { + const cache = sessionCache(); + const entry = { ...link, expires }; + cache.put("id", entry); + expect(cache.get("id")).toEqual(entry); + }); + + it("works without a cache when the browser blocks storage", () => { + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + get() { + throw new DOMException("Blocked", "SecurityError"); + }, + }); + const cache = sessionCache(); + expect(() => cache.put("id", link)).not.toThrow(); + expect(cache.get("id")).toBeNull(); + }); +}); diff --git a/packages/viewer/tests/code.test.ts b/packages/viewer/tests/code.test.ts new file mode 100644 index 00000000..89307d70 --- /dev/null +++ b/packages/viewer/tests/code.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addCopyButtons } from "../src/code"; + +let body: HTMLElement; +const writeText = vi.fn<(text: string) => Promise>(); + +beforeEach(() => { + vi.useFakeTimers(); + writeText.mockReset().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + body = document.createElement("div"); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("copy buttons", () => { + it("adds a copy button after each code block", () => { + body.innerHTML = + "
first
second

inline

plain
"; + addCopyButtons(body); + const buttons = body.querySelectorAll("button"); + expect(buttons).toHaveLength(2); + for (const [index, code] of body.querySelectorAll("pre > code").entries()) { + expect(code.nextElementSibling).toBe(buttons[index]); + } + for (const button of buttons) { + expect(button.className).toBe("copy-code-button"); + expect(button.type).toBe("button"); + expect(button.textContent).toBe("Copy"); + } + }); + + it("leaves content without code blocks alone", () => { + body.innerHTML = "

Text

"; + addCopyButtons(body); + expect(body.innerHTML).toBe("

Text

"); + }); + + it.each([ + ["Copied", false], + ["Copy failed", true], + ] as const)("shows %s after copying", async (label, rejects) => { + if (rejects) writeText.mockRejectedValueOnce(new Error("Denied")); + body.innerHTML = "
  first\nsecond <third>\n
"; + addCopyButtons(body); + const button = body.querySelector("button") as HTMLButtonElement; + button.click(); + await vi.advanceTimersByTimeAsync(0); + expect(writeText).toHaveBeenCalledExactlyOnceWith( + " first\nsecond \n", + ); + expect(button.textContent).toBe(label); + await vi.advanceTimersByTimeAsync(1499); + expect(button.textContent).toBe(label); + await vi.advanceTimersByTimeAsync(1); + expect(button.textContent).toBe("Copy"); + }); +}); diff --git a/packages/viewer/tests/expiry.test.ts b/packages/viewer/tests/expiry.test.ts new file mode 100644 index 00000000..58008e07 --- /dev/null +++ b/packages/viewer/tests/expiry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { expiryText } from "../src/expiry"; + +const NOW = Date.UTC(2026, 9, 8, 12, 0); +const at = (ms: number) => Math.floor((NOW + ms) / 1000); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; + +describe("expiryText", () => { + it.each([ + [5 * MINUTE, "Expires in 5 min."], + [4 * MINUTE + 1000, "Expires in 5 min."], + [59 * MINUTE, "Expires in 59 min."], + [HOUR, "Expires in 1 hour."], + [5 * HOUR + 20 * MINUTE, "Expires in 5 hours."], + [23 * HOUR, "Expires in 23 hours."], + ])("counts %i ms down in minutes, then hours", (ms, text) => { + expect(expiryText(at(ms), NOW)).toBe(text); + }); + + it("says when it has expired", () => { + expect(expiryText(at(0), NOW)).toContain("Expired."); + expect(expiryText(at(-HOUR), NOW)).toContain("Expired."); + }); + + it("names the date a day or more away, the year only when it differs", () => { + const options = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + } as const; + const soon = at(3 * 24 * HOUR); + expect(expiryText(soon, NOW)).toBe( + `Expires ${new Date(soon * 1000).toLocaleString(undefined, options)}.`, + ); + const next = at(200 * 24 * HOUR); + expect(expiryText(next, NOW)).toBe( + `Expires ${new Date(next * 1000).toLocaleString(undefined, { ...options, year: "numeric" })}.`, + ); + }); +}); diff --git a/packages/viewer/tests/images.test.ts b/packages/viewer/tests/images.test.ts new file mode 100644 index 00000000..9939ed19 --- /dev/null +++ b/packages/viewer/tests/images.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { markLoneImages } from "../src/images"; + +const mark = (html: string): string[] => { + const body = document.createElement("div"); + body.innerHTML = html; + markLoneImages(body); + return Array.from(body.querySelectorAll("p")).map((p) => p.className); +}; + +describe("markLoneImages", () => { + it("marks a paragraph that holds only an image, wrapped or not", () => { + expect( + mark( + '

', + ), + ).toEqual(["lone-image", "lone-image"]); + }); + + it("leaves paragraphs with text, and text-only ones, alone", () => { + expect( + mark( + '

See here

Words

', + ), + ).toEqual(["", "", ""]); + }); +}); diff --git a/packages/viewer/tests/keys.test.ts b/packages/viewer/tests/keys.test.ts new file mode 100644 index 00000000..44fd145e --- /dev/null +++ b/packages/viewer/tests/keys.test.ts @@ -0,0 +1,69 @@ +import "fake-indexeddb/auto"; + +import { deriveLinkKeys, newLinkKey, newLinkSalt } from "@mdsync/protocol"; +import { createStore, get } from "idb-keyval"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { browserKeys } from "../src/keys"; + +afterEach(() => { + vi.useRealTimers(); +}); + +async function protectedKeys() { + return deriveLinkKeys(newLinkKey(), { + passphrase: "correct horse", + salt: newLinkSalt(), + }); +} + +describe("browser keys", () => { + it("keeps a non-extractable key and the gate until dropped", async () => { + const store = browserKeys(); + const keys = await protectedKeys(); + await store.put("a", keys, null); + + const kept = await store.get("a"); + expect(kept?.gate).toBe(keys.gate); + expect(kept?.content.extractable).toBe(false); + + await store.drop("a"); + expect(await store.get("a")).toBeNull(); + }); + + it("drops keys by id and tolerates repeated drops", async () => { + const store = browserKeys(); + await store.put("c", await protectedKeys(), null); + await store.drop("c"); + expect(await store.get("c")).toBeNull(); + await store.drop("c"); + expect(await store.get("c")).toBeNull(); + }); + + it("forgets keys once their link has expired", async () => { + const store = browserKeys(); + const now = 1_900_000_000_000; + vi.useFakeTimers({ now, toFake: ["Date"] }); + await store.put("b", await protectedKeys(), now / 1000 + 60); + expect(await store.get("b")).not.toBeNull(); + + vi.setSystemTime(now + 60_000); + expect(await store.get("b")).toBeNull(); + await store.put("b", await protectedKeys(), now / 1000); + expect(await store.get("b")).toBeNull(); + }); + + it("erases expired keys of links never opened again", async () => { + const now = 1_900_000_000_000; + vi.useFakeTimers({ now, toFake: ["Date"] }); + await browserKeys().put("gone", await protectedKeys(), now / 1000 + 60); + await browserKeys().put("kept", await protectedKeys(), null); + + vi.setSystemTime(now + 60_000); + await browserKeys().get("other"); + + const raw = createStore("mdsync-viewer", "keys"); + expect(await get("gone", raw)).toBeUndefined(); + expect(await get("kept", raw)).toBeDefined(); + }); +}); diff --git a/packages/viewer/tests/markdown.test.ts b/packages/viewer/tests/markdown.test.ts new file mode 100644 index 00000000..eaad6ce9 --- /dev/null +++ b/packages/viewer/tests/markdown.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { noteMarkdown } from "../src/markdown"; +import { sanitizeNote } from "../src/sanitize"; + +const markdownOf = (html: string, title = "") => + noteMarkdown(title, sanitizeNote(html)); + +describe("noteMarkdown", () => { + it("writes headings, emphasis, lists, links and fenced code", () => { + const md = markdownOf( + [ + "

Lisbon

", + '

Landing in May with a map.

', + "
  • one
  • two
", + '
const a = 1;
', + ].join(""), + ); + expect(md).toBe( + [ + "## Lisbon", + "", + "Landing in **May** with _a_ [map](https://example.com/map).", + "", + "- one", + "- two", + "", + "```ts", + "const a = 1;", + "```", + "", + ].join("\n"), + ); + }); + + it("puts the note's name first when the page shows it", () => { + expect(markdownOf("

Body

", "Trip notes")).toBe( + "# Trip notes\n\nBody\n", + ); + }); + + it("keeps tables and task lists", () => { + const md = markdownOf( + [ + "", + "
AB
12
", + '
  • Book
  • ', + '
  • Pack
', + ].join(""), + ); + expect(md).toContain("| A | B |"); + expect(md).toContain("| 1 | 2 |"); + expect(md).toContain("- [x] Book"); + expect(md).toContain("- [ ] Pack"); + }); + + it("turns a callout into a quoted block with its type and fold", () => { + const md = markdownOf( + '', + ); + expect(md).toBe( + "> [!tip]- Pack light\n> Only a carry-on.\n>\n> Two lines.\n", + ); + }); + + it("writes a formula back as the TeX it came from", () => { + const md = markdownOf( + '

Euler x

y', + ); + expect(md).toContain("Euler $e^{i\\pi}$"); + expect(md).toContain("$$\na^2+b^2\n$$"); + }); + + it("nests and numbers lists", () => { + const md = markdownOf( + '
  1. three
    • inner
  2. four
', + ); + expect(md).toBe("3. three\n - inner\n4. four\n"); + }); + + it("leaves out drawings and images that live in the page", () => { + const md = markdownOf( + '

Before

diagramshot

After far

', + ); + expect(md).not.toContain("diagram"); + expect(md).not.toContain("data:"); + expect(md).toContain("![far](https://example.com/a.png)"); + }); +}); diff --git a/packages/viewer/tests/outline.test.ts b/packages/viewer/tests/outline.test.ts new file mode 100644 index 00000000..ff232737 --- /dev/null +++ b/packages/viewer/tests/outline.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createOutline } from "../src/outline"; +import { foldSections } from "../src/sections"; + +let controller: AbortController; +let body: HTMLElement; +let headings: HTMLHeadingElement[]; +let outline: HTMLElement; +const scrollIntoView = Element.prototype.scrollIntoView; + +beforeEach(() => { + vi.useFakeTimers(); + controller = new AbortController(); + body = document.createElement("div"); + body.innerHTML = "

First

Deep

Last

"; + headings = foldSections(body); + outline = createOutline(headings, controller.signal) as HTMLElement; + document.body.replaceChildren(body, outline); +}); + +afterEach(() => { + controller.abort(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + Element.prototype.scrollIntoView = scrollIntoView; + document.body.replaceChildren(); +}); + +describe("outline", () => { + it.each([0, 1, 2])("skips an outline for %i headings", (count) => { + expect( + createOutline(headings.slice(0, count), controller.signal), + ).toBeNull(); + }); + + it("lists headings with depths relative to the smallest level", () => { + expect(outline.tagName).toBe("NAV"); + expect(outline.className).toBe("outline"); + expect(outline.getAttribute("aria-label")).toBe("Outline"); + const items = outline.querySelectorAll(".outline-item"); + expect(Array.from(outline.children)).toEqual(Array.from(items)); + expect(items).toHaveLength(3); + expect(Array.from(items, (item) => item.tagName)).toEqual([ + "BUTTON", + "BUTTON", + "BUTTON", + ]); + expect(Array.from(items, (item) => item.textContent)).toEqual([ + "First", + "Deep", + "Last", + ]); + expect( + Array.from(items, (item) => item.style.getPropertyValue("--depth")), + ).toEqual(["1", "3", "0"]); + }); + + it("reveals a heading before scrolling", () => { + const section = body.querySelector(".heading-section") as HTMLElement; + (section.querySelector(".heading-fold") as HTMLButtonElement).click(); + expect(section.classList.contains("is-collapsed")).toBe(true); + const scrolled: Element[] = []; + Element.prototype.scrollIntoView = function (this: Element, options) { + expect(section.classList.contains("is-collapsed")).toBe(false); + expect(options).toEqual({ block: "start" }); + scrolled.push(this); + }; + const item = outline.querySelector( + ".outline-item:nth-child(2)", + ) as HTMLButtonElement; + item.click(); + expect(scrolled).toEqual([headings[1]]); + }); + + it("handles scroll frames without layout boxes", async () => { + await vi.advanceTimersByTimeAsync(16); + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("scroll")); + await vi.advanceTimersByTimeAsync(16); + expect(outline.querySelector(".is-active")).toBeNull(); + }); + + it.each(["scroll", "click"])("tracks headings after a %s", async (event) => { + let tops = [40, 120, 200]; + for (const [index, heading] of headings.entries()) { + vi.spyOn(heading, "getClientRects").mockReturnValue({ + length: index === 2 ? 0 : 1, + } as DOMRectList); + vi.spyOn(heading, "getBoundingClientRect").mockImplementation( + () => ({ top: tops[index] }) as DOMRect, + ); + } + await vi.advanceTimersByTimeAsync(16); + expect(outline.querySelector(".is-active")).toBe(outline.children[0]); + tops = [0, 40, 0]; + const frame = vi.spyOn(window, "requestAnimationFrame"); + const target = event === "scroll" ? window : document; + target.dispatchEvent(new Event(event)); + target.dispatchEvent(new Event(event)); + expect(frame).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(16); + expect(outline.querySelector(".is-active")).toBe(outline.children[1]); + }); + + it("stops scheduling frames after abort", async () => { + await vi.advanceTimersByTimeAsync(16); + const frame = vi.spyOn(window, "requestAnimationFrame"); + controller.abort(); + window.dispatchEvent(new Event("scroll")); + body.click(); + expect(frame).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/viewer/tests/sanitize.test.ts b/packages/viewer/tests/sanitize.test.ts new file mode 100644 index 00000000..0d1abf2b --- /dev/null +++ b/packages/viewer/tests/sanitize.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeNote } from "../src/sanitize"; + +function render(html: string): HTMLElement { + const host = document.createElement("div"); + host.append(sanitizeNote(html)); + return host; +} + +describe("sanitizeNote", () => { + it("drops scripts, handlers and frames", () => { + const host = render( + '

hi

', + ); + expect(host.querySelector("script, iframe")).toBeNull(); + expect(host.querySelector("p")?.getAttribute("onclick")).toBeNull(); + expect(host.querySelector("img")?.getAttribute("onerror")).toBeNull(); + expect(host.textContent).toBe("hi"); + }); + + it("opens external links in a new tab without a referrer", () => { + const link = render('a').querySelector( + "a", + ); + expect(link?.getAttribute("href")).toBe("https://example.com/a"); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + }); + + it("keeps in-page and mail links, and drops vault paths and script urls", () => { + const host = render( + '1mvjl', + ); + const hrefs = [...host.querySelectorAll("a")].map((a) => + a.getAttribute("href"), + ); + expect(hrefs).toEqual(["#fn-1", "mailto:a@b.c", null, null, null]); + }); + + it.each(["href", "xlink:href"])( + "opens external SVG %s links without a referrer", + (attribute) => { + const link = render( + `a`, + ).querySelector("svg a"); + expect(link?.getAttribute(attribute)).toBe("https://example.com/a"); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + }, + ); + + it.each(["href", "xlink:href"])( + "restricts SVG %s to the link allow-list", + (attribute) => { + const host = render( + `1mvjl`, + ); + expect( + [...host.querySelectorAll("svg a")].map((a) => + a.getAttribute(attribute), + ), + ).toEqual(["#fn-1", "mailto:a@b.c", null, null, null]); + }, + ); + + it("checks both href attributes on the same SVG link", () => { + const link = render( + 'a', + ).querySelector("svg a"); + expect(link?.getAttribute("href")).toBeNull(); + expect(link?.getAttribute("xlink:href")).toBe("http://example.com/a"); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + }); + + it("keeps embedded and https images and drops every other source", () => { + const host = render( + '', + ); + expect( + [...host.querySelectorAll("img")].map((img) => img.getAttribute("src")), + ).toEqual(["data:image/png;base64,AAAA", "https://example.com/i.png"]); + }); + + it("drops srcset so an image cannot reach another source", () => { + const img = render( + '', + ).querySelector("img"); + expect(img?.hasAttribute("srcset")).toBe(false); + }); + + it("keeps what Obsidian's reading view is made of", () => { + const host = render( + '
x
m
const
  • done
', + ); + expect( + host.querySelector('.callout[data-callout="tip"] svg path'), + ).not.toBeNull(); + expect(host.querySelector("table td")?.textContent).toBe("x"); + expect(host.querySelector("mark")).not.toBeNull(); + expect(host.querySelector(".token.keyword")?.textContent).toBe("const"); + }); + + it("locks task checkboxes and removes form controls", () => { + const host = render( + '
', + ); + expect(host.querySelector("input")?.hasAttribute("disabled")).toBe(true); + expect(host.querySelector("form, button, textarea")).toBeNull(); + }); + + it("removes every input except task checkboxes", () => { + const host = render( + '', + ); + const inputs = [...host.querySelectorAll("input")]; + expect(inputs).toHaveLength(1); + expect(inputs[0]?.type).toBe("checkbox"); + expect(inputs[0]?.checked).toBe(true); + expect(inputs[0]?.disabled).toBe(true); + }); + + it("keeps MathML and drops what could run inside it", () => { + const host = render( + '

ab

', + ); + expect(host.querySelector("math[display=block] mfrac mi")).not.toBeNull(); + expect(host.querySelector("script")).toBeNull(); + }); + + it("keeps inline svg and drops script inside it", () => { + const host = render( + '', + ); + expect(host.querySelector("svg rect")).not.toBeNull(); + expect(host.querySelector("script")).toBeNull(); + }); +}); diff --git a/packages/viewer/tests/sections.test.ts b/packages/viewer/tests/sections.test.ts new file mode 100644 index 00000000..74f9fdbe --- /dev/null +++ b/packages/viewer/tests/sections.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { foldSections, reveal } from "../src/sections"; + +let body: HTMLElement; + +beforeEach(() => { + body = document.createElement("div"); + document.body.replaceChildren(body); + document.getSelection()?.removeAllRanges(); +}); + +afterEach(() => { + document.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +describe("fold sections", () => { + it("leaves content without headings alone", () => { + body.innerHTML = "Text

Intro

"; + const nodes = Array.from(body.childNodes); + expect(foldSections(body)).toEqual([]); + expect(Array.from(body.childNodes)).toEqual(nodes); + }); + + it("wraps direct headings and their following blocks", () => { + body.innerHTML = + "

Intro

First

Text

Body

Nested

Next

End

"; + const nodes = Array.from(body.childNodes); + const headings = foldSections(body); + const first = body.querySelector(".heading-section") as HTMLElement; + const next = first.nextElementSibling as HTMLElement; + const children = first.querySelector(".heading-children") as HTMLElement; + expect(headings).toEqual([nodes[1], nodes[5]]); + expect(Array.from(body.children)).toEqual([nodes[0], first, next]); + expect(Array.from(first.children, (el) => el.tagName)).toEqual([ + "H2", + "BUTTON", + "DIV", + ]); + expect(first.className).toBe("heading-section"); + expect(first.querySelector("button")?.className).toBe("heading-fold"); + expect(children.className).toBe("heading-children"); + expect(Array.from(children.childNodes)).toEqual(nodes.slice(2, 5)); + expect( + Array.from(next.querySelector(".heading-children")?.childNodes ?? []), + ).toEqual([nodes[6]]); + }); + + it("nests deeper headings under the previous shallower heading", () => { + body.innerHTML = + "

First

Deep

Middle

Peer

Last

"; + const headings = Array.from(body.querySelectorAll("h1, h2, h3, h4")); + expect(foldSections(body)).toEqual(headings); + const sections = headings.map((heading) => heading.parentElement); + expect(Array.from(body.children)).toEqual([sections[0], sections[4]]); + const children = body.querySelector(".heading-children") as HTMLElement; + expect(Array.from(children.children)).toEqual(sections.slice(1, 4)); + }); + + it("keeps footnotes and following blocks outside all sections", () => { + body.innerHTML = + '

First

Deep

Body

Notes

After

Last

'; + const footnotes = body.querySelector(".footnotes"); + const after = body.querySelectorAll("p")[1]; + const headings = foldSections(body); + expect(Array.from(body.children)).toEqual([ + headings[0]?.parentElement, + footnotes, + after, + headings[2]?.parentElement, + ]); + }); + + it("toggles folding and the expanded state", () => { + body.innerHTML = "

First

Body

"; + foldSections(body); + const section = body.firstElementChild as HTMLElement; + const button = section.querySelector("button") as HTMLButtonElement; + expect(button.getAttribute("aria-expanded")).toBe("true"); + button.click(); + expect(section.classList.contains("is-collapsed")).toBe(true); + expect(button.getAttribute("aria-expanded")).toBe("false"); + button.click(); + expect(section.classList.contains("is-collapsed")).toBe(false); + expect(button.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +describe("heading clicks", () => { + beforeEach(() => { + body.innerHTML = + '

FirstLink

Body

'; + foldSections(body); + }); + + it("toggles the fold from heading text", () => { + const text = body.querySelector("h2 > span") as HTMLElement; + const section = body.firstElementChild as HTMLElement; + const fold = section.querySelector(".heading-fold") as HTMLButtonElement; + text.click(); + expect(section.classList.contains("is-collapsed")).toBe(true); + expect(fold.getAttribute("aria-expanded")).toBe("false"); + text.click(); + expect(section.classList.contains("is-collapsed")).toBe(false); + expect(fold.getAttribute("aria-expanded")).toBe("true"); + }); + + it.each(["a", "button"])("leaves a heading %s click alone", (tag) => { + (body.querySelector(`h2 ${tag} span`) as HTMLElement).click(); + expect(body.querySelector(".is-collapsed")).toBeNull(); + }); + + it("leaves selected heading text alone", () => { + const text = body.querySelector("h2 > span") as HTMLElement; + const range = document.createRange(); + range.selectNodeContents(text); + const selection = document.getSelection() as Selection; + selection.addRange(range); + expect(selection.isCollapsed).toBe(false); + text.click(); + expect(body.querySelector(".is-collapsed")).toBeNull(); + selection.collapseToStart(); + text.click(); + expect(body.querySelector(".is-collapsed")).not.toBeNull(); + }); +}); + +describe("reveal", () => { + beforeEach(() => { + body.innerHTML = + "

First

Deep

Target

Other

"; + foldSections(body); + for (const button of body.querySelectorAll("button")) + button.click(); + }); + + it("unfolds every ancestor that hides the target", () => { + reveal(body.querySelector("span") as HTMLElement); + const sections = body.querySelectorAll(".heading-section"); + expect( + Array.from(sections, (el) => el.classList.contains("is-collapsed")), + ).toEqual([false, false, true]); + expect( + Array.from(body.querySelectorAll("button"), (el) => + el.getAttribute("aria-expanded"), + ), + ).toEqual(["true", "true", "false"]); + }); + + it("keeps the revealed heading's own section folded", () => { + reveal(body.querySelector("h2") as HTMLHeadingElement); + expect( + Array.from(body.querySelectorAll(".heading-section"), (el) => + el.classList.contains("is-collapsed"), + ), + ).toEqual([false, true, true]); + }); + + it("leaves sections alone for a target outside them", () => { + reveal(body); + expect(body.querySelectorAll(".is-collapsed")).toHaveLength(3); + }); + + it("unfolds a callout that hides the target", () => { + body.innerHTML = + '

A

'; + foldSections(body); + (body.querySelector("button") as HTMLButtonElement).click(); + reveal(body.querySelector("#ref") as HTMLElement); + expect(body.querySelectorAll(".is-collapsed")).toHaveLength(0); + }); +}); diff --git a/packages/viewer/tests/session.test.ts b/packages/viewer/tests/session.test.ts new file mode 100644 index 00000000..c1935356 --- /dev/null +++ b/packages/viewer/tests/session.test.ts @@ -0,0 +1,456 @@ +import { + deriveLinkKeys, + type LinkKeys, + type LinkMeta, + type LinkPayload, + newLinkId, + newLinkKey, + newLinkSalt, + sealLinkPayload, + toBase64Url, +} from "@mdsync/protocol"; +import { describe, expect, it, vi } from "vitest"; + +import type { LinkApi, OpenOutcome } from "../src/api"; +import { type CachedLink, type LinkCache, sessionCache } from "../src/cache"; +import type { RememberedKeys } from "../src/keys"; +import { LinkSession, type ViewState } from "../src/session"; + +const payload: LinkPayload = { + title: "Trip", + html: "

Lisbon

", + createdAt: 1, +}; + +async function link(passphrase?: string) { + const id = newLinkId(); + const key = newLinkKey(); + const salt = newLinkSalt(); + const keys = await deriveLinkKeys( + key, + passphrase ? { passphrase, salt } : undefined, + ); + const sealed = await sealLinkPayload(id, payload, keys.content); + const meta: LinkMeta = passphrase + ? { protected: true, salt: toBase64Url(salt) } + : { protected: false, salt: null }; + return { id, key, sealed, meta, gate: keys.gate }; +} + +function fakeApi( + meta: LinkMeta | null, + open: (gate: string) => OpenOutcome, +): LinkApi & { opens: string[]; metas: number } { + const calls = { opens: [] as string[], metas: 0 }; + return { + get opens() { + return calls.opens; + }, + get metas() { + return calls.metas; + }, + async meta() { + calls.metas++; + return meta; + }, + async open(_id, gate) { + calls.opens.push(gate); + return open(gate); + }, + }; +} + +function memoryCache(): LinkCache & { stored: Map } { + const stored = new Map(); + return { + stored, + get: (id) => stored.get(id) ?? null, + put: (id, entry) => void stored.set(id, entry), + }; +} + +function memoryKeys(): RememberedKeys & { + stored: Map; +} { + const stored = new Map(); + return { + stored, + get: async (id) => stored.get(id)?.keys ?? null, + put: async (id, keys, expires) => void stored.set(id, { keys, expires }), + drop: async (id) => void stored.delete(id), + }; +} + +function harness(deps: { + id: string; + key: Uint8Array; + api: LinkApi; + cache?: LinkCache; + keys?: ReturnType; +}) { + const states: ViewState[] = []; + const cache = deps.cache ?? memoryCache(); + const keys = deps.keys ?? memoryKeys(); + const session = new LinkSession({ + ...deps, + cache, + keys, + show: (state) => states.push(state), + }); + return { session, states, cache, keys, last: () => states.at(-1) }; +} + +describe("an open link", () => { + it.each([ + { kind: "gate", retryAfter: null }, + { kind: "cooldown", retryAfter: 60 }, + ] as const)( + "shows a damaged address on $kind without keeping bytes or keys", + async (outcome) => { + const { id, meta, gate } = await link(); + let views = 0; + const api = fakeApi(meta, (presented) => { + if (presented === gate) views++; + return outcome; + }); + const { session, last, cache, keys } = harness({ + id, + key: newLinkKey(), + api, + }); + await session.start(); + expect(last()).toEqual({ + kind: "error", + message: "This link is damaged: its address may have been cut short.", + }); + expect(api.opens).toHaveLength(1); + expect(views).toBe(0); + expect(cache.get(id)).toBeNull(); + expect(keys.stored.size).toBe(0); + }, + ); + it("shows the note and keeps the sealed bytes for a reload", async () => { + const { id, key, sealed, meta } = await link(); + const expires = 1_760_003_600; + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: 4, + expires, + })); + const { session, last, cache } = harness({ id, key, api }); + const get = vi.spyOn(cache, "get"); + await session.start(); + expect(get).toHaveBeenCalledExactlyOnceWith(id); + expect(last()).toEqual({ kind: "content", payload, viewsLeft: 4, expires }); + expect(api.opens).toEqual([(await deriveLinkKeys(key)).gate]); + expect(cache.get(id)).toEqual({ ...meta, sealed, viewsLeft: 4, expires }); + }); + + it("shows a reload from the cache without asking the relay", async () => { + const { id, key, sealed, meta } = await link(); + const expires = 1_760_003_600; + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: 0, + expires, + })); + const first = harness({ id, key, api }); + await first.session.start(); + const get = vi.spyOn(first.cache, "get"); + const second = harness({ id, key, api, cache: first.cache }); + await second.session.start(); + expect(get).toHaveBeenCalledExactlyOnceWith(id); + expect(second.last()).toEqual({ + kind: "content", + payload, + viewsLeft: 0, + expires, + }); + expect(api.opens).toHaveLength(1); + expect(api.metas).toBe(1); + }); +}); + +describe("session storage cache", () => { + it("keeps the expiry with the sealed bytes", () => { + const id = newLinkId(); + const entry: CachedLink = { + sealed: Uint8Array.from([1, 2, 3]), + protected: false, + salt: null, + viewsLeft: 2, + expires: 1_760_003_600, + }; + sessionCache().put(id, entry); + expect(sessionCache().get(id)).toEqual(entry); + }); + + it("reloads a non-expiring entry without another view", async () => { + const { id, key, sealed, meta } = await link(); + sessionStorage.setItem( + `mdsync-link:${id}`, + JSON.stringify({ + ...meta, + sealed: toBase64Url(sealed), + viewsLeft: 0, + expires: null, + }), + ); + const cache = sessionCache(); + expect(cache.get(id)?.expires).toBeNull(); + const api = fakeApi(meta, () => ({ kind: "gone" })); + const { session, last } = harness({ id, key, api, cache }); + await session.start(); + expect(last()).toEqual({ + kind: "content", + payload, + viewsLeft: 0, + expires: null, + }); + expect(api.opens).toHaveLength(0); + expect(api.metas).toBe(0); + }); +}); + +describe("a link that is gone", () => { + it("says so when meta finds nothing", async () => { + const { id, key } = await link(); + const api = fakeApi(null, () => ({ kind: "gone" })); + const { session, last } = harness({ id, key, api }); + await session.start(); + expect(last()).toEqual({ kind: "gone" }); + expect(api.opens).toHaveLength(0); + }); + + it("says so when the last view went to someone else meanwhile", async () => { + const { id, key, meta } = await link(); + const api = fakeApi(meta, () => ({ kind: "gone" })); + const { session, last } = harness({ id, key, api }); + await session.start(); + expect(last()).toEqual({ kind: "gone" }); + }); +}); + +describe("a protected link", () => { + it("asks for the passphrase before it spends a view", async () => { + const { id, key, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ kind: "gone" })); + const { session, last } = harness({ id, key, api }); + await session.start(); + expect(last()).toEqual({ kind: "passphrase" }); + expect(api.opens).toHaveLength(0); + }); + + it("opens with the right passphrase, sending the gate and never the passphrase", async () => { + const { id, key, sealed, meta, gate } = await link("correct horse"); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: null, + expires: null, + })); + const { session, last } = harness({ id, key, api }); + await session.start(); + await session.submit("correct horse", false); + expect(last()).toEqual({ + kind: "content", + payload, + viewsLeft: null, + expires: null, + }); + expect(api.opens).toEqual([gate]); + }); + + it("says a passphrase is wrong, and how long to wait when the relay cools down", async () => { + const { id, key, meta } = await link("correct horse"); + const outcomes: OpenOutcome[] = [ + { kind: "gate", retryAfter: null }, + { kind: "gate", retryAfter: 60 }, + { kind: "cooldown", retryAfter: 600 }, + ]; + const api = fakeApi(meta, () => outcomes.shift() as OpenOutcome); + const { session, last } = harness({ id, key, api }); + await session.start(); + await session.submit("nope", false); + expect(last()).toEqual({ + kind: "passphrase", + problem: "Wrong passphrase.", + }); + await session.submit("nope", false); + expect(last()).toEqual({ + kind: "passphrase", + problem: "Too many wrong attempts. Try again in 60 seconds.", + }); + await session.submit("nope", false); + expect(last()).toEqual({ + kind: "passphrase", + problem: "Too many wrong attempts. Try again in 10 minutes.", + }); + }); + + it("asks again after a reload, and checks the passphrase locally", async () => { + const { id, key, sealed, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: 2, + expires: null, + })); + const first = harness({ id, key, api }); + await first.session.start(); + await first.session.submit("correct horse", false); + const get = vi.spyOn(first.cache, "get"); + const second = harness({ id, key, api, cache: first.cache }); + await second.session.start(); + expect(second.last()).toEqual({ kind: "passphrase" }); + await second.session.submit("wrong", false); + expect(second.last()).toEqual({ + kind: "passphrase", + problem: "Wrong passphrase.", + }); + await second.session.submit("correct horse", false); + expect(second.last()).toEqual({ + kind: "content", + payload, + viewsLeft: 2, + expires: null, + }); + expect(get).toHaveBeenCalledExactlyOnceWith(id); + expect(api.opens).toHaveLength(1); + }); + + it("remembers the keys, so another tab opens it without asking", async () => { + const { id, key, sealed, meta, gate } = await link("correct horse"); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: 3, + expires: 1_900_000_000, + })); + const first = harness({ id, key, api }); + await first.session.start(); + await first.session.submit("correct horse", true); + expect(first.keys.stored.get(id)?.expires).toBe(1_900_000_000); + + const second = harness({ id, key, api, keys: first.keys }); + await second.session.start(); + expect(second.states).not.toContainEqual({ kind: "passphrase" }); + expect(second.last()).toMatchObject({ kind: "content", payload }); + expect(api.opens).toEqual([gate, gate]); + }); + + it("keeps nothing when told not to remember", async () => { + const { id, key, sealed, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: null, + expires: null, + })); + const { session, keys } = harness({ id, key, api }); + await session.start(); + await session.submit("correct horse", false); + expect(keys.stored.size).toBe(0); + }); + + it("forgets kept keys the relay refuses and asks again", async () => { + const { id, key, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ kind: "gate", retryAfter: null })); + const keys = memoryKeys(); + await keys.put(id, await deriveLinkKeys(key), null); + const { session, last } = harness({ id, key, api, keys }); + await session.start(); + expect(last()).toEqual({ + kind: "passphrase", + problem: "Wrong passphrase.", + }); + expect(keys.stored.size).toBe(0); + }); + + it("keeps kept keys a tab's older copy cannot open, and asks", async () => { + const { id, key, sealed, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: null, + expires: null, + })); + const first = harness({ id, key, api }); + await first.session.start(); + await first.session.submit("correct horse", false); + const newer = await deriveLinkKeys(key, { + passphrase: "new horse", + salt: newLinkSalt(), + }); + await first.keys.put(id, newer, null); + + const reload = harness({ + id, + key, + api, + cache: first.cache, + keys: first.keys, + }); + await reload.session.start(); + expect(reload.last()).toEqual({ + kind: "passphrase", + problem: "Wrong passphrase.", + }); + expect(first.keys.stored.get(id)?.keys).toBe(newer); + }); + + it("forgets kept keys once the link is gone", async () => { + const { id, key } = await link("correct horse"); + const keys = memoryKeys(); + await keys.put(id, await deriveLinkKeys(key), null); + const { session, last } = harness({ + id, + key, + api: fakeApi(null, () => ({ kind: "gone" })), + keys, + }); + await session.start(); + expect(last()).toEqual({ kind: "gone" }); + expect(keys.stored.size).toBe(0); + }); + + it("ignores an empty passphrase", async () => { + const { id, key, meta } = await link("correct horse"); + const api = fakeApi(meta, () => ({ kind: "gone" })); + const { session, states } = harness({ id, key, api }); + await session.start(); + const before = states.length; + await session.submit("", false); + expect(states).toHaveLength(before); + }); +}); + +describe("trouble", () => { + it("reports a link whose key was cut short as damaged, not as wrong passphrase", async () => { + const { id, sealed, meta } = await link(); + const api = fakeApi(meta, () => ({ + kind: "opened", + sealed, + viewsLeft: null, + expires: null, + })); + const { session, last } = harness({ id, key: newLinkKey(), api }); + await session.start(); + expect(last()).toMatchObject({ kind: "error" }); + }); + + it("reports an unreachable relay", async () => { + const { id, key } = await link(); + const api: LinkApi = { + meta: async () => { + throw new TypeError("Failed to fetch"); + }, + open: async () => ({ kind: "gone" }), + }; + const { session, last } = harness({ id, key, api }); + await session.start(); + expect(last()).toEqual({ kind: "error", message: "Failed to fetch" }); + }); +}); diff --git a/packages/viewer/tests/sidebar.test.ts b/packages/viewer/tests/sidebar.test.ts new file mode 100644 index 00000000..613486bd --- /dev/null +++ b/packages/viewer/tests/sidebar.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSidebar } from "../src/sidebar"; + +let controller: AbortController; +let reader: HTMLElement; +let sidebar: HTMLElement; +let toggle: HTMLButtonElement; +const wideKey = "mdsync-viewer-wide"; + +beforeEach(() => { + localStorage.clear(); + controller = new AbortController(); + reader = document.createElement("div"); + document.body.replaceChildren(reader); +}); + +afterEach(() => { + controller.abort(); + localStorage.clear(); + document.body.replaceChildren(); +}); + +const mount = ( + viewsLeft: number | null = null, + expires: number | null = null, + outline: HTMLElement | null = null, + markdown: () => string = () => "# Note\n", +) => { + sidebar = createSidebar( + reader, + outline, + { viewsLeft, expires }, + markdown, + controller.signal, + ); + reader.append(sidebar); + toggle = sidebar.querySelector(".sidebar-toggle") as HTMLButtonElement; + return sidebar; +}; + +const expectOpen = (open: boolean) => { + expect(sidebar.classList.contains("is-open")).toBe(open); + expect(toggle.getAttribute("aria-expanded")).toBe(String(open)); +}; + +describe("sidebar", () => { + it.each([false, true])("builds the panel with outline %s", (hasOutline) => { + const outline = hasOutline ? document.createElement("nav") : null; + mount(null, null, outline); + expect(sidebar.tagName).toBe("ASIDE"); + expect(sidebar.className).toBe("sidebar"); + expect(toggle.type).toBe("button"); + expect(toggle.getAttribute("aria-label")).toBe("Menu"); + const panel = sidebar.querySelector(".sidebar-panel") as HTMLElement; + const card = panel.querySelector(".link-card"); + expect(Array.from(sidebar.children)).toEqual([toggle, panel]); + expect(Array.from(panel.children)).toEqual( + outline ? [card, outline] : [card], + ); + expect(card?.querySelector(".link-fact")).toBeNull(); + expect(card?.querySelector(".wide-toggle")?.textContent).toBe("Wide text"); + }); + + it("copies the note as Markdown and says how it went", async () => { + const writeText = vi.fn(async (_text: string) => {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + mount(); + const copy = sidebar.querySelector(".copy-markdown") as HTMLButtonElement; + expect(copy.textContent).toBe("Copy as Markdown"); + copy.click(); + await vi.waitFor(() => expect(copy.textContent).toBe("Copied")); + expect(writeText).toHaveBeenCalledExactlyOnceWith("# Note\n"); + vi.unstubAllGlobals(); + }); + + it("says so when the note cannot be copied", async () => { + mount(null, null, null, () => { + throw new Error("not convertible"); + }); + const copy = sidebar.querySelector(".copy-markdown") as HTMLButtonElement; + copy.click(); + await vi.waitFor(() => expect(copy.textContent).toBe("Copy failed")); + }); + + it.each([ + [ + 0, + "This was the last view of this link. Reloading this tab keeps the note until you close it.", + ], + [1, "This link can be opened 1 more time."], + [3, "This link can be opened 3 more times."], + ] as const)("shows %i views left", (viewsLeft, text) => { + const line = mount(viewsLeft).querySelector("p.link-fact.link-views"); + expect(line?.textContent).toBe(text); + }); + + it("toggles the menu and expanded state", () => { + mount(); + expectOpen(false); + toggle.click(); + expectOpen(true); + toggle.click(); + expectOpen(false); + }); + + it("closes outside but not inside the sidebar", () => { + mount(); + toggle.click(); + (sidebar.querySelector(".link-card") as HTMLElement).click(); + expectOpen(true); + reader.click(); + expectOpen(false); + }); + + it("closes when an outline item is clicked", () => { + const outline = document.createElement("nav"); + outline.innerHTML = + ''; + mount(null, null, outline); + toggle.click(); + (outline.querySelector("span") as HTMLElement).click(); + expectOpen(false); + }); + + it("closes on Escape but not other keys", () => { + mount(); + toggle.click(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expectOpen(true); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expectOpen(false); + }); + + it("stops closing outside or on Escape after abort", () => { + mount(); + toggle.click(); + controller.abort(); + reader.click(); + expectOpen(true); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expectOpen(true); + }); + + it.each([false, true])( + "starts wide from storage %s and remembers changes", + (stored) => { + if (stored) localStorage.setItem(wideKey, "1"); + const wide = mount().querySelector(".wide-toggle") as HTMLButtonElement; + expect(reader.classList.contains("is-wide")).toBe(stored); + expect(wide.getAttribute("aria-pressed")).toBe(String(stored)); + wide.click(); + expect(reader.classList.contains("is-wide")).toBe(!stored); + expect(wide.getAttribute("aria-pressed")).toBe(String(!stored)); + expect(localStorage.getItem(wideKey)).toBe(stored ? null : "1"); + wide.click(); + expect(reader.classList.contains("is-wide")).toBe(stored); + expect(wide.getAttribute("aria-pressed")).toBe(String(stored)); + expect(localStorage.getItem(wideKey)).toBe(stored ? "1" : null); + }, + ); +}); diff --git a/packages/viewer/tests/view.test.ts b/packages/viewer/tests/view.test.ts new file mode 100644 index 00000000..ce805052 --- /dev/null +++ b/packages/viewer/tests/view.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createView } from "../src/view"; + +let root: HTMLElement; +const submitted: [string, boolean][] = []; +const views: ReturnType[] = []; +const scrollIntoView = Element.prototype.scrollIntoView; +const scrolled: Element[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + history.replaceState(null, "", "/"); + document.body.innerHTML = '
'; + root = document.getElementById("app") as HTMLElement; + submitted.length = 0; + scrolled.length = 0; + Element.prototype.scrollIntoView = function (this: Element, options) { + expect(options).toEqual({ block: "start" }); + scrolled.push(this); + }; +}); + +afterEach(() => { + for (const v of views) v.show({ kind: "loading" }); + views.length = 0; + vi.clearAllTimers(); + vi.useRealTimers(); + Element.prototype.scrollIntoView = scrollIntoView; + history.replaceState(null, "", "/"); + localStorage.clear(); +}); + +const view = () => { + const v = createView(root, { + onPassphrase: (value, remember) => submitted.push([value, remember]), + }); + views.push(v); + return v; +}; +const showContent = ( + html: string, + viewsLeft: number | null = null, + title = "Trip", +) => { + const v = view(); + v.show({ + kind: "content", + payload: { title, html, createdAt: 1 }, + viewsLeft, + expires: null, + }); + return v; +}; + +describe("states", () => { + it.each([ + ["gone", "This link is no longer available"], + ["invalid", "This link is incomplete"], + ] as const)("shows %s", (kind, heading) => { + view().show({ kind }); + expect(root.querySelector("h1")?.textContent).toBe(heading); + }); + + it("shows an error with its reason", () => { + view().show({ kind: "error", message: "The relay answered 502." }); + expect(root.textContent).toContain("The relay answered 502."); + }); + + it("replaces the previous state", () => { + const v = view(); + v.show({ kind: "loading" }); + v.show({ kind: "gone" }); + expect(root.querySelector(".loading")).toBeNull(); + expect(root.children).toHaveLength(1); + }); + + it("submits the passphrase and shows the problem", () => { + const v = view(); + v.show({ kind: "passphrase", problem: "Wrong passphrase." }); + expect(root.querySelector(".problem")?.textContent).toBe( + "Wrong passphrase.", + ); + const input = root.querySelector("input") as HTMLInputElement; + input.value = "correct horse"; + const form = root.querySelector("form"); + const remember = root.querySelector(".remember input") as HTMLInputElement; + expect(remember.checked).toBe(false); + remember.checked = true; + form?.dispatchEvent(new Event("submit", { cancelable: true })); + v.show({ kind: "passphrase", problem: "Wrong passphrase." }); + const kept = root.querySelector(".remember input") as HTMLInputElement; + expect(kept.checked).toBe(true); + kept.checked = false; + (root.querySelector("#passphrase") as HTMLInputElement).value = + "correct horse"; + root + .querySelector("form") + ?.dispatchEvent(new Event("submit", { cancelable: true })); + v.show({ kind: "passphrase", problem: "Wrong passphrase." }); + expect( + (root.querySelector(".remember input") as HTMLInputElement).checked, + ).toBe(false); + expect(submitted).toEqual([ + ["correct horse", true], + ["correct horse", false], + ]); + expect(input.type).toBe("password"); + }); +}); +describe("content", () => { + it.each(["Trip", "Other"])("avoids repeating the title %s", (title) => { + showContent("

Trip

", null, title); + expect(root.querySelector(".note-title")?.textContent ?? null).toBe( + title === "Trip" ? null : "Other", + ); + }); + + it.each([2, 3, 4])("uses an outline for %i headings when needed", (count) => { + showContent("

Stop

".repeat(count)); + const reader = root.querySelector(".reader") as HTMLElement; + expect(reader.className).toBe("reader"); + expect(Array.from(reader.children, (el) => el.tagName)).toEqual([ + "ASIDE", + "ARTICLE", + ]); + expect(reader.firstElementChild?.className).toBe("sidebar"); + expect( + reader.querySelectorAll(".sidebar-panel > nav.outline"), + ).toHaveLength(count >= 3 ? 1 : 0); + }); + + it("unfolds a footnote before scrolling to it", () => { + showContent( + '[1]

Notes

  1. Note
', + ); + const section = root.querySelector(".heading-section") as HTMLElement; + (section.querySelector(".heading-fold") as HTMLButtonElement).click(); + expect(section.classList.contains("is-collapsed")).toBe(true); + (root.querySelector(".markdown-rendered a") as HTMLAnchorElement).click(); + expect(section.classList.contains("is-collapsed")).toBe(false); + expect(scrolled).toEqual([root.querySelector("#fn-1")]); + }); + + it("shows the sanitized note under its title and sets the tab title", () => { + showContent("

Hello world

"); + expect(root.querySelector(".note-title")?.textContent).toBe("Trip"); + expect(root.querySelector(".markdown-rendered p")?.textContent).toBe( + "Hello world", + ); + expect(root.querySelector("script")).toBeNull(); + expect(document.title).toBe("Trip"); + expect(root.querySelector(".link-views")).toBeNull(); + }); + + it("tells how many views are left", () => { + const show = (viewsLeft: number) => { + showContent("

x

", viewsLeft); + return root.querySelector(".link-views")?.textContent; + }; + expect(show(1)).toBe("This link can be opened 1 more time."); + expect(show(3)).toBe("This link can be opened 3 more times."); + expect(show(0)).toContain("last view"); + }); + + it.each([false, true])("toggles only collapsible callouts: %s", (folds) => { + const classes = folds ? "callout is-collapsible is-collapsed" : "callout"; + showContent( + `
Note
x
`, + ); + const callout = root.querySelector(".callout") as HTMLElement; + const title = callout.querySelector("span") as HTMLElement; + title.click(); + expect(callout.classList.contains("is-collapsed")).toBe(false); + title.click(); + expect(callout.classList.contains("is-collapsed")).toBe(folds); + }); + + it("jumps within the note without replacing the key", () => { + history.replaceState(null, "", "/s/abc#key"); + showContent( + '

Text[1]

  1. Note
', + ); + const anchor = root.querySelector("a") as HTMLAnchorElement; + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + anchor.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + expect(scrolled).toEqual([root.querySelector("#fn-1")]); + expect(location.hash).toBe("#key"); + }); + + it("jumps to an id with a literal percent sign", () => { + showContent('top

100%

'); + (root.querySelector("a") as HTMLAnchorElement).dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(scrolled).toEqual([root.querySelector('h2[id="100%"]')]); + }); + + it("jumps after render and on hash changes until the next show", async () => { + const token = "AAAAAAAAAAAAAAAAAAAAAA"; + const address = `/s/${token}#${token}`; + history.replaceState(null, "", `${address}/first`); + const v = showContent("

First

Second

"); + const headings = root.querySelectorAll("h2"); + expect(scrolled).toEqual([]); + await vi.advanceTimersByTimeAsync(16); + expect(scrolled).toEqual([headings[0]]); + history.replaceState(null, "", `${address}/second`); + window.dispatchEvent(new HashChangeEvent("hashchange")); + expect(scrolled).toEqual([headings[0], headings[1]]); + v.show({ kind: "gone" }); + history.replaceState(null, "", `${address}/first`); + window.dispatchEvent(new HashChangeEvent("hashchange")); + expect(scrolled).toHaveLength(2); + }); +}); diff --git a/packages/viewer/tsconfig.json b/packages/viewer/tsconfig.json new file mode 100644 index 00000000..0c51e3b3 --- /dev/null +++ b/packages/viewer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "lib": ["es2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/viewer/vitest.config.ts b/packages/viewer/vitest.config.ts new file mode 100644 index 00000000..147cdc8a --- /dev/null +++ b/packages/viewer/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644b832a..1d03413e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,18 +84,21 @@ importers: esbuild: specifier: ^0.25.12 version: 0.25.12 + jsdom: + specifier: ^30.1.2 + version: 30.1.2 mdsync-relay: specifier: workspace:* version: link:../relay vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)) packages/protocol: devDependencies: vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/relay: dependencies: @@ -108,13 +111,55 @@ importers: version: 5.20260914.1 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) wrangler: specifier: ^4.131.2 version: 4.131.2(@cloudflare/workers-types@5.20260914.1)(@types/node@22.20.1) + packages/viewer: + dependencies: + '@mdsync/protocol': + specifier: workspace:* + version: link:../protocol + dompurify: + specifier: ^3.4.16 + version: 3.4.16 + idb-keyval: + specifier: ^6.3.0 + version: 6.3.0 + turndown: + specifier: ^7.2.4 + version: 7.2.4 + turndown-plugin-gfm: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 + jsdom: + specifier: ^30.1.2 + version: 30.1.2 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)) + packages: + '@asamuzakjp/css-color@7.1.3': + resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@biomejs/biome@2.5.9': resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} engines: {node: '>=14.21.3'} @@ -168,6 +213,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -233,6 +282,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -557,6 +642,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -725,6 +819,9 @@ packages: '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -873,6 +970,18 @@ packages: '@types/tern@0.23.9': resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -914,9 +1023,16 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -945,10 +1061,21 @@ packages: crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -960,9 +1087,20 @@ packages: dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -986,6 +1124,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1008,11 +1150,22 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + happy-dom@20.14.5: + resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==} + engines: {node: '>=20.0.0'} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + ignore@7.0.6: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} @@ -1021,6 +1174,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -1028,6 +1184,15 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1111,6 +1276,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1118,6 +1287,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + miniflare@5.20260911.1-alpha: resolution: {integrity: sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ==} engines: {node: '>=22.0.0'} @@ -1152,6 +1324,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1182,6 +1357,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -1191,6 +1370,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -1199,6 +1382,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1259,9 +1446,31 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.16: + resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} + + tldts@7.4.16: + resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turndown-plugin-gfm@1.0.2: + resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} + + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1274,6 +1483,10 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -1364,6 +1577,26 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -1403,6 +1636,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-codemirror.next@0.3.6: resolution: {integrity: sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA==} peerDependencies: @@ -1444,6 +1684,21 @@ packages: snapshots: + '@asamuzakjp/css-color@7.1.3': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@biomejs/biome@2.5.9': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.9 @@ -1479,6 +1734,10 @@ snapshots: '@biomejs/cli-win32-x64@2.5.9': optional: true + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260911.1)': @@ -1543,6 +1802,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1720,6 +2003,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.16.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -1847,6 +2132,8 @@ snapshots: '@marijn/find-cluster-break@1.0.3': {} + '@mixmark-io/domino@2.2.0': {} + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1957,6 +2244,19 @@ snapshots: dependencies: '@types/estree': 1.0.9 + '@types/trusted-types@2.0.7': + optional: true + + '@types/turndown@5.0.6': {} + + '@types/whatwg-mimetype@3.0.2': + optional: true + + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.20.1 + optional: true + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -2014,8 +2314,17 @@ snapshots: assertion-error@2.0.1: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + blake3-wasm@2.1.5: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.20.1 + optional: true + camelcase@5.3.1: {} chai@6.2.2: {} @@ -2038,16 +2347,39 @@ snapshots: crelt@1.0.7: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + transitivePeerDependencies: + - '@noble/hashes' + decamelize@1.2.0: {} + decimal.js@10.6.0: {} + detect-libc@2.1.2: {} diff@9.0.0: {} dijkstrajs@1.0.3: {} + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + emoji-regex@8.0.0: {} + entities@7.0.1: + optional: true + + entities@8.1.0: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@2.3.2: {} @@ -2116,6 +2448,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -2130,16 +2464,65 @@ snapshots: get-caller-file@2.0.5: {} + happy-dom@20.14.5: + dependencies: + '@types/node': 22.20.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + husky@9.1.7: {} + idb-keyval@6.3.0: {} + ignore@7.0.6: {} is-fullwidth-code-point@3.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isomorphic.js@0.2.5: {} jiti@2.7.0: {} + jsdom@30.1.2: + dependencies: + '@asamuzakjp/css-color': 7.1.3 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + kleur@4.1.5: {} lib0@0.2.117: @@ -2199,12 +2582,16 @@ snapshots: dependencies: p-locate: 4.1.0 + lru-cache@11.5.3: {} + lz-string@1.5.0: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.27.1: {} + miniflare@5.20260911.1-alpha(@types/node@22.20.1): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -2241,6 +2628,10 @@ snapshots: p-try@2.2.0: {} + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-to-regexp@6.3.0: {} @@ -2261,6 +2652,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -2269,6 +2662,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} rolldown@1.0.1: @@ -2292,6 +2687,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} set-blocking@2.0.0: {} @@ -2362,14 +2761,36 @@ snapshots: tinyrainbow@3.1.1: {} + tldts-core@7.4.16: {} + + tldts@7.4.16: + dependencies: + tldts-core: 7.4.16 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.16 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} + turndown-plugin-gfm@1.0.2: {} + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + typescript@6.0.3: {} undici-types@6.21.0: {} undici@7.29.0: {} + undici@8.11.2: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -2402,7 +2823,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@types/node@22.20.1)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.0.13(@types/node@22.20.1)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)) @@ -2426,10 +2847,12 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 + happy-dom: 20.14.5 + jsdom: 30.1.2 transitivePeerDependencies: - msw - vitest@4.1.11(@types/node@22.20.1)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.14.5)(jsdom@30.1.2)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.0.13(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -2453,11 +2876,32 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 + happy-dom: 20.14.5 + jsdom: 30.1.2 transitivePeerDependencies: - msw w3c-keyname@2.2.8: {} + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@3.0.0: + optional: true + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.2.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-module@2.0.1: {} why-is-node-running@2.3.0: @@ -2499,6 +2943,10 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y-codemirror.next@0.3.6(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(yjs@13.6.32): dependencies: '@codemirror/state': 6.7.1 diff --git a/tests/e2e/device.ts b/tests/e2e/device.ts index da113dd9..b4a0617f 100644 --- a/tests/e2e/device.ts +++ b/tests/e2e/device.ts @@ -66,7 +66,7 @@ export function write( ); } -/** Right-clicks `folder` in the file explorer and picks the item titled `title`. */ +/** Right-clicks a folder or file in the file explorer and picks the item titled `title`. */ export function clickMenuItem( device: Obsidian, folder: string, @@ -94,7 +94,7 @@ export function clickMenuItem( app.workspace.trigger( "file-menu", menu, - app.vault.getFolderByPath(path), + app.vault.getFolderByPath(path) ?? app.vault.getFileByPath(path), "file-explorer", ); const found = items.find((item) => item.title === wanted); diff --git a/tests/e2e/link-client.ts b/tests/e2e/link-client.ts new file mode 100644 index 00000000..8c329791 --- /dev/null +++ b/tests/e2e/link-client.ts @@ -0,0 +1,77 @@ +/** What the plugin will do against a relay's link routes, for the link scenarios. */ + +import { + deriveLinkKeys, + linkUrl, + newLinkId, + newLinkKey, + newLinkSalt, + sealLinkPayload, + toBase64Url, +} from "@mdsync/protocol"; + +export const SECRET = "e2e-secret"; + +export function admin( + base: string, + path: string, + init: RequestInit = {}, +): Promise { + return fetch(`${base}${path}`, { + ...init, + headers: { ...init.headers, "X-Mdsync-Admin": SECRET }, + }); +} + +export function put( + base: string, + id: string, + body: Uint8Array, + query = "", + headers: Record = {}, +): Promise { + return admin(base, `/link/${id}${query}`, { method: "PUT", headers, body }); +} + +export function open( + base: string, + id: string, + gate?: string, +): Promise { + return fetch(`${base}/link/${id}/open`, { + method: "POST", + body: gate === undefined ? undefined : JSON.stringify({ gate }), + }); +} + +export interface Published { + id: string; + /** What the owner sends: the page and the key after the `#`. */ + url: string; +} + +/** Seals a note and stores it, as the plugin's share action will. */ +export async function publish( + base: string, + note: { title: string; html: string }, + options: { passphrase?: string; maxViews?: number } = {}, +): Promise { + const id = newLinkId(); + const key = newLinkKey(); + const salt = newLinkSalt(); + const keys = await deriveLinkKeys( + key, + options.passphrase ? { passphrase: options.passphrase, salt } : undefined, + ); + const sealed = await sealLinkPayload( + id, + { ...note, createdAt: Date.now() }, + keys.content, + ); + const query = options.maxViews ? `?maxViews=${options.maxViews}` : ""; + const headers: Record = { "X-Mdsync-Gate": keys.gate }; + if (options.passphrase) headers["X-Mdsync-Salt"] = toBase64Url(salt); + const stored = await put(base, id, sealed, query, headers); + if (!stored.ok) throw new Error(`could not store the link: ${stored.status}`); + return { id, url: linkUrl(base, id, key) }; +} diff --git a/tests/e2e/links-obsidian.ts b/tests/e2e/links-obsidian.ts new file mode 100644 index 00000000..6edc02ae --- /dev/null +++ b/tests/e2e/links-obsidian.ts @@ -0,0 +1,447 @@ +/** + * Sharing a note by link in a real Obsidian against a real relay, then opening it in a real browser: the plugin's + * rendering and clean-up, the passphrase, the view count, an update and a stop. Run `pnpm build` first. + */ + +import { type Browser, chromium, type Page } from "playwright-core"; + +import { clickMenuItem, confirm, field, fill, loaded, press } from "./device"; +import { check, poll, runScenario } from "./harness"; +import { admin, SECRET } from "./link-client"; +import { launchObsidian, type Obsidian } from "./obsidian"; +import { startRelay } from "./relay"; + +// biome-ignore lint/suspicious/noExplicitAny: Obsidian's renderer app is untyped here. +declare const app: any; +declare const activeDocument: Document; + +const OBSIDIAN_PORT = 9246; +const RELAY_PORT = 8799; +const PASSPHRASE = "correct horse battery"; +const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +const NOTE = `--- +password: hunter2 +--- +# Trip + +Landing in **May** with [[Other]] and [a map](https://example.com/map). A tag #travel. + +Inline math $E=mc^2$ and block: + +$$ +\\int_0^1 x^2 dx = \\frac{1}{3} +$$ + +> [!tip] Pack light +> Only a carry-on. + +- [x] Book hotel + +![[Other]] + +![[pic.png]] + +%%private comment%% + +## Days + +### Lisbon + +\`\`\`ts +const days = 3; +\`\`\` +`; + +await runScenario("links in obsidian e2e", async () => { + const relay = await startRelay(RELAY_PORT, SECRET); + const device = await launchObsidian({ + port: OBSIDIAN_PORT, + settings: { + realtimeSync: false, + cachePassphrase: false, + relayUrl: relay.url, + relaySecret: SECRET, + }, + files: { "Trip.md": NOTE, "Other.md": "Other note body, private.\n" }, + }); + const browser = await chromium.launch({ headless: true }); + try { + await loaded(device); + await device.evaluate(async (base64) => { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + await app.vault.createBinary("pic.png", bytes.buffer); + }, PNG); + const link = await create(device); + await recipient(browser, link, relay.url); + await manage(device, browser, link); + } finally { + await browser.close(); + await device.stop(); + relay.stop(); + } +}); + +async function create(device: Obsidian): Promise { + await clickMenuItem(device, "Trip.md", "MDSync: Share link"); + await device.waitFor( + "the preview", + () => activeDocument.querySelector(".mdsync-link-modal")?.textContent ?? "", + (text) => text.includes("Left out of the link"), + ); + const left = await device.evaluate( + () => + [...activeDocument.querySelectorAll(".mdsync-link-modal p")] + .map((p) => p.textContent) + .find((text) => text?.startsWith("Left out")) ?? "", + ); + check( + "the preview says what is left out", + left, + "Left out of the link: 1 embedded file.", + ); + check( + "the preview shows what goes out, in a sandbox", + await device.evaluate(() => { + const frame = activeDocument.querySelector(".mdsync-link-preview iframe"); + const page = frame?.getAttribute("srcdoc") ?? ""; + return [ + frame?.getAttribute("sandbox"), + page.includes("Landing in"), + page.includes("hunter2"), + ]; + }), + ["", true, false], + ); + await choose(device, "Expires", "date"); + check( + "a picked date and time, two hours ahead, is taken", + await device.evaluate(() => { + const input = activeDocument.querySelector( + '.modal input[type="datetime-local"]', + ) as HTMLInputElement; + const when = new Date(Date.now() + 2 * 3600_000); + const local = new Date( + when.getTime() - when.getTimezoneOffset() * 60_000, + ); + input.value = local.toISOString().slice(0, 16); + input.dispatchEvent(new Event("input")); + const row = input.closest(".setting-item"); + return [ + input.offsetParent !== null, + row + ?.querySelector(".setting-item-description") + ?.textContent?.startsWith("Expires "), + ]; + }), + [true, true], + ); + await choose(device, "Views", "5"); + await fill(device, "Passphrase", PASSPHRASE); + await press(device, "Create link"); + const link = await field(device, "Link"); + check( + "the passphrase is shown once beside the link", + [ + await field(device, "Passphrase"), + link.startsWith(`http://127.0.0.1:${RELAY_PORT}/s/`) && + link.includes("#"), + ], + [PASSPHRASE, true], + ); + await device.evaluate(() => { + for (const button of activeDocument.querySelectorAll(".modal button")) { + if (button.textContent === "Done") (button as HTMLElement).click(); + } + }); + check( + "the plugin keeps the link but not the passphrase", + await device.evaluate(() => { + const { links } = app.plugins.plugins.mdsync.settings; + const text = JSON.stringify(links); + return [links.length, text.includes("correct horse")]; + }), + [1, false], + ); + return link; +} + +async function recipient( + browser: Browser, + link: string, + base: string, +): Promise { + const page = await open(browser, link); + check( + "the link asks for the passphrase", + await page.locator("#passphrase").count(), + 1, + ); + await page.locator("#passphrase").fill(PASSPHRASE); + await page.locator("button[type=submit]").click(); + await page.locator(".markdown-rendered").waitFor(); + const note = page.locator(".markdown-rendered"); + const text = (await note.textContent()) ?? ""; + check( + "a note opening with its name shows the name once", + await page.locator("h1", { hasText: "Trip" }).count(), + 1, + ); + check( + "it keeps the formatting", + await note.locator("strong").first().textContent(), + "May", + ); + check( + "web links stay, links to other notes become text", + [ + await note.locator('a[href="https://example.com/map"]').count(), + await note.locator("a").count(), + text.includes("Other"), + ], + [1, 1, true], + ); + check( + "properties, comments and embedded notes stay out", + [ + text.includes("hunter2"), + text.includes("password"), + text.includes("private comment"), + text.includes("Other note body"), + ], + [false, false, false, false], + ); + check( + "math is MathML", + [ + await note.locator("math").count(), + await note.locator('math[display="block"]').count(), + await note.locator("mjx-container").count(), + ], + [2, 1, 0], + ); + check( + "a block formula needs no scrollbar", + await note.locator('math[display="block"]').evaluate((math) => ({ + wider: math.scrollWidth > math.clientWidth, + taller: math.scrollHeight > math.clientHeight, + })), + { wider: false, taller: false }, + ); + check( + "the callout, the task and the image come along", + [ + await note.locator('.callout[data-callout="tip"]').count(), + await note.locator(".callout-title-inner").first().textContent(), + await note.locator("input[type=checkbox][disabled]").count(), + await note.locator('img[src^="data:image/png"]').count(), + ], + [1, "Pack light", 1, 1], + ); + check( + "the outline lists the headings, code blocks copy", + [ + await page.locator(".outline-item").allTextContents(), + await page.locator("pre .copy-code-button").count(), + ], + [["Trip", "Days", "Lisbon"], 1], + ); + check( + "it tells how many views and how long are left", + [ + await page.locator(".link-views").textContent(), + await page.locator(".link-expiry").textContent(), + ], + ["This link can be opened 4 more times.", "Expires in 2 hours."], + ); + check("the relay counted the view", await views(base, link), 1); + if (process.env.E2E_SHOTS) { + await page.screenshot({ + path: "artifacts/e2e-shots/link-viewer.png", + fullPage: true, + }); + } + await page.context().close(); +} + +async function manage( + device: Obsidian, + browser: Browser, + link: string, +): Promise { + await device.evaluate(() => + app.workspace.getLeaf().openFile(app.vault.getFileByPath("Trip.md")), + ); + check( + "a shared note is marked in the tree and in its header", + await marks(device, "fresh"), + ["fresh", "fresh"], + ); + await device.evaluate(() => + app.vault.modify( + app.vault.getFileByPath("Trip.md"), + "# Trip\n\nChanged after sharing.\n", + ), + ); + check( + "an edit after sharing marks the link outdated", + await marks(device, "stale"), + ["stale", "stale"], + ); + check( + "the command to update this note's links is offered", + await device.evaluate(() => + app.commands.findCommand("mdsync:update-note-links")?.checkCallback(true), + ), + true, + ); + + await device.evaluate(() => + ( + document.querySelector( + '[data-path="Trip.md"] .mdsync-published-badge', + ) as HTMLElement + ).click(), + ); + const status = await device.waitFor( + "the link's status", + () => + activeDocument.querySelector( + ".mdsync-link-modal .setting-item-description", + )?.textContent ?? "", + (text) => text.includes("views left"), + ); + check( + "the badge opens this note's links, saying which changed", + [ + status.startsWith("Changed since it was shared."), + status.includes("4 of 5 views left"), + ], + [true, true], + ); + + await press(device, "Update"); + await promptFor(device, PASSPHRASE); + await device.waitFor( + "the update to finish", + () => + activeDocument.querySelector( + ".mdsync-link-modal .setting-item-description", + )?.textContent ?? "", + (text) => text.includes("views left") && !text.startsWith("Changed"), + ); + check("an update clears the outdated marks", await marks(device, "fresh"), [ + "fresh", + "fresh", + ]); + const page = await open(browser, link); + await page.locator("#passphrase").fill(PASSPHRASE); + await page.locator("button[type=submit]").click(); + await page.locator(".markdown-rendered").waitFor(); + check( + "an update shows the note as it is now and keeps the views spent", + [ + (await page.locator(".markdown-rendered").textContent())?.includes( + "Changed after sharing.", + ), + await page.locator(".link-views").textContent(), + ], + [true, "This link can be opened 3 more times."], + ); + await page.context().close(); + + await device.evaluate(() => { + const stop = activeDocument.querySelector( + '.mdsync-link-modal [aria-label="Stop sharing"]', + ); + (stop as HTMLElement).click(); + }); + await confirm(device, "Stop sharing"); + const gone = await open(browser, link, "h1"); + check( + "a stopped link is gone", + await gone.locator("h1").textContent(), + "This link is no longer available", + ); + await gone.context().close(); + check( + "and leaves the list", + await device.evaluate( + () => app.plugins.plugins.mdsync.settings.links.length, + ), + 0, + ); +} + +/** Trip.md's tree badge and header button, each "fresh", "stale" or "none", once both read `want`. */ +function marks(device: Obsidian, want: string): Promise { + return device.waitFor( + `the shared-note marks to read ${want}`, + () => + [ + '[data-path="Trip.md"] .mdsync-published-badge', + ".workspace-leaf.mod-active .mdsync-published-action", + ].map((selector) => { + const mark = document.querySelector(selector); + if (!mark) return "none"; + return mark.classList.contains("is-stale") ? "stale" : "fresh"; + }), + (seen) => seen.every((mark) => mark === want), + ); +} + +async function open( + browser: Browser, + link: string, + ready = "#passphrase", +): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(link); + await page.locator(ready).waitFor(); + return page; +} + +async function views(base: string, link: string): Promise { + const id = new URL(link).pathname.split("/").pop(); + const status = await admin(base, `/link/${id}/status`); + return ((await status.json()) as { views: number }).views; +} + +/** Picks `value` in the open modal's dropdown named `name`. */ +function choose(device: Obsidian, name: string, value: string): Promise { + return device.evaluate( + ({ label, wanted }) => { + const select = [ + ...activeDocument.querySelectorAll(".modal .setting-item"), + ] + .find( + (row) => + row.querySelector(".setting-item-name")?.textContent === label, + ) + ?.querySelector("select"); + if (!select) throw new Error(`no "${label}" dropdown`); + select.value = wanted; + select.dispatchEvent(new Event("change")); + }, + { label: name, wanted: value }, + ); +} + +/** Answers the prompt modal, whose field is not a setting. */ +async function promptFor(device: Obsidian, text: string): Promise { + await poll("the prompt", () => + device.evaluate(() => + activeDocument.querySelector(".mdsync-prompt-input") ? true : undefined, + ), + ); + await device.evaluate((value) => { + const input = activeDocument.querySelector( + ".mdsync-prompt-input", + ) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event("input")); + }, text); + await press(device, "Update"); +} diff --git a/tests/e2e/links-viewer.ts b/tests/e2e/links-viewer.ts new file mode 100644 index 00000000..09375883 --- /dev/null +++ b/tests/e2e/links-viewer.ts @@ -0,0 +1,400 @@ +/** + * The link viewer in a real browser, served by the relay: what a recipient sees, the passphrase form, + * that a reload costs no view, and that a hostile note cannot run script. + */ + +import { newLinkKey, toBase64Url } from "@mdsync/protocol"; +import { + type Browser, + type BrowserContext, + chromium, + type Page, +} from "playwright-core"; + +import { check } from "./harness"; +import { admin, type Published, publish } from "./link-client"; + +const NOTE = { + title: "Trip notes", + html: [ + '

Lisbon

Landing in May with a map.

', + '', + '
  • Book hotel
', + '

Porto

const a = 1;
', + "

Food

Pastel de nata.

", + // Room below, so a jump can bring the last headings to the top. + "

Notes from the road.

".repeat(30), + ].join(""), +}; + +const HOSTILE = { + title: "Hostile", + html: '

safe text

click', +}; + +export async function viewerScenario(base: string): Promise { + const browser = await launch(); + try { + await shell(base); + await recipient(base, browser); + await oneView(base, browser); + await protectedNote(base, browser); + await hostile(base, browser); + await brokenLinks(base, browser); + } finally { + await browser.close(); + } +} + +async function launch(): Promise { + try { + return await chromium.launch({ headless: true }); + } catch { + return chromium.launch({ headless: true, channel: "chrome" }); + } +} + +async function shell(base: string): Promise { + const { id } = await publish(base, NOTE); + const page = await fetch(`${base}/s/${id}`); + const csp = page.headers.get("Content-Security-Policy") ?? ""; + check("the link page is served", page.status, 200); + check( + "under a policy that allows no inline script", + [csp.includes("script-src 'self'"), csp.includes("frame-ancestors 'none'")], + [true, true], + ); + check( + "and never a referrer", + page.headers.get("Referrer-Policy"), + "no-referrer", + ); + check( + "it loads the viewer script", + (await page.text()).includes("/viewer/viewer.js"), + true, + ); + const script = await fetch(`${base}/viewer/viewer.js`); + check( + "the script is served as a plain asset", + [script.status, script.headers.get("Content-Type")?.includes("javascript")], + [200, true], + ); + check( + "an id that cannot be a link's is 404", + (await fetch(`${base}/s/short`)).status, + 404, + ); + check( + "looking at a link spends no view", + (await (await admin(base, `/link/${id}/status`)).json()).views, + 0, + ); +} + +async function recipient(base: string, browser: Browser): Promise { + const link = await publish(base, NOTE); + const context = await browser.newContext(); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + const page = await context.newPage(); + await page.goto(link.url); + await page.locator(".markdown-rendered").waitFor(); + check( + "the recipient reads the note", + await page.locator(".note-title").textContent(), + "Trip notes", + ); + check( + "with its formatting", + await page.locator("h2").first().textContent(), + "Lisbon", + ); + await outlineAndFolds(page); + check( + "external links open in a new tab, safely", + await page.locator("a").getAttribute("rel"), + "noopener noreferrer nofollow", + ); + check("the page is titled by the note", await page.title(), "Trip notes"); + check( + "a folded callout starts closed", + await page.locator(".callout-content").isVisible(), + false, + ); + await page.locator(".callout-title").click(); + check( + "and opens from its title", + await page.locator(".callout-content").isVisible(), + true, + ); + check( + "checkboxes cannot be changed", + await page.locator("input[type=checkbox]").isDisabled(), + true, + ); + check("opening spent one view", await views(base, link), 1); + await page.reload(); + await page.locator(".markdown-rendered").waitFor(); + check( + "a reload shows the note again", + await page.locator(".note-title").textContent(), + "Trip notes", + ); + check("at no cost", await views(base, link), 1); + await context.close(); +} + +async function outlineAndFolds(page: Page): Promise { + const food = page.getByText("Pastel de nata."); + check( + "the outline lists the headings beside the note", + [ + await page.locator(".outline-item").allTextContents(), + await page.locator(".outline").isVisible(), + ], + [["Lisbon", "Porto", "Food"], true], + ); + await page.locator(".heading-fold").nth(1).click(); + check( + "a heading folds what is under it", + [await page.locator("pre").isVisible(), await food.isVisible()], + [false, false], + ); + await page.locator(".outline-item", { hasText: "Food" }).click(); + check("the outline opens a folded heading", await food.isVisible(), true); + await page.locator("h3", { hasText: "Food" }).click(); + check("a click on a heading folds it", await food.isVisible(), false); + await page.locator("h3", { hasText: "Food" }).click(); + await page.locator(".copy-code-button").click(); + check( + "a code block copies its code", + await page.evaluate(() => navigator.clipboard.readText()), + "const a = 1;", + ); + await page.locator(".copy-markdown").click(); + // Windows hands the clipboard back with CRLF. + const markdown = ( + await page.evaluate(() => navigator.clipboard.readText()) + ).replaceAll("\r\n", "\n"); + check( + "the note copies as Markdown", + [ + markdown.startsWith("# Trip notes\n\n## Lisbon"), + markdown.includes("> [!tip]- Pack light"), + markdown.includes("```ts\nconst a = 1;\n```"), + markdown.includes("Copy"), + ], + [true, true, true, false], + ); + check( + "and the tab has an icon of its own", + await page.locator("link[rel=icon]").getAttribute("type"), + "image/svg+xml", + ); + await anchors(page); + const viewport = page.viewportSize(); + await page.setViewportSize({ width: 390, height: 800 }); + const panel = page.locator(".sidebar-panel"); + // The panel slides, so its visibility settles after a transition. + const settles = (state: "visible" | "hidden") => + panel.waitFor({ state, timeout: 2000 }).then( + () => true, + () => false, + ); + const hidden = await settles("hidden"); + await page.locator(".sidebar-toggle").click(); + check( + "on a phone the outline waits behind a menu button", + [hidden, await settles("visible")], + [true, true], + ); + await page.locator(".outline-item", { hasText: "Lisbon" }).click(); + check("and closes after a jump", await settles("hidden"), true); + if (viewport) await page.setViewportSize(viewport); +} + +async function anchors(page: Page): Promise { + const key = new URL(page.url()).hash; + const porto = page.locator("h2", { hasText: "Porto" }); + await porto.hover(); + await porto.locator(".heading-anchor").click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + check( + "a heading's anchor copies a link to it and keeps the key in the address", + [copied === page.url(), new URL(copied).hash], + [true, `${key}/porto`], + ); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.reload(); + await porto.waitFor(); + await page.waitForFunction(() => window.scrollY > 0, null, { timeout: 2000 }); + check( + "opening that link lands on the heading", + await porto.evaluate((h) => Math.round(h.getBoundingClientRect().top)), + 24, + ); + await page.locator(".wide-toggle").click(); + await page.reload(); + await porto.waitFor(); + check( + "wide text is remembered", + await page.locator(".reader.is-wide").count(), + 1, + ); + await page.locator(".wide-toggle").click(); +} + +async function oneView(base: string, browser: Browser): Promise { + const link = await publish(base, NOTE, { maxViews: 1 }); + const first = await visit(browser, link.url); + check( + "the one view shows the note", + await first.page.locator(".note-title").textContent(), + "Trip notes", + ); + check( + "and says it was the last", + (await first.page.locator(".link-views").textContent())?.includes( + "last view", + ), + true, + ); + const second = await visit(browser, link.url, ".message"); + check( + "the next visitor finds it gone", + await second.page.locator("h1").textContent(), + "This link is no longer available", + ); + await first.context.close(); + await second.context.close(); +} + +async function protectedNote(base: string, browser: Browser): Promise { + const link = await publish(base, NOTE, { passphrase: "correct horse" }); + const { page, context } = await visit(browser, link.url, ".passphrase"); + check( + "a protected note asks first", + await page.locator("h1").textContent(), + "This note is protected", + ); + check("and has spent no view", await views(base, link), 0); + await submit(page, "wrong horse"); + await page.locator(".problem:not(:empty)").waitFor(); + check( + "a wrong passphrase is refused", + await page.locator(".problem").textContent(), + "Wrong passphrase.", + ); + check("without a view", await views(base, link), 0); + await page.locator(".remember input").check(); + await submit(page, "correct horse"); + await page.locator(".markdown-rendered").waitFor(); + check( + "the right one opens it", + await page.locator(".note-title").textContent(), + "Trip notes", + ); + check("for one view", await views(base, link), 1); + await page.reload(); + await page.locator(".markdown-rendered").waitFor(); + check( + "a reload remembers the passphrase and costs nothing", + await views(base, link), + 1, + ); + const tab = await context.newPage(); + await tab.goto(link.url); + await tab.locator(".markdown-rendered").waitFor(); + check( + "another tab of this browser opens it without asking, for a view", + await views(base, link), + 2, + ); + await context.close(); + + const other = await visit(browser, link.url, ".passphrase"); + check( + "remembering is off until ticked", + await other.page.locator(".remember input").isChecked(), + false, + ); + await submit(other.page, "correct horse"); + await other.page.locator(".markdown-rendered").waitFor(); + await other.page.reload(); + await other.page.locator(".passphrase").waitFor(); + check( + "another browser asks, and asks again as it was not told to remember", + await other.page.locator("h1").textContent(), + "This note is protected", + ); + await other.context.close(); +} + +async function hostile(base: string, browser: Browser): Promise { + const link = await publish(base, HOSTILE); + const { page, context } = await visit(browser, link.url); + check( + "a hostile note still shows its text", + await page.locator(".markdown-rendered").textContent(), + "safe textclick", + ); + check( + "and runs nothing", + await page.evaluate(() => (window as { __pwned?: number }).__pwned), + undefined, + ); + check( + "its javascript link goes nowhere", + await page.locator("a").getAttribute("href"), + null, + ); + await context.close(); +} + +async function brokenLinks(base: string, browser: Browser): Promise { + const link = await publish(base, NOTE); + const bare = await visit(browser, link.url.split("#")[0] ?? "", ".message"); + check( + "a link without its key says so", + await bare.page.locator("h1").textContent(), + "This link is incomplete", + ); + const cut = await visit(browser, link.url.slice(0, -4), ".message"); + check( + "a key cut short is incomplete too", + await cut.page.locator("h1").textContent(), + "This link is incomplete", + ); + const other = `${link.url.split("#")[0]}#${toBase64Url(newLinkKey())}`; + const wrong = await visit(browser, other, ".message"); + check( + "a whole but wrong key is reported as damaged", + await wrong.page.locator("h1").textContent(), + "This note could not be opened", + ); + await bare.context.close(); + await cut.context.close(); + await wrong.context.close(); +} + +async function visit( + browser: Browser, + url: string, + ready = ".markdown-rendered", +): Promise<{ page: Page; context: BrowserContext }> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(url); + await page.locator(ready).waitFor(); + return { page, context }; +} + +async function submit(page: Page, passphrase: string): Promise { + await page.locator("#passphrase").fill(passphrase); + await page.locator("button[type=submit]").click(); +} + +async function views(base: string, link: Published): Promise { + const status = await admin(base, `/link/${link.id}/status`); + return status.ok ? ((await status.json()) as { views: number }).views : -1; +} diff --git a/tests/e2e/links.ts b/tests/e2e/links.ts new file mode 100644 index 00000000..d41dfa88 --- /dev/null +++ b/tests/e2e/links.ts @@ -0,0 +1,271 @@ +/** + * Share links under a real workerd: the Link Durable Object's storage (chunks, erase, expiry, a restart), + * the routes in front of it, and the sealed payload from the protocol package end to end. + */ + +import { + deriveLinkKeys, + type LinkPayload, + newLinkId, + newLinkKey, + newLinkSalt, + openLinkPayload, + sealLinkPayload, + toBase64Url, +} from "@mdsync/protocol"; + +import { check, runScenario, sleep } from "./harness"; +import { admin, open, put, SECRET } from "./link-client"; +import { viewerScenario } from "./links-viewer"; +import { type Relay, startRelay } from "./relay"; + +const PORT = 8799; +const VIEWS_LEFT = "X-Mdsync-Views-Left"; + +const payload: LinkPayload = { + title: "Trip notes", + html: "

Lisbon in May

", + createdAt: Date.now(), +}; + +await runScenario("links e2e", async () => { + let relay: Relay | null = await startRelay(PORT, SECRET); + try { + await scenario(relay.url); + await viewerScenario(relay.url); + const survivor = await seal(newLinkId(), newLinkKey()); + await put(relay.url, survivor.id, survivor.sealed, "?maxViews=3", { + "X-Mdsync-Gate": survivor.gate, + }); + await open(relay.url, survivor.id, survivor.gate); + relay.stop(); + relay = await startRelay(PORT, SECRET); + const again = await open(relay.url, survivor.id, survivor.gate); + check( + "a restart keeps the blob and the counter", + [again.status, again.headers.get(VIEWS_LEFT)], + [200, "1"], + ); + } finally { + relay?.stop(); + } +}); + +async function scenario(base: string): Promise { + const id = newLinkId(); + const key = newLinkKey(); + const { sealed, content, gate } = await seal(id, key); + + check( + "a stored link reports its size", + await (await put(base, id, sealed, "", { "X-Mdsync-Gate": gate })).json(), + { + stored: true, + size: sealed.length, + expires: null, + }, + ); + check( + "the owner route refuses without the secret", + (await fetch(`${base}/link/${id}/status`)).status, + 401, + ); + const opened = await open(base, id, gate); + const blob = new Uint8Array(await opened.arrayBuffer()); + check( + "the sealed bytes come back whole and open", + await openLinkPayload(id, blob, content), + payload, + ); + + await oneView(base); + await passphrase(base); + await update(base); + await expiry(base); + await bigNote(base); +} + +/** Spent links erase their storage; the same id must still take a new link afterwards. */ +async function oneView(base: string): Promise { + const id = newLinkId(); + const { sealed, gate } = await seal(id, newLinkKey()); + const headers = { "X-Mdsync-Gate": gate }; + await put(base, id, sealed, "?maxViews=1", headers); + const results = await Promise.all( + Array.from({ length: 8 }, async () => (await open(base, id, gate)).status), + ); + check( + "one of eight simultaneous opens gets the only view", + results.filter((s) => s === 200).length, + 1, + ); + check("the spent link is gone", (await open(base, id, gate)).status, 404); + check( + "and so is its status", + (await admin(base, `/link/${id}/status`)).status, + 404, + ); + + check( + "an update cannot bring a spent link back", + (await put(base, id, sealed, "?update=1&maxViews=2", headers)).status, + 404, + ); + await put(base, id, sealed, "?maxViews=2", headers); + check( + "a new link takes the id of a spent one", + (await open(base, id, gate)).headers.get(VIEWS_LEFT), + "1", + ); + check( + "a creation never replaces a link that stands", + (await put(base, id, sealed, "", headers)).status, + 409, + ); + + await admin(base, `/link/${id}`, { method: "DELETE" }); + check( + "a revoked link is gone at once", + (await open(base, id, gate)).status, + 404, + ); + await put(base, id, sealed, "", headers); + check("and takes a new one again", (await open(base, id, gate)).status, 200); +} + +async function passphrase(base: string): Promise { + const id = newLinkId(); + const key = newLinkKey(); + const salt = newLinkSalt(); + const right = await deriveLinkKeys(key, { + passphrase: "correct horse", + salt, + }); + const wrong = await deriveLinkKeys(key, { passphrase: "wrong horse", salt }); + const sealed = await sealLinkPayload(id, payload, right.content); + await put(base, id, sealed, "?maxViews=1", { + "X-Mdsync-Gate": right.gate ?? "", + "X-Mdsync-Salt": toBase64Url(salt), + }); + const meta = (await (await fetch(`${base}/link/${id}/meta`)).json()) as { + protected: boolean; + salt: string; + }; + check( + "the viewer is told to ask for a passphrase", + [meta.protected, meta.salt], + [true, toBase64Url(salt)], + ); + check( + "a wrong passphrase is refused", + (await open(base, id, wrong.gate ?? "")).status, + 401, + ); + check("a missing gate is refused", (await open(base, id)).status, 401); + const opened = await open(base, id, right.gate ?? ""); + check( + "the right one opens, the refusals having cost no view", + opened.status, + 200, + ); + const blob = new Uint8Array(await opened.arrayBuffer()); + check( + "and decrypts with the same key and passphrase", + await openLinkPayload(id, blob, right.content), + payload, + ); + + const cooled = newLinkId(); + await put(base, cooled, sealed, "", { + "X-Mdsync-Gate": right.gate ?? "", + "X-Mdsync-Salt": toBase64Url(salt), + }); + for (let i = 0; i < 5; i++) await open(base, cooled, wrong.gate ?? ""); + const locked = await open(base, cooled, right.gate ?? ""); + check( + "five wrong guesses start a cooldown", + [locked.status, locked.headers.get("Retry-After")], + [429, "60"], + ); +} + +async function update(base: string): Promise { + const id = newLinkId(); + const key = newLinkKey(); + const { content, gate } = await deriveLinkKeys(key); + const first = await sealLinkPayload(id, payload, content); + await put(base, id, first, "?maxViews=3", { "X-Mdsync-Gate": gate }); + await open(base, id, gate); + const edited = { ...payload, html: "

Edited

" }; + await put( + base, + id, + await sealLinkPayload(id, edited, content), + "?update=1&maxViews=1&ttl=60", + { "X-Mdsync-Gate": gate }, + ); + const opened = await open(base, id, gate); + check( + "an update keeps the views already spent", + opened.headers.get(VIEWS_LEFT), + "1", + ); + const blob = new Uint8Array(await opened.arrayBuffer()); + check( + "and serves the new text", + (await openLinkPayload(id, blob, content)).html, + edited.html, + ); +} + +async function expiry(base: string): Promise { + const id = newLinkId(); + const { sealed, gate } = await seal(id, newLinkKey()); + await put(base, id, sealed, "?ttl=2", { "X-Mdsync-Gate": gate }); + check( + "a link opens before it expires", + (await open(base, id, gate)).status, + 200, + ); + await sleep(3_500); + check("and is gone after", (await open(base, id, gate)).status, 404); +} + +/** Past the Durable Object's 2 MB value limit, so the blob must be stored in chunks. */ +async function bigNote(base: string): Promise { + const id = newLinkId(); + const { content, gate } = await deriveLinkKeys(newLinkKey()); + const noise = crypto.getRandomValues(new Uint8Array(65_536)); + const html = Array.from({ length: 90 }, () => toBase64Url(noise)).join(""); + const sealed = await sealLinkPayload(id, { ...payload, html }, content); + check( + "the note is several chunks long", + sealed.length > 3 * 1024 * 1024, + true, + ); + await put(base, id, sealed, "", { "X-Mdsync-Gate": gate }); + const blob = new Uint8Array(await (await open(base, id, gate)).arrayBuffer()); + check( + "a multi-chunk note comes back whole", + (await openLinkPayload(id, blob, content)).html === html, + true, + ); + const tooBig = await put( + base, + newLinkId(), + new Uint8Array(6 * 1024 * 1024 + 1), + "", + { "X-Mdsync-Gate": gate }, + ); + check("a note past the cap is refused", tooBig.status, 413); +} + +async function seal(id: string, key: Uint8Array) { + const { content, gate } = await deriveLinkKeys(key); + return { + id, + sealed: await sealLinkPayload(id, payload, content), + content, + gate, + }; +} diff --git a/tests/e2e/relay.ts b/tests/e2e/relay.ts index 301b09a2..c3072cf2 100644 --- a/tests/e2e/relay.ts +++ b/tests/e2e/relay.ts @@ -1,6 +1,6 @@ /** The relay worker under `wrangler dev`, as a scenario resource. */ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { rmSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -33,6 +33,13 @@ export async function startRelay( // Storage left by an older build may hold tables of another shape. if (!wiped) { rmSync(join(RELAY_DIR, STATE_DIR), { recursive: true, force: true }); + // wrangler refuses to start when the assets directory the worker names is missing. + const built = spawnSync("pnpm --filter mdsync-viewer run build", { + cwd: RELAY_DIR, + shell: true, + stdio: "ignore", + }); + if (built.status !== 0) throw new Error("the link viewer did not build"); wiped = true; } const flags = Object.entries({ ...vars, RELAY_SECRET: secret }) diff --git a/tests/e2e/settings.ts b/tests/e2e/settings.ts new file mode 100644 index 00000000..8b051498 --- /dev/null +++ b/tests/e2e/settings.ts @@ -0,0 +1,268 @@ +import { chromium, type Page } from "playwright-core"; + +import { fill, loaded, press } from "./device"; +import { check, poll, runScenario } from "./harness"; +import { launchObsidian, type Obsidian } from "./obsidian"; + +// biome-ignore lint/suspicious/noExplicitAny: Obsidian's renderer app is untyped here. +declare const app: any; +declare const activeDocument: Document; + +const PORT = 9235; +const LOCAL_RULES = "Attachments/\n*.tmp"; +const SHARED_RULES = "Archive/\n"; + +await runScenario("settings UX e2e", async () => { + const device = await launchObsidian({ + port: PORT, + settings: { realtimeSync: false, cachePassphrase: false }, + files: { "syncignore.md": SHARED_RULES }, + }); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`); + try { + await loaded(device); + await device.evaluate( + () => + new Promise((resolve) => app.workspace.onLayoutReady(resolve)), + ); + await device.evaluate(() => { + app.setting.open(); + }); + const page = await poll("settings navigation ready", async () => { + for (const candidate of browser + .contexts() + .flatMap((context) => context.pages())) { + if ( + (await candidate + .locator(".vertical-tab-nav-item-title", { hasText: /^MDSync$/ }) + .count()) > 0 + ) + return candidate; + } + return undefined; + }); + await page.setViewportSize({ width: 1100, height: 760 }); + await page + .locator(".vertical-tab-nav-item-title", { hasText: /^MDSync$/ }) + .click(); + await checkPassphraseScroll(device, page); + await checkSyncSettings(device, page); + await checkExclusions(device, page); + await checkPhoneLayout(device, page); + await page.getByRole("tab", { name: "Sync", exact: true }).click(); + await page + .getByRole("button", { name: "Open syncignore.md", exact: true }) + .click(); + await device.waitFor( + "shared rules opened outside settings", + () => activeDocument.querySelector(".mdsync-settings") === null, + Boolean, + ); + check( + "opening shared rules reveals the note outside settings", + await device.evaluate(() => app.workspace.getActiveFile()?.path), + "syncignore.md", + ); + } finally { + await browser.close(); + await device.stop(); + } +}); + +async function checkPassphraseScroll( + device: Obsidian, + page: Page, +): Promise { + const row = settingRow(page, "Passphrase on this device"); + await row.scrollIntoViewIfNeeded(); + const before = await scrollTop(device); + check("the passphrase action starts below the top", before > 0, true); + await row.getByRole("button", { name: "Enter…", exact: true }).click(); + await fill(device, "Passphrase", "settings-test-passphrase"); + await press(device, "Continue"); + await row.getByText(/Loaded for this session/).waitFor(); + check( + "entering a passphrase preserves scroll", + await scrollTop(device), + before, + ); + + await row.getByRole("button", { name: "Forget", exact: true }).click(); + await row.getByText(/Not loaded for this session/).waitFor(); + check( + "forgetting a passphrase preserves scroll", + await scrollTop(device), + before, + ); + await row.getByRole("button", { name: "Enter…", exact: true }).click(); + await press(device, "Cancel"); + check( + "cancelling a passphrase preserves scroll", + await scrollTop(device), + before, + ); + + await settingRow(page, "Change vault passphrase").getByRole("button").click(); + check( + "unconfigured rotation does not ask for a new password", + await device.evaluate(() => + [...activeDocument.querySelectorAll(".modal-title")].some( + (el) => el.textContent === "Change passphrase", + ), + ), + false, + ); +} + +async function checkSyncSettings(device: Obsidian, page: Page): Promise { + await page.getByRole("tab", { name: "Sync", exact: true }).click(); + check("switching sections starts at the top", await scrollTop(device), 0); + const toggle = settingRow(page, "Push shared folders right away").locator( + ".checkbox-container", + ); + await toggle.evaluate((el) => el.scrollIntoView({ block: "center" })); + const before = await scrollTop(device); + check("the shared-folder toggle starts below the top", before > 0, true); + await toggle.click(); + await settingRow(page, "Push only added, moved and deleted files").waitFor({ + state: "detached", + }); + check( + "a conditional toggle preserves scroll", + await scrollTop(device), + before, + ); + await toggle.click(); + await settingRow(page, "Push only added, moved and deleted files").waitFor(); + check( + "revealing a sub-setting preserves scroll", + await scrollTop(device), + before, + ); +} + +async function checkExclusions(device: Obsidian, page: Page): Promise { + const textarea = page.locator(".mdsync-ignore-patterns textarea"); + await textarea.scrollIntoViewIfNeeded(); + check( + "local rules are explicitly labelled", + await textarea.getAttribute("aria-labelledby"), + "mdsync-local-ignore-label", + ); + check( + "local rules do not display the shared note", + await textarea.inputValue(), + "", + ); + check( + "the shared-note button is outside the textarea row", + await page.locator(".mdsync-ignore-patterns button").count(), + 0, + ); + await textarea.fill(LOCAL_RULES); + await page.getByRole("tab", { name: "Interface", exact: true }).click(); + await poll( + "local rules saved after leaving the tab", + async () => + (await device.evaluate( + async () => + ( + await app.plugins.plugins.mdsync.loadData() + ).ignorePatterns, + )) === LOCAL_RULES || undefined, + ); + check( + "editing local rules leaves the shared note unchanged", + await device.evaluate(() => + app.vault.read(app.vault.getFileByPath("syncignore.md")), + ), + SHARED_RULES, + ); + + await page.getByRole("tab", { name: "Sync", exact: true }).click(); + check( + "local rules survive a rerender", + await textarea.inputValue(), + LOCAL_RULES, + ); + await textarea.scrollIntoViewIfNeeded(); + await shot(page, "settings-exclusions-desktop"); +} + +async function checkPhoneLayout(device: Obsidian, page: Page): Promise { + await device.evaluate(() => { + activeDocument.body.classList.remove("is-desktop"); + activeDocument.body.classList.add("is-mobile", "is-phone"); + }); + for (const width of [320, 390, 600]) { + await page.setViewportSize({ width, height: 844 }); + const textarea = page.locator(".mdsync-ignore-patterns textarea"); + await textarea.scrollIntoViewIfNeeded(); + const layout = await device.evaluate(() => { + const root = + activeDocument.querySelector(".mdsync-settings"); + const row = root?.querySelector(".mdsync-ignore-patterns"); + const text = row?.querySelector("textarea"); + const label = row?.querySelector(".setting-item-info"); + const controls = row?.querySelector(".setting-item-control"); + if (!root || !text || !label || !controls) + throw new Error("Missing exclusion controls"); + const input = text.getBoundingClientRect(); + const control = controls.getBoundingClientRect(); + return { + fullWidth: Math.abs(input.width - control.width) < 2, + belowLabel: input.top >= label.getBoundingClientRect().bottom, + usableWidth: input.width >= root.clientWidth - 64, + noOverflow: root.scrollWidth <= root.clientWidth, + largeButtons: [...root.querySelectorAll("button")].every( + (button) => button.getBoundingClientRect().height >= 44, + ), + }; + }); + check(`phone layout at ${width}px`, layout, { + fullWidth: true, + belowLabel: true, + usableWidth: true, + noOverflow: true, + largeButtons: true, + }); + await shot(page, `settings-exclusions-${width}px`); + } + await page.getByRole("tab", { name: "Connection", exact: true }).click(); + await page.setViewportSize({ width: 320, height: 844 }); + check( + "connection controls fit a narrow phone", + await device.evaluate(() => { + const root = + activeDocument.querySelector(".mdsync-settings"); + if (!root) throw new Error("No settings container"); + return root.scrollWidth <= root.clientWidth; + }), + true, + ); + await checkPassphraseScroll(device, page); +} + +async function shot(page: Page, name: string): Promise { + if (!process.env.E2E_SHOTS) return; + await page.locator(".mdsync-settings").screenshot({ + path: `artifacts/e2e-shots/${name}.png`, + style: ".notice-container { display: none; }", + }); +} + +function settingRow(page: Page, name: string) { + return page.locator(".mdsync-settings .setting-item").filter({ + has: page.locator(".setting-item-name", { + hasText: new RegExp(`^${name}$`), + }), + }); +} + +function scrollTop(device: Obsidian): Promise { + return device.evaluate(() => { + const root = activeDocument.querySelector(".mdsync-settings"); + if (!root) throw new Error("No settings container"); + return root.scrollTop; + }); +} diff --git a/versions.json b/versions.json index 85a82e71..9602ead7 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "1.13.0" + "1.0.0": "1.13.0", + "1.1.0": "1.13.0" }