From c39c0523de8767935d2ef95a6ac8d67c9cab4466 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Thu, 8 Oct 2026 21:35:40 +0300 Subject: [PATCH 1/4] feat: share links and settings fixes, 1.1.0 --- .claude/architecture.md | 3 +- .claude/commands-and-settings.md | 1 + .claude/security.md | 1 + .claude/share-links.md | 235 +++++++++ .claude/testing.md | 3 + .github/workflows/deploy-relay.yml | 3 + .gitignore | 8 +- CHANGELOG.md | 11 + README.md | 23 +- biome.json | 3 +- manifest.json | 2 +- package.json | 5 +- packages/plugin/package.json | 3 +- packages/plugin/src/commands/index.ts | 51 ++ packages/plugin/src/links/describe.ts | 109 +++++ packages/plugin/src/links/errors.ts | 10 + packages/plugin/src/links/images.ts | 80 +++ packages/plugin/src/links/index.ts | 30 ++ packages/plugin/src/links/math.ts | 108 +++++ packages/plugin/src/links/note-links.ts | 32 ++ packages/plugin/src/links/note-text.ts | 12 + packages/plugin/src/links/publish.ts | 190 ++++++++ packages/plugin/src/links/record.ts | 87 ++++ packages/plugin/src/links/sanitize.ts | 119 +++++ packages/plugin/src/links/shared-links.ts | 89 ++++ packages/plugin/src/links/snapshot.ts | 102 ++++ packages/plugin/src/main.ts | 7 + packages/plugin/src/plugin/events.ts | 2 + packages/plugin/src/plugin/host.ts | 2 + packages/plugin/src/plugin/links.ts | 16 + packages/plugin/src/plugin/ui.ts | 2 + packages/plugin/src/settings/model.ts | 16 + .../src/settings/sections/exclusions.ts | 107 ++++ .../plugin/src/settings/sections/index.ts | 2 + .../plugin/src/settings/sections/links.ts | 20 + .../plugin/src/settings/sections/security.ts | 54 ++- .../plugin/src/settings/sections/shares.ts | 19 +- packages/plugin/src/settings/tab.ts | 98 +--- .../src/settings/transfer-controller.ts | 6 +- .../src/storage/adapters/link-broker.ts | 101 ++++ .../src/storage/adapters/share-broker.ts | 14 +- packages/plugin/src/storage/adapters/util.ts | 11 + packages/plugin/src/storage/index.ts | 1 + packages/plugin/src/styles/explorer.css | 25 +- packages/plugin/src/styles/index.css | 1 + packages/plugin/src/styles/links.css | 48 ++ .../src/styles/settings/tabs-and-logs.css | 38 ++ packages/plugin/src/ui/common/copyable.ts | 27 ++ packages/plugin/src/ui/common/index.ts | 1 + .../src/ui/explorer/file-explorer-badges.ts | 36 +- .../ui/explorer/file-explorer-decorations.ts | 4 + .../ui/explorer/file-explorer-indicators.ts | 8 + .../src/ui/explorer/file-explorer-marks.ts | 23 + packages/plugin/src/ui/index.ts | 8 + packages/plugin/src/ui/links/index.ts | 8 + packages/plugin/src/ui/links/link-actions.ts | 29 ++ packages/plugin/src/ui/links/link-menu.ts | 64 +++ .../plugin/src/ui/links/manage-links-modal.ts | 190 ++++++++ .../plugin/src/ui/links/note-link-action.ts | 64 +++ packages/plugin/src/ui/links/preview-frame.ts | 22 + .../plugin/src/ui/links/share-link-modal.ts | 295 +++++++++++ .../plugin/src/ui/shares/invite-action.ts | 3 +- .../plugin/src/ui/shares/invite-section.ts | 24 +- packages/plugin/src/ui/shares/share-action.ts | 14 +- packages/plugin/src/ui/shares/share-modal.ts | 3 +- .../plugin/tests/helpers/explorer-host.ts | 28 +- packages/plugin/tests/links/describe.test.ts | 212 ++++++++ packages/plugin/tests/links/errors.test.ts | 32 ++ .../tests/links/fixtures/rendered-note.html | 67 +++ packages/plugin/tests/links/images.test.ts | 70 +++ packages/plugin/tests/links/math.test.ts | 91 ++++ packages/plugin/tests/links/publish.test.ts | 449 +++++++++++++++++ packages/plugin/tests/links/record.test.ts | 120 +++++ packages/plugin/tests/links/sanitize.test.ts | 256 ++++++++++ .../plugin/tests/links/shared-links.test.ts | 129 +++++ .../tests/settings/security-section.test.ts | 175 +++++++ .../settings/transfer-controller.test.ts | 45 ++ .../plugin/tests/storage/link-broker.test.ts | 240 +++++++++ packages/plugin/tests/stubs/obsidian.ts | 4 + packages/plugin/tests/sync/keyfile.test.ts | 2 +- .../ui/file-explorer-decorations.test.ts | 149 +++++- .../tests/ui/file-explorer-indicators.test.ts | 105 +++- .../tests/ui/file-explorer-marks.test.ts | 35 +- packages/plugin/tests/ui/link-actions.test.ts | 173 +++++++ packages/plugin/tests/ui/link-menu.test.ts | 29 ++ .../tests/ui/manage-links-modal.test.ts | 196 ++++++++ .../plugin/tests/ui/note-link-action.test.ts | 148 ++++++ .../plugin/tests/ui/preview-frame.test.ts | 18 + .../plugin/tests/ui/row-decorator.test.ts | 22 +- packages/protocol/src/bytes.ts | 22 + packages/protocol/src/index.ts | 3 +- packages/protocol/src/link.ts | 304 ++++++++++++ packages/protocol/tests/link.test.ts | 247 ++++++++++ packages/relay/package.json | 4 +- packages/relay/src/index.ts | 16 +- packages/relay/src/link/client.ts | 24 + packages/relay/src/link/durable-object.ts | 58 +++ packages/relay/src/link/routes.ts | 218 +++++++++ packages/relay/src/link/shell.ts | 64 +++ packages/relay/src/link/store.ts | 270 +++++++++++ packages/relay/src/link/stub.ts | 10 + packages/relay/tests/link/client.test.ts | 39 ++ packages/relay/tests/link/routes.test.ts | 404 ++++++++++++++++ packages/relay/tests/link/shell.test.ts | 82 ++++ packages/relay/tests/link/store.test.ts | 319 ++++++++++++ packages/relay/wrangler.toml | 15 +- packages/viewer/build.mjs | 23 + packages/viewer/package.json | 23 + packages/viewer/src/anchors.ts | 99 ++++ packages/viewer/src/api.ts | 61 +++ packages/viewer/src/cache.ts | 60 +++ packages/viewer/src/clipboard.ts | 9 + packages/viewer/src/code.ts | 23 + packages/viewer/src/expiry.ts | 28 ++ packages/viewer/src/index.html | 16 + packages/viewer/src/keys.ts | 96 ++++ packages/viewer/src/main.ts | 29 ++ packages/viewer/src/note.css | 456 ++++++++++++++++++ packages/viewer/src/outline.ts | 62 +++ packages/viewer/src/sanitize.ts | 46 ++ packages/viewer/src/sections.ts | 79 +++ packages/viewer/src/session.ts | 185 +++++++ packages/viewer/src/sidebar.css | 173 +++++++ packages/viewer/src/sidebar.ts | 132 +++++ packages/viewer/src/view.ts | 145 ++++++ packages/viewer/src/viewer.css | 225 +++++++++ packages/viewer/tests/anchors.test.ts | 177 +++++++ packages/viewer/tests/api.test.ts | 112 +++++ packages/viewer/tests/cache.test.ts | 38 ++ packages/viewer/tests/code.test.ts | 63 +++ packages/viewer/tests/expiry.test.ts | 43 ++ packages/viewer/tests/keys.test.ts | 71 +++ packages/viewer/tests/outline.test.ts | 115 +++++ packages/viewer/tests/sanitize.test.ts | 92 ++++ packages/viewer/tests/sections.test.ts | 173 +++++++ packages/viewer/tests/session.test.ts | 417 ++++++++++++++++ packages/viewer/tests/sidebar.test.ts | 139 ++++++ packages/viewer/tests/view.test.ts | 209 ++++++++ packages/viewer/tsconfig.json | 13 + packages/viewer/vitest.config.ts | 8 + pnpm-lock.yaml | 426 +++++++++++++++- tests/e2e/device.ts | 4 +- tests/e2e/link-client.ts | 78 +++ tests/e2e/links-obsidian.ts | 447 +++++++++++++++++ tests/e2e/links-viewer.ts | 375 ++++++++++++++ tests/e2e/links.ts | 251 ++++++++++ tests/e2e/relay.ts | 9 +- tests/e2e/settings.ts | 268 ++++++++++ versions.json | 3 +- 149 files changed, 12906 insertions(+), 222 deletions(-) create mode 100644 .claude/share-links.md create mode 100644 packages/plugin/src/links/describe.ts create mode 100644 packages/plugin/src/links/errors.ts create mode 100644 packages/plugin/src/links/images.ts create mode 100644 packages/plugin/src/links/index.ts create mode 100644 packages/plugin/src/links/math.ts create mode 100644 packages/plugin/src/links/note-links.ts create mode 100644 packages/plugin/src/links/note-text.ts create mode 100644 packages/plugin/src/links/publish.ts create mode 100644 packages/plugin/src/links/record.ts create mode 100644 packages/plugin/src/links/sanitize.ts create mode 100644 packages/plugin/src/links/shared-links.ts create mode 100644 packages/plugin/src/links/snapshot.ts create mode 100644 packages/plugin/src/plugin/links.ts create mode 100644 packages/plugin/src/settings/sections/exclusions.ts create mode 100644 packages/plugin/src/settings/sections/links.ts create mode 100644 packages/plugin/src/storage/adapters/link-broker.ts create mode 100644 packages/plugin/src/styles/links.css create mode 100644 packages/plugin/src/ui/common/copyable.ts create mode 100644 packages/plugin/src/ui/links/index.ts create mode 100644 packages/plugin/src/ui/links/link-actions.ts create mode 100644 packages/plugin/src/ui/links/link-menu.ts create mode 100644 packages/plugin/src/ui/links/manage-links-modal.ts create mode 100644 packages/plugin/src/ui/links/note-link-action.ts create mode 100644 packages/plugin/src/ui/links/preview-frame.ts create mode 100644 packages/plugin/src/ui/links/share-link-modal.ts create mode 100644 packages/plugin/tests/links/describe.test.ts create mode 100644 packages/plugin/tests/links/errors.test.ts create mode 100644 packages/plugin/tests/links/fixtures/rendered-note.html create mode 100644 packages/plugin/tests/links/images.test.ts create mode 100644 packages/plugin/tests/links/math.test.ts create mode 100644 packages/plugin/tests/links/publish.test.ts create mode 100644 packages/plugin/tests/links/record.test.ts create mode 100644 packages/plugin/tests/links/sanitize.test.ts create mode 100644 packages/plugin/tests/links/shared-links.test.ts create mode 100644 packages/plugin/tests/settings/security-section.test.ts create mode 100644 packages/plugin/tests/storage/link-broker.test.ts create mode 100644 packages/plugin/tests/ui/link-actions.test.ts create mode 100644 packages/plugin/tests/ui/link-menu.test.ts create mode 100644 packages/plugin/tests/ui/manage-links-modal.test.ts create mode 100644 packages/plugin/tests/ui/note-link-action.test.ts create mode 100644 packages/plugin/tests/ui/preview-frame.test.ts create mode 100644 packages/protocol/src/link.ts create mode 100644 packages/protocol/tests/link.test.ts create mode 100644 packages/relay/src/link/client.ts create mode 100644 packages/relay/src/link/durable-object.ts create mode 100644 packages/relay/src/link/routes.ts create mode 100644 packages/relay/src/link/shell.ts create mode 100644 packages/relay/src/link/store.ts create mode 100644 packages/relay/src/link/stub.ts create mode 100644 packages/relay/tests/link/client.test.ts create mode 100644 packages/relay/tests/link/routes.test.ts create mode 100644 packages/relay/tests/link/shell.test.ts create mode 100644 packages/relay/tests/link/store.test.ts create mode 100644 packages/viewer/build.mjs create mode 100644 packages/viewer/package.json create mode 100644 packages/viewer/src/anchors.ts create mode 100644 packages/viewer/src/api.ts create mode 100644 packages/viewer/src/cache.ts create mode 100644 packages/viewer/src/clipboard.ts create mode 100644 packages/viewer/src/code.ts create mode 100644 packages/viewer/src/expiry.ts create mode 100644 packages/viewer/src/index.html create mode 100644 packages/viewer/src/keys.ts create mode 100644 packages/viewer/src/main.ts create mode 100644 packages/viewer/src/note.css create mode 100644 packages/viewer/src/outline.ts create mode 100644 packages/viewer/src/sanitize.ts create mode 100644 packages/viewer/src/sections.ts create mode 100644 packages/viewer/src/session.ts create mode 100644 packages/viewer/src/sidebar.css create mode 100644 packages/viewer/src/sidebar.ts create mode 100644 packages/viewer/src/view.ts create mode 100644 packages/viewer/src/viewer.css create mode 100644 packages/viewer/tests/anchors.test.ts create mode 100644 packages/viewer/tests/api.test.ts create mode 100644 packages/viewer/tests/cache.test.ts create mode 100644 packages/viewer/tests/code.test.ts create mode 100644 packages/viewer/tests/expiry.test.ts create mode 100644 packages/viewer/tests/keys.test.ts create mode 100644 packages/viewer/tests/outline.test.ts create mode 100644 packages/viewer/tests/sanitize.test.ts create mode 100644 packages/viewer/tests/sections.test.ts create mode 100644 packages/viewer/tests/session.test.ts create mode 100644 packages/viewer/tests/sidebar.test.ts create mode 100644 packages/viewer/tests/view.test.ts create mode 100644 packages/viewer/tsconfig.json create mode 100644 packages/viewer/vitest.config.ts create mode 100644 tests/e2e/link-client.ts create mode 100644 tests/e2e/links-obsidian.ts create mode 100644 tests/e2e/links-viewer.ts create mode 100644 tests/e2e/links.ts create mode 100644 tests/e2e/settings.ts 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..7ba04835 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 passphrase check, 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..55419948 --- /dev/null +++ b/.claude/share-links.md @@ -0,0 +1,235 @@ +# 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 | +| Passphrase = a second factor on top of the fragment key, checked by the relay before it serves | 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: `{v:1, title, html, createdAt}` -> JSON -> deflate -> AES-GCM. +Content key = HKDF(`key` [+ PBKDF2(passphrase, salt) when protected]); AAD +`link:`. With a passphrase, a second HKDF output (other `info`) is the +**gate**; the relay stores only its hash and compares it in constant time. + +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`, `expires` (unix s); headers `X-Mdsync-Gate`, `X-Mdsync-Salt`. Creates (409 if one stands); `?update=1` replaces a standing link and keeps its counter (404 when gone), so 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?}` -> 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, on by default) 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 (a new passphrase on **Update**) is + dropped and the form says so. +- 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`, + **Wide text** kept in `localStorage`) and `outline.ts`, the headings (3 or + more) marking the one being read. +- 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. +- 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, title, + createdAt, publishedAt, expiry, view limit, salt, images) in `settings.links` + (device-local, never in device transfer). 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); 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 kept; a protected link asks for its passphrase, a different one + replaces it) 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 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..15cacfdc 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 and copyable code, 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..f73d13af 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,21 @@ 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, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**. It needs the relay. The reader needs nothing installed: the link opens a page served by your relay. + +- **Expires** after 5 or 15 minutes, an hour, a day, 7 days (default) or 30 days, or never. **Pick a date…** sets an exact date and time, from 1 minute to 365 days ahead. **Views** limits how many times it opens (1, 5, 25, unlimited). Once either runs out the relay erases the note. +- An optional **passphrase** is asked before the note opens. Send it by a different route than the link. The reader can let their browser remember it until the link expires. +- The note is drawn as Obsidian's reading view draws it (callouts, tasks, code, tables, math; a Mermaid diagram your vault has not allowed shows as its source), then encrypted on your device before it leaves. 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 of the note's headings, shows how many views and how long the link has left, and can widen the text. Headings fold with a click, and their `#` copies a link to that place in the note. Code blocks have a copy button. +- Left out: properties, comments, links to other notes (their text stays), embedded notes, and files other than images. Images from your vault are embedded and shrunk to fit; turn that off with **Include images**. The window tells you what was left out before you create the link. +- **Manage share links** (command, or **Settings → MDSync → Sync → Share links**) shows how many views are left, copies the link again, **Update**s it to the note as it is now (same link, views already used stay used) or stops it at once. +- A shared note shows a globe in the file tree and in its header; a dot means it changed since it was shared. Click either to update it, or run **Update share links of this note**. +- Opening the link in the same tab again costs no extra view. + +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 +291,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 +312,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 +332,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 +362,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/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..f750a90d 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 { type LinkRecord, linkError, 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, + updateSharedLink, 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 updateNoteLinks(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", @@ -178,6 +216,19 @@ export function registerCommands( }); } +async function updateNoteLinks( + plugin: PluginHost, + records: LinkRecord[], +): Promise { + for (const record of records) { + try { + await updateSharedLink(plugin, record); + } catch (err) { + notifyError("Could not change the link", linkError(err)); + } + } +} + async function runCompare(plugin: Plugin & PluginHost): Promise { try { await plugin.controller.refresh(); diff --git a/packages/plugin/src/links/describe.ts b/packages/plugin/src/links/describe.ts new file mode 100644 index 00000000..1bfd76d3 --- /dev/null +++ b/packages/plugin/src/links/describe.ts @@ -0,0 +1,109 @@ +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; + +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: "date", label: "Pick a date…", seconds: "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 function resolveExpiry( + key: string, + pickedDate: string, + nowMs: number, +): { expires: number | null } | string { + const choice = EXPIRY_CHOICES.find((choice) => choice.key === key); + if (!choice) return "Choose an expiry."; + if (choice.seconds !== "date") { + return { + expires: + choice.seconds === null + ? null + : Math.floor(nowMs / MS_PER_S) + choice.seconds, + }; + } + const pickedMs = new Date(pickedDate).getTime(); + if (!Number.isFinite(pickedMs)) return "Pick a date and time."; + const aheadMs = pickedMs - nowMs; + if (aheadMs < MINUTE_S * MS_PER_S) { + return "Pick a date at least 1 minute ahead."; + } + if (aheadMs > LINK_MAX_TTL_S * MS_PER_S) { + return `Pick a date no more than ${LINK_MAX_TTL_S / DAY_S} days ahead.`; + } + return { expires: Math.floor(pickedMs / 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..09ec1e56 --- /dev/null +++ b/packages/plugin/src/links/images.ts @@ -0,0 +1,80 @@ +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 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"; + if (flat && bytes.byteLength > KEEP_BYTES) { + const smaller = await reencode(blob); + if (smaller && smaller.size < blob.size) blob = smaller; + } + 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..b2a7f762 --- /dev/null +++ b/packages/plugin/src/links/index.ts @@ -0,0 +1,30 @@ +export { + DEFAULT_EXPIRY, + DEFAULT_VIEWS, + EXPIRY_CHOICES, + expiryLine, + leftOutText, + linkStatusText, + resolveExpiry, + VIEW_CHOICES, +} from "./describe"; +export { linkError } from "./errors"; +export { type NoteLinks, noteLinks, noteLinksText } from "./note-links"; +export { + linkStatusOf, + previewLink, + publishLink, + revokeLinkRecord, + type ShareOptions, + TOO_LARGE, + updateLink, +} from "./publish"; +export { + isExpired, + isLinkRecord, + isStale, + type LinkRecord, + onRelay, +} 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..de153c67 --- /dev/null +++ b/packages/plugin/src/links/math.ts @@ -0,0 +1,108 @@ +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) { + 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..69eedbd2 --- /dev/null +++ b/packages/plugin/src/links/publish.ts @@ -0,0 +1,190 @@ +import { + deriveLinkKeys, + fromBase64Url, + LINK_MAX_SEALED_BYTES, + type LinkPayload, + 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, linkStatus, putLink, revokeLink } from "@/storage"; +import { noteText } from "./note-text"; +import { type LinkRecord, onRelay } from "./record"; +import { type Snapshot, takeSnapshot } from "./snapshot"; + +export interface ShareOptions { + /** Unix seconds; null never expires. */ + expires: number | null; + maxViews: number | null; + passphrase: string | null; + images: boolean; + /** Off publishes the note without its name. */ + title: boolean; +} + +export const TOO_LARGE = "This note is too large to share as a link."; +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 }; +} + +/** With the `snapshot` the owner looked at, that exact copy is 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 shot = snapshot ?? (await previewLink(plugin, file, options.images)); + const record: LinkRecord = { + id, + url: linkUrl(relayBase(admin.relayUrl), id, key), + path: file.path, + title: options.title ? file.basename : "", + createdAt: Date.now(), + publishedAt: shot.takenAt, + expires: options.expires, + maxViews: options.maxViews, + salt: options.passphrase ? toBase64Url(newLinkSalt()) : null, + images: options.images, + }; + await upload({ + admin, + file, + record, + key, + passphrase: options.passphrase, + update: false, + snapshot: shot, + }); + // A rename during the upload moved only the links already stored. + const stored = { ...record, path: file.path }; + await plugin.sharedLinks.add(stored); + return stored; +} + +/** The same link, id and key, now showing the note as it is; views already spent stay spent. */ +export async function updateLink( + plugin: PluginHost, + shown: LinkRecord, + passphrase: string | null, +): 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."); + 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 snapshot = await previewLink(plugin, file, record.images); + await upload({ + admin, + file, + record, + key: location.key, + passphrase, + update: true, + snapshot, + }); + 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 upload(job: { + admin: BrokerAdmin; + file: TFile; + record: LinkRecord; + key: Uint8Array; + passphrase: string | null; + update: boolean; + snapshot: Snapshot; +}): Promise { + const { admin, file, record, key, passphrase, update, snapshot } = job; + const protection = + record.salt && passphrase + ? { passphrase, salt: fromBase64Url(record.salt) } + : undefined; + if (record.salt && !protection) + throw new Error("Enter the link's passphrase."); + const keys = await deriveLinkKeys(key, protection); + const payload: LinkPayload = { + title: record.title && file.basename, + html: snapshot.html, + createdAt: Date.now(), + }; + const sealed = await sealLinkPayload(record.id, payload, keys.content).catch( + (error: unknown) => { + throw error instanceof RangeError ? new Error(TOO_LARGE) : error; + }, + ); + if (sealed.length > LINK_MAX_SEALED_BYTES) throw new Error(TOO_LARGE); + await putLink( + admin, + record.id, + sealed, + { + maxViews: record.maxViews, + expires: record.expires, + protection: + keys.gate && record.salt + ? { gate: keys.gate, salt: record.salt } + : null, + }, + update, + ); +} diff --git a/packages/plugin/src/links/record.ts b/packages/plugin/src/links/record.ts new file mode 100644 index 00000000..523c54e4 --- /dev/null +++ b/packages/plugin/src/links/record.ts @@ -0,0 +1,87 @@ +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; + title: string; + /** Milliseconds. */ + createdAt: number; + /** Milliseconds; the last create or update. A note modified later shows a changed copy. */ + publishedAt: number; + /** Unix seconds; 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; +} + +export function isLinkRecord(value: unknown): value is LinkRecord { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.url === "string" && + typeof record.path === "string" && + typeof record.title === "string" && + typeof record.createdAt === "number" && + typeof record.publishedAt === "number" && + (record.expires === null || typeof record.expires === "number") && + (record.maxViews === null || typeof record.maxViews === "number") && + (record.salt === null || typeof record.salt === "string") && + typeof record.images === "boolean" + ); +} + +/** 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 { + let moved = false; + const next = links.map((record) => { + const path = + record.path === from + ? to + : record.path.startsWith(`${from}/`) + ? to + record.path.slice(from.length) + : null; + if (path === null) return record; + moved = true; + return { ...record, path }; + }); + return moved ? next : null; +} diff --git a/packages/plugin/src/links/sanitize.ts b/packages/plugin/src/links/sanitize.ts new file mode 100644 index 00000000..b3b009e5 --- /dev/null +++ b/packages/plugin/src/links/sanitize.ts @@ -0,0 +1,119 @@ +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; + /** 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, + mermaidAsSource: 0, + }; + for (const frontmatter of Array.from( + root.querySelectorAll("pre.frontmatter"), + )) { + frontmatter.remove(); + } + for (const embed of Array.from(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++; + } + } + for (const wrapper of Array.from( + root.querySelectorAll(".mermaid-wrapper.is-guarded"), + )) { + const pre = wrapper.querySelector(".mermaid-guard-source pre"); + if (pre) { + wrapper.replaceWith(pre); + report.mermaidAsSource++; + } + } + for (const element of Array.from( + root.querySelectorAll( + "audio, video, iframe, object, embed, script, style, button", + ), + )) { + element.remove(); + } + for (const anchor of Array.from(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") || + !(/^(https?:|mailto:)/i.test(href) || href.startsWith("#")) + ) { + anchor.replaceWith( + root.ownerDocument.createTextNode(anchor.textContent ?? ""), + ); + } else if (href.startsWith("#")) { + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + } + } + for (const img of Array.from(root.querySelectorAll("img"))) { + const src = img.getAttribute("src"); + // Collected vault images have no src until the caller inlines them. + if ( + src !== null && + !src.startsWith("https://") && + !src.startsWith("data:image/") + ) { + img.remove(); + } + } + for (const content of Array.from(root.querySelectorAll(".callout-content"))) { + content.removeAttribute("style"); + } + for (const paragraph of Array.from(root.querySelectorAll("p"))) { + if (paragraph.childElementCount === 0 && !paragraph.textContent?.trim()) { + paragraph.remove(); + } + } + for (const element of [root, ...Array.from(root.querySelectorAll("*"))]) { + 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"); + } + for (const checkbox of Array.from( + root.querySelectorAll('input[type="checkbox"]'), + )) { + checkbox.setAttribute("disabled", ""); + } + return report; +} + +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..dcfae3b0 --- /dev/null +++ b/packages/plugin/src/links/shared-links.ts @@ -0,0 +1,89 @@ +import { 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; + } + + of(path: string): LinkRecord[] { + return this.all().filter((record) => 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); + } + + /** 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..b05ad2f2 --- /dev/null +++ b/packages/plugin/src/links/snapshot.ts @@ -0,0 +1,102 @@ +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; + +export interface Snapshot { + html: string; + /** Milliseconds, just before the note was read. */ + takenAt: number; + /** 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> { + const host = activeDocument.body.createDiv({ cls: "mdsync-link-render" }); + const component = new Component(); + component.load(); + const untag = await tagMathSources(markdown); + try { + await MarkdownRenderer.render(app, markdown, host, file.path, component); + 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, + left: { + embeds: report.embedsDropped, + images, + diagrams: report.mermaidAsSource, + }, + }; + } finally { + untag(); + component.unload(); + host.remove(); + } +} + +/** Renders asynchronously: math, embeds and images arrive after `render` resolves. */ +async function settled(host: HTMLElement): Promise { + const deadline = Date.now() + SETTLE_MAX_MS; + let last = -1; + 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; + if (loading === 0 && size === last) return; + last = size; + await sleep(SETTLE_STEP_MS); + } +} + +/** 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; + for (const { img, link } of images) { + const uri = await inlineImage(app, sourcePath, link).catch(() => null); + 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..5ddf85aa 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 { registerLinkRenames } 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); + registerLinkRenames(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..1cc9a587 --- /dev/null +++ b/packages/plugin/src/plugin/links.ts @@ -0,0 +1,16 @@ +import type { Plugin } from "obsidian"; + +import { reportWarning } from "@/shared"; + +import type { PluginHost } from "./host"; + +/** A renamed note keeps its links: they follow it to the new path. */ +export function registerLinkRenames(plugin: Plugin & PluginHost): void { + plugin.registerEvent( + plugin.app.vault.on("rename", (file, oldPath) => { + plugin.sharedLinks + .move(oldPath, file.path) + .catch((err) => reportWarning("Could not save the moved 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..08ac9623 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 { isLinkRecord, type LinkRecord } 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.filter(isLinkRecord) + : [], 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..85c801c8 --- /dev/null +++ b/packages/plugin/src/storage/adapters/link-broker.ts @@ -0,0 +1,101 @@ +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"; + +export interface LinkOptions { + maxViews: number | null; + /** Unix seconds. */ + expires: number | null; + /** Passphrase gate and salt (base64url), both or neither. */ + protection: { gate: string; salt: string } | null; +} + +/** Creates a link, or with `update` replaces a standing one and keeps its view counter. */ +export async function putLink( + admin: BrokerAdmin, + id: string, + sealed: Uint8Array, + options: LinkOptions, + update?: boolean, +): Promise { + const query = new URLSearchParams(); + if (options.maxViews !== null) + query.set("maxViews", String(options.maxViews)); + if (options.expires !== null) query.set("expires", String(options.expires)); + if (update) query.set("update", "1"); + const headers: Record = { + "Content-Type": "application/octet-stream", + }; + if (options.protection) { + headers[LINK_HEADERS.gate] = options.protection.gate; + headers[LINK_HEADERS.salt] = options.protection.salt; + } + const suffix = query.size ? `?${query}` : ""; + await callLink(admin, `/link/${id}${suffix}`, { + 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 = + "This relay does not support share links yet. Redeploy it with the Deploy Relay workflow."; + } 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 === 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..dda5b332 100644 --- a/packages/plugin/src/storage/index.ts +++ b/packages/plugin/src/storage/index.ts @@ -2,6 +2,7 @@ export { defaultGoogleDriveConfig, googleLoginUrl, } from "./adapters/google-drive-auth"; +export { linkStatus, putLink, 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..c6e135b8 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, + updateSharedLink, +} 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..561f7494 --- /dev/null +++ b/packages/plugin/src/ui/links/index.ts @@ -0,0 +1,8 @@ +export { updateSharedLink } 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..9a2e4af0 --- /dev/null +++ b/packages/plugin/src/ui/links/link-actions.ts @@ -0,0 +1,29 @@ +import { type LinkRecord, updateLink } from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { notifyInfo } from "@/ui/common"; +import { openPromptModal } from "@/ui/modals"; + +export async function updateSharedLink( + plugin: PluginHost, + record: LinkRecord, +): Promise { + let passphrase: string | null = null; + if (record.salt) { + passphrase = await openPromptModal({ + app: plugin.app, + title: "Passphrase", + description: + "Type the link's passphrase. A different one replaces it, and anyone who has the old one can no longer open the link.", + initialValue: "", + confirmLabel: "Update", + label: "Passphrase", + }); + if (passphrase === null) return; + } + await updateLink(plugin, record, passphrase); + notifyInfo( + record.maxViews === null + ? "The link now shows the note as it is." + : "The link now shows the note as it is. Views already used stay used.", + ); +} 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..f256b9cb --- /dev/null +++ b/packages/plugin/src/ui/links/link-menu.ts @@ -0,0 +1,64 @@ +import { type App, type Menu, type TAbstractFile, TFile } from "obsidian"; + +import { docKindOf } from "@/live"; +import type { PluginHost } from "@/plugin/host"; +import { isRelayConfigured } from "@/settings/model"; +import { notifyInfo, RELAY_TEXT } 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, + onCreated?: () => void, +): void { + if (!isRelayConfigured(plugin.settings)) { + notifyInfo(`${RELAY_TEXT["no-relay"]} to share links.`); + return; + } + new ShareLinkModal(plugin, file, onCreated).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..a5352095 --- /dev/null +++ b/packages/plugin/src/ui/links/manage-links-modal.ts @@ -0,0 +1,190 @@ +import type { LinkStatus } from "@mdsync/protocol"; +import { type EventRef, Modal, Setting, type TFile } from "obsidian"; +import { + isExpired, + isStale, + type LinkRecord, + linkError, + linkStatusOf, + linkStatusText, + onRelay, + revokeLinkRecord, +} from "@/links"; +import type { PluginHost } from "@/plugin/host"; +import { copyText, notifyError, serial } from "@/ui/common"; +import { openConfirmModal } from "@/ui/modals"; + +import { updateSharedLink } 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); + } + + private async load(): Promise { + await Promise.all( + this.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 = this.app.vault.getFileByPath(record.path); + const stale = file && isStale(record, file.stat.mtime); + const setting = new Setting(list) + .setName(record.title || record.path) + .setDesc( + `${stale ? "Changed since it was shared. " : ""}${describe(record, reachable, known)}`, + ); + if (record.title && record.title !== record.path) { + setting.descEl.createDiv({ text: record.path }); + } + if (!ended) { + setting + .addExtraButton((button) => + button + .setIcon("copy") + .setTooltip("Copy link") + .onClick(() => void copyText("Link", record.url)), + ) + .addButton((button) => + button.setButtonText("Update").onClick( + this.guarded(button, async () => { + await updateSharedLink(this.plugin, record); + await this.load(); + }), + ), + ); + } + 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 "${record.title || record.path}" 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) { + 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..7924b0a5 --- /dev/null +++ b/packages/plugin/src/ui/links/note-link-action.ts @@ -0,0 +1,64 @@ +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)); + plugin.registerEvent(vault.on("modify", 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..02dc5f5e --- /dev/null +++ b/packages/plugin/src/ui/links/share-link-modal.ts @@ -0,0 +1,295 @@ +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, + 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 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, + private readonly onCreated?: () => void, + ) { + super(plugin.app); + } + + onOpen(): void { + this.modalEl.addClass("mdsync-link-modal"); + this.titleEl.setText(`Share a link to "${this.file.basename}"`); + this.renderForm(); + } + + 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 (typeof expiry === "string") { + failure.setText(expiry); + 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.expires, + ); + this.onCreated?.(); + if (!this.closed) 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 === "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( + typeof expiry === "string" ? expiry : expiryLine(expiry.expires), + ); + }; + 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, + expires: number | null, + ): Promise { + const views = VIEW_CHOICES.find(({ key }) => key === this.views); + return publishLink( + this.plugin, + this.file, + { + expires, + maxViews: views?.views ?? null, + passphrase: passphrase || null, + images: this.images, + title: 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}.` : ""); + 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..847e6c32 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,19 @@ export const LAPTOP: Person = { idle: false, }; +export const LINK: LinkRecord = { + id: "link", + url: "https://example.com/link", + path: "notes/published.md", + title: "Published", + 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 +49,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..65b3f91d --- /dev/null +++ b/packages/plugin/tests/links/describe.test.ts @@ -0,0 +1,212 @@ +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", + title: "Note", + 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({ + expires: NOW_MS / MS_PER_S + seconds, + }); + }); + + it("resolves never without a date", () => { + expect(resolveExpiry("never", "", NOW_MS)).toEqual({ expires: null }); + }); + + it("ignores a picked date for presets", () => { + expect(resolveExpiry("day", "invalid", NOW_MS)).toEqual({ + 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( + { + 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)).toBe( + "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), + ).toBe("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, + ), + ).toBe("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)).toBe( + "Pick a date and time.", + ); + }, + ); + + it("rejects an unknown choice", () => { + expect(resolveExpiry("unknown", "", NOW_MS)).toBe("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..2ca578f4 --- /dev/null +++ b/packages/plugin/tests/links/images.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import type { App, TFile } from "obsidian"; +import { 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 }; +} + +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("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..27ede89b --- /dev/null +++ b/packages/plugin/tests/links/math.test.ts @@ -0,0 +1,91 @@ +// @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(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..9c930fd4 --- /dev/null +++ b/packages/plugin/tests/links/publish.test.ts @@ -0,0 +1,449 @@ +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, + 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 { linkStatus, putLink, revokeLink } from "@/storage"; + +vi.mock("@/storage", async (original) => ({ + ...(await original()), + putLink: 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, + left: { embeds: 0, images: 0, diagrams: 0 }, +}; +const options: ShareOptions = { + expires: 2_000_000_000, + maxViews: 5, + passphrase: null, + images: true, + title: 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, + title: file.basename, + createdAt: 1_760_000_000_000, + publishedAt: 1_760_000_000_000, + expires: options.expires, + 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(putLink).mock.calls[0]; + assert(call); + return call; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(takeSnapshot).mockResolvedValue(snapshot); + vi.mocked(noteText).mockResolvedValue("# Trip\nTrip notes in May"); +}); + +describe("publishLink", () => { + 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 published = await publishLink(plugin, file, options); + const location = locationOf(published); + const [, id, sealed, uploadedOptions, update] = uploadCall(); + + expect(published.url).toBe( + `${relay}/s/${published.id}#${toBase64Url(location.key)}`, + ); + expect(location.id).toBe(published.id); + expect(published).toMatchObject({ + path: file.path, + title: file.basename, + expires: options.expires, + maxViews: options.maxViews, + salt: null, + images: true, + }); + expect(putLink).toHaveBeenCalledTimes(1); + expect(uploadCall()[0]).toEqual(admin); + expect(id).toBe(published.id); + expect(update).toBeFalsy(); + expect(uploadedOptions).toEqual({ + expires: options.expires, + maxViews: options.maxViews, + protection: 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: expect.any(Number), + }); + }); + + 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, + }); + 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(putLink).toHaveBeenCalledTimes(1); + expect(uploadedOptions.protection).toEqual({ + 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, + title: false, + }); + const [, id, sealed] = uploadCall(); + const { content } = await deriveLinkKeys(locationOf(published).key); + + expect(published.title).toBe(""); + 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(); + vi.mocked(takeSnapshot).mockResolvedValueOnce({ + ...snapshot, + html: randomBytes(LINK_MAX_SEALED_BYTES + 1024 * 1024).toString("base64"), + }); + + await expect(publishLink(plugin, file, options)).rejects.toThrow(TOO_LARGE); + + expect(putLink).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(); + vi.mocked(takeSnapshot).mockResolvedValueOnce({ + ...snapshot, + html: "a".repeat(40 * 1024 * 1024), + }); + + await expect(publishLink(plugin, file, options)).rejects.toThrow(TOO_LARGE); + + expect(putLink).not.toHaveBeenCalled(); + }); +}); + +describe("updateLink", () => { + 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(putLink).toHaveBeenCalledTimes(1); + const [, id, sealed, uploadedOptions, update] = uploadCall(); + expect(uploadCall()[0]).toEqual(admin); + expect(id).toBe(published.id); + expect(update).toBe(true); + expect(uploadedOptions.protection).toBeNull(); + 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(putLink).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(putLink).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(putLink).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(putLink).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(putLink).toHaveBeenCalled()); + finish(); + await first; + + await updateLink(plugin, published, null); + expect(putLink).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

" }; + + 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); + }); + + it("stores the note's path as it is once the upload ends", async () => { + const { plugin, file } = host(); + vi.mocked(putLink).mockImplementationOnce(async () => { + file.path = "moved/Trip.md"; + }); + + const published = await publishLink(plugin, file, options); + + 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(takeSnapshot).mockImplementationOnce(async () => { + plugin.settings.relayUrl = "https://other.example"; + return snapshot; + }); + + const published = await publishLink(plugin, file, options); + + 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)).rejects.toThrow( + "Set up the relay", + ); + expect(putLink).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(putLink).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..439f2c78 --- /dev/null +++ b/packages/plugin/tests/links/record.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + isLinkRecord, + type LinkRecord, + onRelay, + renamedLinks, +} from "@/links/record"; + +const record: LinkRecord = { + id: "abcdefghijklmnopqrstuv", + url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", + path: "a/b/note.md", + title: "Note", + 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("isLinkRecord", () => { + it.each([ + record, + { ...record, expires: 2_000_000_000, maxViews: 5, salt: "salt" }, + { ...record, title: "", images: false }, + ])("accepts a complete record %#", (value) => { + expect(isLinkRecord(value)).toBe(true); + }); + + it.each([null, undefined, "link", 42, true, [], {}])( + "rejects a non-record %#", + (value) => { + expect(isLinkRecord(value)).toBe(false); + }, + ); + + it.each([ + ["id", 1], + ["url", null], + ["path", false], + ["title", 1], + ["createdAt", "today"], + ["expires", "never"], + ["maxViews", "unlimited"], + ["salt", 1], + ["images", "yes"], + ] as const)("rejects an invalid %s", (field, value) => { + expect(isLinkRecord({ ...record, [field]: value })).toBe(false); + }); + + it("requires every field, including nullable fields", () => { + for (const field of Object.keys(record)) { + const incomplete = { ...record }; + Reflect.deleteProperty(incomplete, field); + expect(isLinkRecord(incomplete), field).toBe(false); + } + }); +}); + +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..4ad1a010 --- /dev/null +++ b/packages/plugin/tests/links/sanitize.test.ts @@ -0,0 +1,256 @@ +// @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, + 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, + 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("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..201c0279 --- /dev/null +++ b/packages/plugin/tests/links/shared-links.test.ts @@ -0,0 +1,129 @@ +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, + title: "Note", + 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("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..41118f00 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,46 @@ 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", + title: "Note", + 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..6732ace5 --- /dev/null +++ b/packages/plugin/tests/storage/link-broker.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type LinkOptions, + linkStatus, + putLink, + 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: LinkOptions = { + maxViews: null, + expires: null, + protection: null, +}; +const actions = { + put: () => putLink(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); + + await putLink(admin, ID, bytes, { + maxViews: 5, + expires: 2_000_000_000, + protection: { gate: "gate", salt: "salt" }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + url: `${URL}?maxViews=5&expires=2000000000`, + 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("omits the query and protection headers when options are null", async () => { + await actions.put(); + + expect(requests[0]).toMatchObject({ + url: URL, + method: "PUT", + }); + expect(requests[0]?.headers).toEqual({ + "X-Mdsync-Admin": "secret", + "Content-Type": "application/octet-stream", + }); + }); + + it("updates a standing link with its new options", async () => { + await putLink(admin, ID, sealed, { ...options, maxViews: 10 }, true); + + expect(requests[0]).toMatchObject({ + url: `${URL}?maxViews=10&update=1`, + method: "PUT", + }); + expect(new Uint8Array(requests[0]?.body as ArrayBuffer)).toEqual(sealed); + }); + + it("updates without limits using only the update query", async () => { + await putLink(admin, ID, sealed, options, true); + + 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 does not support share links yet. Redeploy it with the Deploy Relay workflow.", + }); + } + }, + ); + + 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("asks for a new link when an update has ended", async () => { + replies = [{ status: 404, text: '{"error":"gone"}' }]; + + await expect( + putLink(admin, ID, sealed, options, true), + ).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 () => { + await putLink( + { ...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..3920fe5b --- /dev/null +++ b/packages/plugin/tests/ui/link-actions.test.ts @@ -0,0 +1,173 @@ +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 { updateSharedLink } 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", + title: "Note", + 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).mockResolvedValue(undefined); + vi.mocked(openPromptModal).mockResolvedValue("passphrase"); +}); + +describe("updateSharedLink", () => { + it("updates an unprotected link without asking and keeps the view-limit copy", async () => { + const { plugin } = host(); + const record = { ...RECORD, maxViews: 5 }; + await updateSharedLink(plugin, record); + expect(openPromptModal).not.toHaveBeenCalled(); + expect(updateLink).toHaveBeenCalledWith(plugin, record, null); + 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 updateSharedLink(plugin, record); + expect(openPromptModal).toHaveBeenCalledWith( + expect.objectContaining({ title: "Passphrase", confirmLabel: "Update" }), + ); + expect(updateLink).toHaveBeenCalledWith(plugin, record, "passphrase"); + vi.mocked(updateLink).mockClear(); + vi.mocked(notifyInfo).mockClear(); + vi.mocked(openPromptModal).mockResolvedValueOnce(null); + await updateSharedLink(plugin, record); + expect(updateLink).not.toHaveBeenCalled(); + 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..0f80591b --- /dev/null +++ b/packages/plugin/tests/ui/manage-links-modal.test.ts @@ -0,0 +1,196 @@ +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 { updateSharedLink } 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", () => ({ + updateSharedLink: 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", + title: "Note", + 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]) { + 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, RECORD.path).onOpen(); + return { plugin, sharedLinks, note }; +} + +beforeEach(() => { + rows.length = 0; + vi.clearAllMocks(); + vi.mocked(linkStatusOf).mockResolvedValue(null); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ManageLinksModal", () => { + 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", async () => { + const { plugin } = host(2); + await rows[0]?.buttons.find(({ text }) => text === "Update")?.click(); + expect(updateSharedLink).toHaveBeenCalledWith(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(updateSharedLink).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..7ce32d35 --- /dev/null +++ b/packages/plugin/tests/ui/note-link-action.test.ts @@ -0,0 +1,148 @@ +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", + title: "Note", + 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: () => 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: (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")?.(); + 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("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..cba31814 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,7 +1,8 @@ -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 { isShareId, SIGN_BATCH_MAX, diff --git a/packages/protocol/src/link.ts b/packages/protocol/src/link.ts new file mode 100644 index 00000000..698c532b --- /dev/null +++ b/packages/protocol/src/link.ts @@ -0,0 +1,304 @@ +/** + * 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; +} + +export interface LinkProtection { + passphrase: string; + salt: Uint8Array; +} + +export interface LinkKeys { + content: CryptoKey; + /** What the relay checks before it serves a protected link; null when unprotected. */ + gate: string | null; +} + +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. + */ +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"], + ); + if (!protection) return { content, gate: null }; + 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. + const base = await subtle.importKey( + "raw", + utf8(passphrase.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..c6c0d65e --- /dev/null +++ b/packages/protocol/tests/link.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveLinkKeys, + fromBase64Url, + isLinkId, + LINK_GATE_PATTERN, + 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("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("has no gate without a passphrase", async () => { + expect((await deriveLinkKeys(newLinkKey())).gate).toBeNull(); + }); + + 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..431af881 --- /dev/null +++ b/packages/relay/src/link/client.ts @@ -0,0 +1,24 @@ +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 { + 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().split("::"); + const front = head === "" ? [] : head.split(":"); + const back = tail === undefined || tail === "" ? [] : tail.split(":"); + const gap = Math.max(IPV6_GROUPS - front.length - back.length, 0); + return [...front, ...Array(gap).fill("0"), ...back] + .slice(0, IPV6_PREFIX_GROUPS) + .map((group) => group.replace(/^0+(?=.)/, "")) + .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..e29a52ab --- /dev/null +++ b/packages/relay/src/link/durable-object.ts @@ -0,0 +1,58 @@ +/** One share link: its sealed blob, view counter and gate. See store.ts for the rules. */ + +import { DurableObject } from "cloudflare:workers"; +import type { LinkMeta, LinkStatus } from "@mdsync/protocol"; + +import { + type LinkSettings, + LinkStore, + type OpenResult, + type PutMode, + 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: PutMode, + ): Promise { + const result = this.store.put(blob, settings, mode); + if (result !== "stored") 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..fc4f9488 --- /dev/null +++ b/packages/relay/src/link/routes.ts @@ -0,0 +1,218 @@ +/** + * 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}$/; +/** Longer than any real gate; the cap only keeps a hostile body from being hashed. */ +const MAX_PRESENTED_GATE = 64; +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 settings = await readSettings(request, url); + if (typeof settings === "string") return badRequest(settings); + 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"; + const stored = await linkStub(env, id).put(blob, settings, mode); + if (stored === "gone") return gone(); + if (stored === "exists") { + return fresh( + jsonError(409, "exists", "A link with this id already exists"), + ); + } + return fresh(json({ stored: true, size: blob.byteLength })); +} + +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); + const presented = + typeof body?.gate === "string" && body.gate.length <= MAX_PRESENTED_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 = jsonError( + result.reason === "gate" ? 401 : 429, + result.reason, + result.reason === "gate" + ? "Wrong passphrase." + : "Too many wrong passphrases.", + ); + if (result.retryAfter !== null) { + response.headers.set("Retry-After", String(result.retryAfter)); + } + return fresh(response); +} + +/** A string is the reason the request is refused. */ +async function readSettings( + request: Request, + url: URL, +): Promise { + const now = Math.floor(Date.now() / 1000); + const maxViews = optionalInt( + url.searchParams.get("maxViews"), + 1, + LINK_MAX_VIEWS, + ); + const expires = optionalInt( + url.searchParams.get("expires"), + now + 1, + now + LINK_MAX_TTL_S, + ); + if (maxViews === "invalid") return "Invalid maxViews"; + if (expires === "invalid") return "Invalid expires"; + const gate = request.headers.get(LINK_HEADERS.gate); + const salt = request.headers.get(LINK_HEADERS.salt); + if ((gate === null) !== (salt === null)) + return "A passphrase needs a gate and a salt"; + if (gate !== null && salt !== null) { + if (!LINK_GATE_PATTERN.test(gate) || !SALT_PATTERN.test(salt)) { + return "Invalid gate or salt"; + } + return { maxViews, expires, gate: await fingerprint(gate), salt }; + } + return { maxViews, expires, gate: null, salt: null }; +} + +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 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..f402d0fd --- /dev/null +++ b/packages/relay/src/link/store.ts @@ -0,0 +1,270 @@ +/** + * 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 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; null when the link has no passphrase. */ + gate: string | null; + 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 }; + +/** A creation never inherits a counter; only an update, which needs a link still standing, keeps one. */ +export type PutMode = "create" | "update"; +export type PutResult = "stored" | "exists" | "gone"; + +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, + ) {} + + put(blob: ArrayBuffer, settings: LinkSettings, mode: PutMode): 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" && !standing) { + this.live(); + return "gone"; + } + 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, 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", + ); + 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), + ); + } + this.sql.exec( + "INSERT OR REPLACE INTO link(id, views, max_views, expires, gate, salt, size) VALUES(1, ?, ?, ?, ?, ?, ?)", + standing && old ? old.views : 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 (link.gate !== null && !(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.gate !== null, + size: link.size, + } + ); + } + + meta(): LinkMeta | null { + const link = this.live(); + return link && { protected: link.gate !== 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: typeof row.gate === "string" ? row.gate : null, + 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; + } + + /** 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/tests/link/client.test.ts b/packages/relay/tests/link/client.test.ts new file mode 100644 index 00000000..8c8faffb --- /dev/null +++ b/packages/relay/tests/link/client.test.ts @@ -0,0 +1,39 @@ +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("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("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"); + expect(await clientOf(request())).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/packages/relay/tests/link/routes.test.ts b/packages/relay/tests/link/routes.test.ts new file mode 100644 index 00000000..f3eb1000 --- /dev/null +++ b/packages/relay/tests/link/routes.test.ts @@ -0,0 +1,404 @@ +import { + LINK_HEADERS, + LINK_MAX_SEALED_BYTES, + LINK_MAX_TTL_S, + LINK_MAX_VIEWS, + 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, + type PutMode, +} 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: PutMode) => + 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, + body, + }); +} + +const open = ( + env: LinkRouteEnv, + id: string, + gate?: string, + ip = "203.0.113.7", +) => + call(env, `/link/${id}/open`, { + method: "POST", + headers: { "CF-Connecting-IP": ip }, + body: gate === undefined ? 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("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&expires=${expires}`); + expect(await stored.json()).toEqual({ stored: true, size: BLOB.length }); + 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"); + await open(env, id); + const updated = await put( + env, + id, + "?update=1&maxViews=5", + {}, + Uint8Array.from([9, 9]), + ); + expect(updated.status).toBe(200); + 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("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", {}], + ["an expiry in the past", `?expires=${NOW_S - 1}`, {}], + ["an expiry past the cap", `?expires=${NOW_S + LINK_MAX_TTL_S + 5}`, {}], + ["a gate without a salt", "", { [LINK_HEADERS.gate]: 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 put(env, id, query, headers)).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("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&expires=${expires}`); + 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, `?expires=${Math.floor(Date.now() / 1000) + 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)).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..950fb043 --- /dev/null +++ b/packages/relay/tests/link/store.test.ts @@ -0,0 +1,319 @@ +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: null, + 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 = null, 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("serves only the right gate", () => { + const { store } = setup(PROTECTED); + expect(open(store, WRONG)).toMatchObject({ ok: false, reason: "gate" }); + expect(open(store)).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(setup().store.meta()).toEqual({ protected: false, salt: null }); + }); + + 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 the counter", () => { + const { store } = setup({ ...OPEN, maxViews: 5 }); + open(store); + open(store); + expect(store.put(blob(50, 9), { ...OPEN, maxViews: 5 }, "update")).toBe( + "stored", + ); + expect(store.status()?.views).toBe(2); + expect(served(open(store))).toEqual(new Uint8Array(blob(50, 9))); + }); + + it("leaves a link already past a lowered limit spent", () => { + const { store } = setup({ ...OPEN, maxViews: 5 }); + open(store); + open(store); + store.put(blob(10, 3), { ...OPEN, maxViews: 2 }, "update"); + expect(open(store)).toEqual({ ok: false, reason: "gone" }); + }); + + 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("can add or drop the passphrase", () => { + const { store } = setup(); + store.put(blob(10, 3), PROTECTED, "update"); + expect(store.meta()?.protected).toBe(true); + store.put(blob(10, 3), OPEN, "update"); + expect(store.meta()?.protected).toBe(false); + }); +}); + +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..51659223 --- /dev/null +++ b/packages/viewer/package.json @@ -0,0 +1,23 @@ +{ + "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" + }, + "devDependencies": { + "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..345b9eb3 --- /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 | null): 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 === null ? {} : { 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..7f3e1309 --- /dev/null +++ b/packages/viewer/src/cache.ts @@ -0,0 +1,60 @@ +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), + expires: stored.expires ?? null, + }; + } 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..edf7f7d7 --- /dev/null +++ b/packages/viewer/src/clipboard.ts @@ -0,0 +1,9 @@ +/** 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; + } +} diff --git a/packages/viewer/src/code.ts b/packages/viewer/src/code.ts new file mode 100644 index 00000000..13f239c0 --- /dev/null +++ b/packages/viewer/src/code.ts @@ -0,0 +1,23 @@ +import { copyText } from "./clipboard"; + +/** How long a copy button shows how the copy went. */ +const RESULT_MS = 1500; +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", async () => { + const copied = await copyText(code.textContent ?? ""); + button.textContent = copied ? "Copied" : "Copy failed"; + setTimeout(() => { + button.textContent = LABEL; + }, RESULT_MS); + }); + 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/index.html b/packages/viewer/src/index.html new file mode 100644 index 00000000..c7778577 --- /dev/null +++ b/packages/viewer/src/index.html @@ -0,0 +1,16 @@ + + + + + + + + + Shared note + + + +
+ + + diff --git a/packages/viewer/src/keys.ts b/packages/viewer/src/keys.ts new file mode 100644 index 00000000..f9674a96 --- /dev/null +++ b/packages/viewer/src/keys.ts @@ -0,0 +1,96 @@ +import type { LinkKeys } from "@mdsync/protocol"; +import { + createStore, + 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; + /** With `stale`, only while those are still the kept ones: another tab may have kept newer. */ + drop(id: string, stale?: LinkKeys): 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, stale?: LinkKeys) => { + try { + // Read and delete in one transaction, so a newer entry kept meanwhile survives. + await db()("readwrite", (keys) => { + const kept = keys.get(id); + kept.onsuccess = () => { + const entry = kept.result as Entry | undefined; + if (!stale || entry?.keys.gate === stale.gate) keys.delete(id); + }; + return promisifyRequest(keys.transaction); + }); + } 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/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..8774be6a --- /dev/null +++ b/packages/viewer/src/sanitize.ts @@ -0,0 +1,46 @@ +import DOMPurify from "dompurify"; + +/** Anything else is a path into the owner's vault or the relay, and leads nowhere here. */ +const KEPT_LINK = /^(https?:|mailto:|#)/i; +const KEPT_IMAGE = /^(data:image\/|https:\/\/)/i; + +const purify = DOMPurify(window); + +purify.addHook("afterSanitizeAttributes", (node) => { + if (node instanceof HTMLAnchorElement) { + const href = node.getAttribute("href"); + if (href !== null && !KEPT_LINK.test(href)) node.removeAttribute("href"); + if (/^https?:/i.test(href ?? "")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer nofollow"); + } + } + if (node instanceof HTMLImageElement) { + node.removeAttribute("srcset"); + if (!KEPT_IMAGE.test(node.getAttribute("src") ?? "")) node.remove(); + } + if (node instanceof HTMLInputElement) 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..24eae84a --- /dev/null +++ b/packages/viewer/src/session.ts @@ -0,0 +1,185 @@ +/** 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."; +const CHANGED = "The passphrase has changed. Type the new one."; + +export class LinkSession { + private meta: LinkMeta | 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.meta = cache.get(id) ?? (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 { id, cache, show } = this.deps; + // A reload costs no view; a wrong passphrase is only known by failing to open. + const cached = cache.get(id); + 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 }); + // This tab's copy may predate a new passphrase that the kept keys already have. + return show( + unlock === "remembered" + ? { kind: "passphrase" } + : { 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") { + // Refused kept keys are stale: the owner set another passphrase. + if (outcome.kind === "gate" && unlock === "remembered") { + await this.deps.keys.drop(id, keys); + } + const problem = unlock === "remembered" ? CHANGED : WRONG; + show({ + kind: "passphrase", + problem: outcome.retryAfter ? waitMessage(outcome.retryAfter) : problem, + }); + 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); + 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..020fb4e8 --- /dev/null +++ b/packages/viewer/src/sidebar.css @@ -0,0 +1,173 @@ +/* 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; +} + +.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. */ +.wide-toggle::after { + content: ""; + flex: none; + width: 2.1rem; + height: 1.2rem; + border-radius: 1rem; + background: + radial-gradient(circle closest-side, #fff 90%, transparent) no-repeat + 0.15rem 50% / 0.9rem 0.9rem 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% - 0.15rem) 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, + .link-card:not(:has(.link-fact)), + .sidebar:not(:has(.outline, .link-fact)) { + 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..3501f994 --- /dev/null +++ b/packages/viewer/src/sidebar.ts @@ -0,0 +1,132 @@ +import { expiryText } from "./expiry"; + +/** 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, + 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, 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, + 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); + }); + card.append(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/view.ts b/packages/viewer/src/view.ts new file mode 100644 index 00000000..e5f2bf76 --- /dev/null +++ b/packages/viewer/src/view.ts @@ -0,0 +1,145 @@ +import { addAnchors, currentAnchor } from "./anchors"; +import { addCopyButtons } from "./code"; +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 = true; + + 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); + 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, 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..ee5418dd --- /dev/null +++ b/packages/viewer/src/viewer.css @@ -0,0 +1,225 @@ +@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; + } +} + +.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..1b647a7f --- /dev/null +++ b/packages/viewer/tests/api.test.ts @@ -0,0 +1,112 @@ +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", null)).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", null)).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", null)).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", null); + expect(JSON.parse(sent)).toEqual({}); + }); + + it("tells gone, wrong gate and cooldown apart", async () => { + expect(await createApi(reply(404)).open("id", null)).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", null)).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..c82a2401 --- /dev/null +++ b/packages/viewer/tests/cache.test.ts @@ -0,0 +1,38 @@ +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("gives back what it stored", () => { + const cache = sessionCache(); + cache.put("id", link); + expect(cache.get("id")).toEqual(link); + }); + + 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/keys.test.ts b/packages/viewer/tests/keys.test.ts new file mode 100644 index 00000000..8b199e72 --- /dev/null +++ b/packages/viewer/tests/keys.test.ts @@ -0,0 +1,71 @@ +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 refused keys only while they are still the kept ones", async () => { + const store = browserKeys(); + const old = await protectedKeys(); + const newer = await protectedKeys(); + await store.put("c", newer, null); + await store.drop("c", old); + expect((await store.get("c"))?.gate).toBe(newer.gate); + await store.drop("c", newer); + 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/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..3ccc1876 --- /dev/null +++ b/packages/viewer/tests/sanitize.test.ts @@ -0,0 +1,92 @@ +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("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("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..07f1684f --- /dev/null +++ b/packages/viewer/tests/session.test.ts @@ -0,0 +1,417 @@ +import { + deriveLinkKeys, + type LinkKeys, + type LinkMeta, + type LinkPayload, + newLinkId, + newLinkKey, + newLinkSalt, + sealLinkPayload, + toBase64Url, +} from "@mdsync/protocol"; +import { describe, expect, it } 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 | null) => OpenOutcome, +): LinkApi & { opens: (string | null)[]; metas: number } { + const calls = { opens: [] as (string | null)[], 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, stale) => { + if (stale && stored.get(id)?.keys.gate !== stale.gate) return; + 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("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 }); + await session.start(); + expect(last()).toEqual({ kind: "content", payload, viewsLeft: 4, expires }); + expect(api.opens).toEqual([null]); + 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 second = harness({ id, key, api, cache: first.cache }); + await second.session.start(); + 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 an older cached entry without an expiry or another view", async () => { + const { id, key, sealed, meta } = await link(); + sessionStorage.setItem( + `mdsync-link:${id}`, + JSON.stringify({ ...meta, sealed: toBase64Url(sealed), viewsLeft: 0 }), + ); + 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 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(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, as after a new passphrase", 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: "The passphrase has changed. Type the new one.", + }); + 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" }); + 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..471f2898 --- /dev/null +++ b/packages/viewer/tests/sidebar.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it } 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, +) => { + sidebar = createSidebar( + reader, + outline, + { viewsLeft, expires }, + 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.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..f8c7d66c --- /dev/null +++ b/packages/viewer/tests/view.test.ts @@ -0,0 +1,209 @@ +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"); + form?.dispatchEvent(new Event("submit", { cancelable: true })); + const remember = root.querySelector(".remember input") as HTMLInputElement; + remember.checked = false; + 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..3b408ecc 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,46 @@ 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 + devDependencies: + 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 +204,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 +273,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 +633,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'} @@ -873,6 +958,15 @@ 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/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 +1008,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 +1046,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 +1072,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 +1109,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 +1135,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 +1159,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 +1169,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 +1261,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 +1272,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 +1309,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 +1342,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 +1355,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 +1367,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,6 +1431,21 @@ 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==} @@ -1274,6 +1461,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 +1555,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 +1614,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 +1662,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 +1712,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 +1780,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 +1981,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': @@ -1957,6 +2220,17 @@ snapshots: dependencies: '@types/estree': 1.0.9 + '@types/trusted-types@2.0.7': + optional: true + + '@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 +2288,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 +2321,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 +2422,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 +2438,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 +2556,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 +2602,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 +2626,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 +2636,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 +2661,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,6 +2735,20 @@ 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: {} typescript@6.0.3: {} @@ -2370,6 +2757,8 @@ snapshots: undici@7.29.0: {} + undici@8.11.2: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -2402,7 +2791,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 +2815,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 +2844,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 +2911,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..bc506597 --- /dev/null +++ b/tests/e2e/link-client.ts @@ -0,0 +1,78 @@ +/** 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 = keys.gate + ? { "X-Mdsync-Gate": keys.gate, "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..66a12c63 --- /dev/null +++ b/tests/e2e/links-viewer.ts @@ -0,0 +1,375 @@ +/** + * 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 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 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"); + await other.page.locator(".remember input").uncheck(); + 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 when told not 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..15e09f5c --- /dev/null +++ b/tests/e2e/links.ts @@ -0,0 +1,251 @@ +/** + * 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"); + await open(relay.url, survivor.id); + relay.stop(); + relay = await startRelay(PORT, SECRET); + const again = await open(relay.url, survivor.id); + 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 } = await seal(id, key); + + check( + "a stored link reports its size", + await (await put(base, id, sealed)).json(), + { + stored: true, + size: sealed.length, + }, + ); + check( + "the owner route refuses without the secret", + (await fetch(`${base}/link/${id}/status`)).status, + 401, + ); + const opened = await open(base, id); + 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 } = await seal(id, newLinkKey()); + await put(base, id, sealed, "?maxViews=1"); + const results = await Promise.all( + Array.from({ length: 8 }, async () => (await open(base, id)).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)).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")).status, + 404, + ); + await put(base, id, sealed, "?maxViews=2"); + check( + "a new link takes the id of a spent one", + (await open(base, id)).headers.get(VIEWS_LEFT), + "1", + ); + check( + "a creation never replaces a link that stands", + (await put(base, id, sealed)).status, + 409, + ); + + await admin(base, `/link/${id}`, { method: "DELETE" }); + check("a revoked link is gone at once", (await open(base, id)).status, 404); + await put(base, id, sealed); + check("and takes a new one again", (await open(base, id)).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 } = await deriveLinkKeys(key); + const first = await sealLinkPayload(id, payload, content); + await put(base, id, first, "?maxViews=3"); + await open(base, id); + const edited = { ...payload, html: "

Edited

" }; + await put( + base, + id, + await sealLinkPayload(id, edited, content), + "?update=1&maxViews=3", + ); + const opened = await open(base, id); + 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 } = await seal(id, newLinkKey()); + await put(base, id, sealed, `?expires=${Math.floor(Date.now() / 1000) + 2}`); + check("a link opens before it expires", (await open(base, id)).status, 200); + await sleep(3_500); + check("and is gone after", (await open(base, id)).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 } = 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); + const blob = new Uint8Array(await (await open(base, id)).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), + ); + check("a note past the cap is refused", tooBig.status, 413); +} + +async function seal(id: string, key: Uint8Array) { + const { content } = await deriveLinkKeys(key); + return { id, sealed: await sealLinkPayload(id, payload, content), content }; +} 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" } From dc1f7e5402f1cd2c47017f8f9b442489bb0eb216 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Thu, 8 Oct 2026 23:01:16 +0300 Subject: [PATCH 2/4] refactor: link handling and improve passphrase gate logic --- .claude/security.md | 2 +- .claude/share-links.md | 60 +++-- packages/plugin/src/commands/index.ts | 19 +- packages/plugin/src/links/describe.ts | 46 ++-- packages/plugin/src/links/images.ts | 11 +- packages/plugin/src/links/index.ts | 6 +- packages/plugin/src/links/publish.ts | 155 ++++++----- packages/plugin/src/links/record.ts | 94 +++++-- packages/plugin/src/links/sanitize.ts | 110 +++++--- packages/plugin/src/links/shared-links.ts | 13 +- packages/plugin/src/links/snapshot.ts | 34 ++- packages/plugin/src/main.ts | 4 +- packages/plugin/src/plugin/links.ts | 16 +- packages/plugin/src/settings/model.ts | 4 +- .../src/storage/adapters/link-broker.ts | 73 ++++-- packages/plugin/src/storage/index.ts | 7 +- packages/plugin/src/ui/index.ts | 2 +- packages/plugin/src/ui/links/index.ts | 2 +- packages/plugin/src/ui/links/link-actions.ts | 64 +++-- packages/plugin/src/ui/links/link-menu.ts | 14 +- .../plugin/src/ui/links/manage-links-modal.ts | 55 ++-- .../plugin/src/ui/links/note-link-action.ts | 7 +- .../plugin/src/ui/links/share-link-modal.ts | 45 ++-- .../plugin/tests/helpers/explorer-host.ts | 3 +- packages/plugin/tests/links/describe.test.ts | 52 +++- packages/plugin/tests/links/images.test.ts | 104 +++++++- packages/plugin/tests/links/publish.test.ts | 247 +++++++++++++----- packages/plugin/tests/links/record.test.ts | 129 +++++++-- packages/plugin/tests/links/sanitize.test.ts | 14 +- .../plugin/tests/links/shared-links.test.ts | 22 +- .../settings/transfer-controller.test.ts | 3 +- .../plugin/tests/storage/link-broker.test.ts | 77 ++++-- packages/plugin/tests/ui/link-actions.test.ts | 89 ++++++- .../tests/ui/manage-links-modal.test.ts | 64 ++++- .../plugin/tests/ui/note-link-action.test.ts | 20 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/link-content.ts | 2 + packages/protocol/src/link.ts | 14 +- packages/protocol/tests/link.test.ts | 50 +++- packages/relay/src/link/client.ts | 29 +- packages/relay/src/link/durable-object.ts | 7 +- packages/relay/src/link/routes.ts | 77 +++--- packages/relay/src/link/store.ts | 63 +++-- packages/relay/src/share/http.ts | 31 ++- packages/relay/tests/link/client.test.ts | 33 ++- packages/relay/tests/link/routes.test.ts | 135 ++++++++-- packages/relay/tests/link/store.test.ts | 55 ++-- packages/viewer/src/api.ts | 4 +- packages/viewer/src/cache.ts | 1 - packages/viewer/src/keys.ts | 16 +- packages/viewer/src/sanitize.ts | 28 +- packages/viewer/src/session.ts | 29 +- packages/viewer/src/view.ts | 2 +- packages/viewer/tests/api.test.ts | 18 +- packages/viewer/tests/cache.test.ts | 7 +- packages/viewer/tests/keys.test.ts | 12 +- packages/viewer/tests/sanitize.test.ts | 47 ++++ packages/viewer/tests/session.test.ts | 67 ++++- packages/viewer/tests/view.test.ts | 13 +- tests/e2e/link-client.ts | 5 +- tests/e2e/links-viewer.ts | 9 +- tests/e2e/links.ts | 82 +++--- 62 files changed, 1830 insertions(+), 674 deletions(-) create mode 100644 packages/protocol/src/link-content.ts diff --git a/.claude/security.md b/.claude/security.md index 7ba04835..0480dd2e 100644 --- a/.claude/security.md +++ b/.claude/security.md @@ -10,5 +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 passphrase check, 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. +- 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 index 55419948..f6232431 100644 --- a/.claude/share-links.md +++ b/.claude/share-links.md @@ -15,7 +15,7 @@ shared folder ([shares](shares.md)): no accounts, no sync, a read-only snapshot. | 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 | -| Passphrase = a second factor on top of the fragment key, checked by the relay before it serves | a wrong guess burns no view; operator alone cannot decrypt | +| 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 | @@ -26,10 +26,11 @@ shared folder ([shares](shares.md)): no accounts, no sync, a read-only snapshot. (base64url has no `/`, so it cannot run into the key). The whole fragment stays in the browser. -Payload: `{v:1, title, html, createdAt}` -> JSON -> deflate -> AES-GCM. -Content key = HKDF(`key` [+ PBKDF2(passphrase, salt) when protected]); AAD -`link:`. With a passphrase, a second HKDF output (other `info`) is the -**gate**; the relay stores only its hash and compares it in constant time. +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. @@ -38,11 +39,11 @@ timings. Not the title, text or key. | Route | Auth | Does | | --- | --- | --- | -| `PUT /link/` | admin | body is the sealed bytes; query `maxViews`, `expires` (unix s); headers `X-Mdsync-Gate`, `X-Mdsync-Salt`. Creates (409 if one stands); `?update=1` replaces a standing link and keeps its counter (404 when gone), so an update never brings a spent link back and a creation never inherits a counter | +| `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?}` -> 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 | +| `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 @@ -75,12 +76,14 @@ gone ("expired or reached its view limit"), error. - 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, on by default) 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 (a new passphrase on **Update**) is - dropped and the form says so. +- **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: @@ -113,7 +116,8 @@ gone ("expired or reached its view limit"), error. 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. + **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. @@ -146,13 +150,18 @@ Properties are never published (no option): the renderer does not draw them. `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, title, - createdAt, publishedAt, expiry, view limit, salt, images) in `settings.links` - (device-local, never in device transfer). An older relay answers - `not_found`: the error says to redeploy it. + 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); each change saves and notifies subscribers. + (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 @@ -166,8 +175,8 @@ Properties are never published (no option): the renderer does not draw them. 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 kept; a protected link asks for its passphrase, a different one - replaces it) and **Stop sharing** (revoke at the relay, then forget). An ended + 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`). @@ -226,6 +235,15 @@ relay gains the `Link` Durable Object, migration `v3`, and static assets). 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 diff --git a/packages/plugin/src/commands/index.ts b/packages/plugin/src/commands/index.ts index f750a90d..83c8b5ae 100644 --- a/packages/plugin/src/commands/index.ts +++ b/packages/plugin/src/commands/index.ts @@ -1,7 +1,7 @@ import { MarkdownView, type Plugin } from "obsidian"; import { SOURCE_CONTROL_VIEW_TYPE } from "@/constants"; -import { type LinkRecord, linkError, noteLinks, onRelay } from "@/links"; +import { noteLinks, onRelay } from "@/links"; import type { PluginHost } from "@/plugin/host"; import { spaceOf } from "@/sync/space"; import type { DiffResult } from "@/sync/types"; @@ -24,7 +24,7 @@ import { resetRemoteStorage, runWithNotice, toggleAuthors, - updateSharedLink, + updateSharedLinks, verifyRemoteIntegrity, } from "@/ui"; @@ -183,7 +183,7 @@ export function registerCommands( onRelay(record, plugin.settings), ); if (records === undefined || records.length === 0) return false; - if (!checking) void updateNoteLinks(plugin, records); + if (!checking) void updateSharedLinks(plugin, records); return true; }, }); @@ -216,19 +216,6 @@ export function registerCommands( }); } -async function updateNoteLinks( - plugin: PluginHost, - records: LinkRecord[], -): Promise { - for (const record of records) { - try { - await updateSharedLink(plugin, record); - } catch (err) { - notifyError("Could not change the link", linkError(err)); - } - } -} - async function runCompare(plugin: Plugin & PluginHost): Promise { try { await plugin.controller.refresh(); diff --git a/packages/plugin/src/links/describe.ts b/packages/plugin/src/links/describe.ts index 1bfd76d3..77aec217 100644 --- a/packages/plugin/src/links/describe.ts +++ b/packages/plugin/src/links/describe.ts @@ -8,6 +8,9 @@ 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 }, @@ -16,7 +19,7 @@ export const EXPIRY_CHOICES = [ { 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: "date", label: "Pick a date…", seconds: "date" }, + { key: PICK_DATE, label: "Pick a date…" }, ] as const; export const VIEW_CHOICES = [ @@ -29,31 +32,44 @@ export const VIEW_CHOICES = [ 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, -): { expires: number | null } | string { +): ExpiryResult { const choice = EXPIRY_CHOICES.find((choice) => choice.key === key); - if (!choice) return "Choose an expiry."; - if (choice.seconds !== "date") { - return { - expires: - choice.seconds === null - ? null - : Math.floor(nowMs / MS_PER_S) + choice.seconds, - }; - } + 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 "Pick a date and time."; + 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 "Pick a date at least 1 minute ahead."; + return { ok: false, reason: "Pick a date at least 1 minute ahead." }; } if (aheadMs > LINK_MAX_TTL_S * MS_PER_S) { - return `Pick a date no more than ${LINK_MAX_TTL_S / DAY_S} days ahead.`; + return { + ok: false, + reason: `Pick a date no more than ${LINK_MAX_TTL_S / DAY_S} days ahead.`, + }; } - return { expires: Math.floor(pickedMs / MS_PER_S) }; + return expiringIn(Math.floor(aheadMs / MS_PER_S)); } export function expiryLine(expires: number | null): string { diff --git a/packages/plugin/src/links/images.ts b/packages/plugin/src/links/images.ts index 09ec1e56..fdd4af5e 100644 --- a/packages/plugin/src/links/images.ts +++ b/packages/plugin/src/links/images.ts @@ -10,7 +10,7 @@ const MIME: Record = { bmp: "image/bmp", avif: "image/avif", }; -/** An image under this goes as it is; a bigger photo is re-encoded smaller. */ +/** 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. */ @@ -33,9 +33,12 @@ export async function inlineImage( 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"; - if (flat && bytes.byteLength > KEEP_BYTES) { - const smaller = await reencode(blob); - if (smaller && smaller.size < blob.size) blob = smaller; + // 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; } diff --git a/packages/plugin/src/links/index.ts b/packages/plugin/src/links/index.ts index b2a7f762..bb595aea 100644 --- a/packages/plugin/src/links/index.ts +++ b/packages/plugin/src/links/index.ts @@ -5,6 +5,7 @@ export { expiryLine, leftOutText, linkStatusText, + PICK_DATE, resolveExpiry, VIEW_CHOICES, } from "./describe"; @@ -12,8 +13,10 @@ 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, @@ -21,10 +24,11 @@ export { } from "./publish"; export { isExpired, - isLinkRecord, 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/publish.ts b/packages/plugin/src/links/publish.ts index 69eedbd2..9454958c 100644 --- a/packages/plugin/src/links/publish.ts +++ b/packages/plugin/src/links/publish.ts @@ -2,7 +2,6 @@ import { deriveLinkKeys, fromBase64Url, LINK_MAX_SEALED_BYTES, - type LinkPayload, linkUrl, newLinkId, newLinkKey, @@ -16,23 +15,28 @@ import type { TFile } from "obsidian"; import type { PluginHost } from "@/plugin/host"; import { isRelayConfigured, relayAdmin } from "@/settings/model"; import { relayBase } from "@/shared"; -import { type BrokerAdmin, linkStatus, putLink, revokeLink } from "@/storage"; +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 { - /** Unix seconds; null never expires. */ - expires: number | null; + /** Seconds from now, counted by the relay; null never expires. */ + ttl: number | null; maxViews: number | null; passphrase: string | null; - images: boolean; /** Off publishes the note without its name. */ - title: boolean; + showTitle: boolean; } export const TOO_LARGE = "This note is too large to share as a link."; -const NO_RELAY = "Set up the relay under Connection to share links."; +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. */ @@ -50,51 +54,73 @@ export async function previewLink( return { ...rendered, takenAt }; } -/** With the `snapshot` the owner looked at, that exact copy is what goes out. */ +/** The `snapshot` the owner looked at is exactly what goes out. */ export async function publishLink( plugin: PluginHost, file: TFile, options: ShareOptions, - snapshot?: Snapshot, + 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 shot = snapshot ?? (await previewLink(plugin, file, options.images)); + 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, - title: options.title ? file.basename : "", + showTitle: options.showTitle, createdAt: Date.now(), - publishedAt: shot.takenAt, - expires: options.expires, + publishedAt: snapshot.takenAt, + expires, maxViews: options.maxViews, - salt: options.passphrase ? toBase64Url(newLinkSalt()) : null, - images: options.images, + 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, }; - await upload({ - admin, - file, - record, - key, - passphrase: options.passphrase, - update: false, - snapshot: shot, - }); - // A rename during the upload moved only the links already stored. - const stored = { ...record, path: file.path }; - await plugin.sharedLinks.add(stored); - return stored; + 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; } -/** The same link, id and key, now showing the note as it is; views already spent stay spent. */ +/** 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."); @@ -103,6 +129,7 @@ export async function updateLink( // 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}".`); @@ -110,16 +137,19 @@ export async function updateLink( const location = parseLinkLocation(pathname, hash); if (!location) throw new Error("This link's key is damaged. Create a new one."); - const snapshot = await previewLink(plugin, file, record.images); - await upload({ - admin, - file, - record, + 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, - update: true, + 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); @@ -145,46 +175,27 @@ function adminFor(plugin: PluginHost, record: LinkRecord): BrokerAdmin { return relayAdmin(plugin.settings); } -async function upload(job: { - admin: BrokerAdmin; - file: TFile; - record: LinkRecord; +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; - update: boolean; + title: string; snapshot: Snapshot; -}): Promise { - const { admin, file, record, key, passphrase, update, snapshot } = job; +}): Promise<{ bytes: Uint8Array; gate: string }> { + const { id, key, salt, passphrase, title, snapshot } = job; const protection = - record.salt && passphrase - ? { passphrase, salt: fromBase64Url(record.salt) } - : undefined; - if (record.salt && !protection) - throw new Error("Enter the link's passphrase."); + 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 payload: LinkPayload = { - title: record.title && file.basename, - html: snapshot.html, - createdAt: Date.now(), - }; - const sealed = await sealLinkPayload(record.id, payload, keys.content).catch( - (error: unknown) => { - throw error instanceof RangeError ? new Error(TOO_LARGE) : error; - }, - ); - if (sealed.length > LINK_MAX_SEALED_BYTES) throw new Error(TOO_LARGE); - await putLink( - admin, - record.id, - sealed, - { - maxViews: record.maxViews, - expires: record.expires, - protection: - keys.gate && record.salt - ? { gate: keys.gate, salt: record.salt } - : null, - }, - update, - ); + 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 index 523c54e4..6a178841 100644 --- a/packages/plugin/src/links/record.ts +++ b/packages/plugin/src/links/record.ts @@ -9,35 +9,60 @@ export interface LinkRecord { url: string; /** Where the note was when last shared; a rename in Obsidian updates it. */ path: string; - title: 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; null never expires. */ + /** 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; } -export function isLinkRecord(value: unknown): value is LinkRecord { - if (typeof value !== "object" || value === null) return false; +/** + * 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; - return ( - typeof record.id === "string" && - typeof record.url === "string" && - typeof record.path === "string" && - typeof record.title === "string" && - typeof record.createdAt === "number" && - typeof record.publishedAt === "number" && - (record.expires === null || typeof record.expires === "number") && - (record.maxViews === null || typeof record.maxViews === "number") && - (record.salt === null || typeof record.salt === "string") && - typeof record.images === "boolean" - ); + 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. */ @@ -71,17 +96,32 @@ export function renamedLinks( from: string, to: string, ): LinkRecord[] | null { - let moved = false; + 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 path = - record.path === from - ? to - : record.path.startsWith(`${from}/`) - ? to + record.path.slice(from.length) - : null; - if (path === null) return record; - moved = true; - return { ...record, path }; + const within = record.path === path || record.path.startsWith(`${path}/`); + if (record.detached || !within) return record; + touched = true; + return change(record); }); - return moved ? next : null; + return touched ? next : null; } diff --git a/packages/plugin/src/links/sanitize.ts b/packages/plugin/src/links/sanitize.ts index b3b009e5..f2a67f32 100644 --- a/packages/plugin/src/links/sanitize.ts +++ b/packages/plugin/src/links/sanitize.ts @@ -1,3 +1,5 @@ +import { LINK_HREF_ALLOWED, LINK_IMAGE_SRC_ALLOWED } from "@mdsync/protocol"; + export interface LocalImage { img: HTMLImageElement; link: string; @@ -8,6 +10,7 @@ export interface SanitizeReport { 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; } @@ -16,14 +19,30 @@ export function sanitizeRendered(root: HTMLElement): SanitizeReport { const report: SanitizeReport = { images: [], embedsDropped: 0, + imagesDropped: 0, mermaidAsSource: 0, }; - for (const frontmatter of Array.from( - root.querySelectorAll("pre.frontmatter"), - )) { + 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(); } - for (const embed of Array.from(root.querySelectorAll(".internal-embed"))) { +} + +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) { @@ -38,23 +57,28 @@ export function sanitizeRendered(root: HTMLElement): SanitizeReport { report.embedsDropped++; } } - for (const wrapper of Array.from( - root.querySelectorAll(".mermaid-wrapper.is-guarded"), - )) { +} + +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++; } } - for (const element of Array.from( - root.querySelectorAll( - "audio, video, iframe, object, embed, script, style, button", - ), +} + +function removeUnsafeElements(root: HTMLElement): void { + for (const element of root.querySelectorAll( + "audio, video, iframe, object, embed, script, style, button", )) { element.remove(); } - for (const anchor of Array.from(root.querySelectorAll("a"))) { +} + +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"); @@ -63,7 +87,7 @@ export function sanitizeRendered(root: HTMLElement): SanitizeReport { anchor.replaceWith(tag); } else if ( anchor.classList.contains("internal-link") || - !(/^(https?:|mailto:)/i.test(href) || href.startsWith("#")) + !LINK_HREF_ALLOWED.test(href) ) { anchor.replaceWith( root.ownerDocument.createTextNode(anchor.textContent ?? ""), @@ -73,45 +97,57 @@ export function sanitizeRendered(root: HTMLElement): SanitizeReport { anchor.removeAttribute("rel"); } } - for (const img of Array.from(root.querySelectorAll("img"))) { +} + +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 && - !src.startsWith("https://") && - !src.startsWith("data:image/") - ) { + if (src !== null && !LINK_IMAGE_SRC_ALLOWED.test(src)) { img.remove(); + report.imagesDropped++; } } - for (const content of Array.from(root.querySelectorAll(".callout-content"))) { +} + +function unfoldCallouts(root: HTMLElement): void { + for (const content of root.querySelectorAll(".callout-content")) { content.removeAttribute("style"); } - for (const paragraph of Array.from(root.querySelectorAll("p"))) { +} + +function removeEmptyParagraphs(root: HTMLElement): void { + for (const paragraph of root.querySelectorAll("p")) { if (paragraph.childElementCount === 0 && !paragraph.textContent?.trim()) { paragraph.remove(); } } - for (const element of [root, ...Array.from(root.querySelectorAll("*"))]) { - 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); - } +} + +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"); } - for (const checkbox of Array.from( - root.querySelectorAll('input[type="checkbox"]'), - )) { + 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", ""); } - return report; } function isPathAlt(alt: string | null, link: string): boolean { diff --git a/packages/plugin/src/links/shared-links.ts b/packages/plugin/src/links/shared-links.ts index dcfae3b0..7b8b6a81 100644 --- a/packages/plugin/src/links/shared-links.ts +++ b/packages/plugin/src/links/shared-links.ts @@ -1,4 +1,4 @@ -import { type LinkRecord, renamedLinks } from "./record"; +import { detachedLinks, type LinkRecord, renamedLinks } from "./record"; const MS_PER_S = 1000; /** setTimeout's ceiling; a later expiry just takes another lap. */ @@ -19,8 +19,11 @@ export class SharedLinks { 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.path === path); + return this.all().filter( + (record) => !record.detached && record.path === path, + ); } add(record: LinkRecord): Promise { @@ -45,6 +48,12 @@ export class SharedLinks { 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); diff --git a/packages/plugin/src/links/snapshot.ts b/packages/plugin/src/links/snapshot.ts index b05ad2f2..1b52f936 100644 --- a/packages/plugin/src/links/snapshot.ts +++ b/packages/plugin/src/links/snapshot.ts @@ -8,11 +8,16 @@ import { sanitizeRendered } from "./sanitize"; 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 }; } @@ -27,13 +32,14 @@ export async function takeSnapshot( 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(); - const untag = await tagMathSources(markdown); try { await MarkdownRenderer.render(app, markdown, host, file.path, component); - await settled(host); + const complete = await settled(host); mathToMathML(host); const report = sanitizeRendered(host); const images = options.images @@ -41,9 +47,11 @@ export async function takeSnapshot( : dropAll(report.images); return { html: host.innerHTML, + images: options.images, + complete, left: { embeds: report.embedsDropped, - images, + images: images + report.imagesDropped, diagrams: report.mermaidAsSource, }, }; @@ -54,10 +62,11 @@ export async function takeSnapshot( } } -/** Renders asynchronously: math, embeds and images arrive after `render` resolves. */ -async function settled(host: HTMLElement): Promise { +/** 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( @@ -65,10 +74,13 @@ async function settled(host: HTMLElement): Promise { ).length + [...host.querySelectorAll("img")].filter((img) => !img.complete).length; const size = host.innerHTML.length; - if (loading === 0 && size === last) return; + // 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. */ @@ -79,8 +91,16 @@ async function inline( ): 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) { - const uri = await inlineImage(app, sourcePath, link).catch(() => null); + 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; diff --git a/packages/plugin/src/main.ts b/packages/plugin/src/main.ts index 5ddf85aa..559c2d3f 100644 --- a/packages/plugin/src/main.ts +++ b/packages/plugin/src/main.ts @@ -46,7 +46,7 @@ import { type IgnoreStateHandle, registerIgnoreState, } from "./plugin/ignore-state"; -import { registerLinkRenames } from "./plugin/links"; +import { registerLinkPaths } from "./plugin/links"; import { registerProtocolHandlers } from "./plugin/protocols"; import { createRealtime, type Realtime } from "./plugin/realtime"; import { registerShareRenames } from "./plugin/share-moves"; @@ -163,7 +163,7 @@ export default class MdsyncPlugin extends Plugin implements PluginHost { registerScheduler(this, this.controller); registerWorkspaceMenus(this); registerShareRenames(this); - registerLinkRenames(this); + registerLinkPaths(this); registerIgnoreFileRefresh(this); registerStatePersistenceFlush(this, this.statePersister); diff --git a/packages/plugin/src/plugin/links.ts b/packages/plugin/src/plugin/links.ts index 1cc9a587..b3fbcc99 100644 --- a/packages/plugin/src/plugin/links.ts +++ b/packages/plugin/src/plugin/links.ts @@ -4,13 +4,23 @@ import { reportWarning } from "@/shared"; import type { PluginHost } from "./host"; -/** A renamed note keeps its links: they follow it to the new path. */ -export function registerLinkRenames(plugin: Plugin & PluginHost): void { +/** 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( - plugin.app.vault.on("rename", (file, oldPath) => { + 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/settings/model.ts b/packages/plugin/src/settings/model.ts index 08ac9623..0a11bb75 100644 --- a/packages/plugin/src/settings/model.ts +++ b/packages/plugin/src/settings/model.ts @@ -6,7 +6,7 @@ import { FILE_HISTORY_MAX_SNAPSHOTS, FILE_HISTORY_MIN_SNAPSHOTS, } from "@/constants"; -import { isLinkRecord, type LinkRecord } from "@/links/record"; +import { type LinkRecord, parseLinkRecord } from "@/links/record"; import { isSpaceRecord, type SpaceRecord } from "@/spaces/record"; import { type BrokerAdmin, @@ -289,7 +289,7 @@ export function mergeSettings( : [], localRoots: stringValues(stored?.localRoots), links: Array.isArray(stored?.links) - ? stored.links.filter(isLinkRecord) + ? stored.links.flatMap((link) => parseLinkRecord(link) ?? []) : [], spacesVault: typeof stored?.spacesVault === "string" ? stored.spacesVault : null, diff --git a/packages/plugin/src/storage/adapters/link-broker.ts b/packages/plugin/src/storage/adapters/link-broker.ts index 85c801c8..22eb3ac9 100644 --- a/packages/plugin/src/storage/adapters/link-broker.ts +++ b/packages/plugin/src/storage/adapters/link-broker.ts @@ -7,36 +7,68 @@ import { StorageRequestError } from "@/storage/types"; import { ADMIN_HEADER, type BrokerAdmin } from "./share-broker"; import { errorCode, StorageHttpError } from "./util"; -export interface LinkOptions { +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; - /** Unix seconds. */ - expires: number | null; - /** Passphrase gate and salt (base64url), both or neither. */ - protection: { gate: string; salt: string } | 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; } -/** Creates a link, or with `update` replaces a standing one and keeps its view counter. */ -export async function putLink( +/** Unix seconds the relay will end the link at; null never. */ +export async function createLink( admin: BrokerAdmin, id: string, sealed: Uint8Array, - options: LinkOptions, - update?: boolean, -): Promise { + options: NewLink, +): Promise { const query = new URLSearchParams(); if (options.maxViews !== null) query.set("maxViews", String(options.maxViews)); - if (options.expires !== null) query.set("expires", String(options.expires)); - if (update) query.set("update", "1"); + 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 (options.protection) { - headers[LINK_HEADERS.gate] = options.protection.gate; - headers[LINK_HEADERS.salt] = options.protection.salt; - } - const suffix = query.size ? `?${query}` : ""; - await callLink(admin, `/link/${id}${suffix}`, { + if (protection.salt) headers[LINK_HEADERS.salt] = protection.salt; + return callLink(admin, path, { method: "PUT", headers, body: sealed.slice().buffer, @@ -82,8 +114,7 @@ async function callLink( let refusal: string | undefined; if (res.status === 404) { if (code !== "gone") { - refusal = - "This relay does not support share links yet. Redeploy it with the Deploy Relay workflow."; + refusal = OUTDATED_RELAY; } else if (request.method === "GET") { return null; } else if (request.method === "PUT") { @@ -91,6 +122,8 @@ async function callLink( } } 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") { diff --git a/packages/plugin/src/storage/index.ts b/packages/plugin/src/storage/index.ts index dda5b332..db6e965b 100644 --- a/packages/plugin/src/storage/index.ts +++ b/packages/plugin/src/storage/index.ts @@ -2,7 +2,12 @@ export { defaultGoogleDriveConfig, googleLoginUrl, } from "./adapters/google-drive-auth"; -export { linkStatus, putLink, revokeLink } from "./adapters/link-broker"; +export { + createLink, + linkStatus, + replaceLink, + revokeLink, +} from "./adapters/link-broker"; export { defaultS3Config } from "./adapters/s3"; export { type BrokerAccess, diff --git a/packages/plugin/src/ui/index.ts b/packages/plugin/src/ui/index.ts index c6e135b8..647c0ca6 100644 --- a/packages/plugin/src/ui/index.ts +++ b/packages/plugin/src/ui/index.ts @@ -26,7 +26,7 @@ export { openManageLinks, openShareLink, registerNoteLinkActions, - updateSharedLink, + updateSharedLinks, } from "./links"; export { createDeletedElsewhere } from "./live/deleted-elsewhere"; export { rebuildLiveNote, toggleAuthors } from "./live/header/live-actions"; diff --git a/packages/plugin/src/ui/links/index.ts b/packages/plugin/src/ui/links/index.ts index 561f7494..d21c9746 100644 --- a/packages/plugin/src/ui/links/index.ts +++ b/packages/plugin/src/ui/links/index.ts @@ -1,4 +1,4 @@ -export { updateSharedLink } from "./link-actions"; +export { updateSharedLinks } from "./link-actions"; export { addLinkMenuItems, isLinkable, diff --git a/packages/plugin/src/ui/links/link-actions.ts b/packages/plugin/src/ui/links/link-actions.ts index 9a2e4af0..6ded1d5b 100644 --- a/packages/plugin/src/ui/links/link-actions.ts +++ b/packages/plugin/src/ui/links/link-actions.ts @@ -1,29 +1,49 @@ -import { type LinkRecord, updateLink } from "@/links"; +import { + type LinkRecord, + linkError, + type RenderedNote, + updateLink, +} from "@/links"; import type { PluginHost } from "@/plugin/host"; -import { notifyInfo } from "@/ui/common"; +import { notifyError, notifyInfo } from "@/ui/common"; import { openPromptModal } from "@/ui/modals"; -export async function updateSharedLink( +/** Updates each link in turn, asking a protected one for its passphrase; one notice says how it went. */ +export async function updateSharedLinks( plugin: PluginHost, - record: LinkRecord, + records: readonly LinkRecord[], ): Promise { - let passphrase: string | null = null; - if (record.salt) { - passphrase = await openPromptModal({ - app: plugin.app, - title: "Passphrase", - description: - "Type the link's passphrase. A different one replaces it, and anyone who has the old one can no longer open the link.", - initialValue: "", - confirmLabel: "Update", - label: "Passphrase", - }); - if (passphrase === null) return; + 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)); + } } - await updateLink(plugin, record, passphrase); - notifyInfo( - record.maxViews === null - ? "The link now shows the note as it is." - : "The link now shows the note as it is. Views already used stay used.", - ); + 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 index f256b9cb..fe27d257 100644 --- a/packages/plugin/src/ui/links/link-menu.ts +++ b/packages/plugin/src/ui/links/link-menu.ts @@ -1,9 +1,9 @@ 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, RELAY_TEXT } from "@/ui/common"; +import { notifyInfo } from "@/ui/common"; import { ManageLinksModal } from "./manage-links-modal"; import { ShareLinkModal } from "./share-link-modal"; @@ -21,16 +21,12 @@ export function isLinkable( ); } -export function openShareLink( - plugin: PluginHost, - file: TFile, - onCreated?: () => void, -): void { +export function openShareLink(plugin: PluginHost, file: TFile): void { if (!isRelayConfigured(plugin.settings)) { - notifyInfo(`${RELAY_TEXT["no-relay"]} to share links.`); + notifyInfo(NO_RELAY); return; } - new ShareLinkModal(plugin, file, onCreated).open(); + new ShareLinkModal(plugin, file).open(); } export function openManageLinks(plugin: PluginHost, path?: string): void { diff --git a/packages/plugin/src/ui/links/manage-links-modal.ts b/packages/plugin/src/ui/links/manage-links-modal.ts index a5352095..9575a6c9 100644 --- a/packages/plugin/src/ui/links/manage-links-modal.ts +++ b/packages/plugin/src/ui/links/manage-links-modal.ts @@ -7,6 +7,7 @@ import { linkError, linkStatusOf, linkStatusText, + noteName, onRelay, revokeLinkRecord, } from "@/links"; @@ -14,7 +15,7 @@ import type { PluginHost } from "@/plugin/host"; import { copyText, notifyError, serial } from "@/ui/common"; import { openConfirmModal } from "@/ui/modals"; -import { updateSharedLink } from "./link-actions"; +import { updateSharedLinks } from "./link-actions"; /** Null: the relay no longer has it. */ type Known = LinkStatus | null | Error; @@ -68,9 +69,10 @@ export class ManageLinksModal extends Modal { return onRelay(record, this.plugin.settings); } - private async load(): Promise { + /** Asks the relay where these stand; one update need not ask about the rest. */ + private async load(records = this.records()): Promise { await Promise.all( - this.records() + records .filter((record) => this.reachable(record)) .map(async (record) => { try { @@ -102,32 +104,36 @@ export class ManageLinksModal extends Modal { // Expiry redraws without asking the relay again. const known = isExpired(record) ? null : this.known.get(record.id); const ended = known === null || !reachable; - const file = this.app.vault.getFileByPath(record.path); + 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(record.title || record.path) + .setName(noteName(record)) .setDesc( - `${stale ? "Changed since it was shared. " : ""}${describe(record, reachable, known)}`, + `${deleted}${stale ? "Changed since it was shared. " : ""}${describe(record, reachable, known)}`, ); - if (record.title && record.title !== record.path) { + 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)), - ) - .addButton((button) => - button.setButtonText("Update").onClick( - this.guarded(button, async () => { - await updateSharedLink(this.plugin, record); - await this.load(); - }), - ), - ); + 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 @@ -162,7 +168,7 @@ export class ManageLinksModal extends Modal { app: this.plugin.app, title: "Stop sharing this link?", body: [ - `The link to "${record.title || record.path}" stops working at once. Anyone who already opened it keeps what they saw.`, + `The link to "${noteName(record)}" stops working at once. Anyone who already opened it keeps what they saw.`, ], confirmLabel: "Stop sharing", confirmClass: "mod-warning", @@ -180,6 +186,9 @@ function describe( 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…"; diff --git a/packages/plugin/src/ui/links/note-link-action.ts b/packages/plugin/src/ui/links/note-link-action.ts index 7924b0a5..5e793a00 100644 --- a/packages/plugin/src/ui/links/note-link-action.ts +++ b/packages/plugin/src/ui/links/note-link-action.ts @@ -53,7 +53,12 @@ export function registerNoteLinkActions(plugin: Plugin & PluginHost): void { plugin.register(plugin.sharedLinks.subscribe(refresh)); plugin.registerEvent(workspace.on("layout-change", refresh)); plugin.registerEvent(workspace.on("file-open", refresh)); - plugin.registerEvent(vault.on("modify", 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; diff --git a/packages/plugin/src/ui/links/share-link-modal.ts b/packages/plugin/src/ui/links/share-link-modal.ts index 02dc5f5e..5e4e5b43 100644 --- a/packages/plugin/src/ui/links/share-link-modal.ts +++ b/packages/plugin/src/ui/links/share-link-modal.ts @@ -15,6 +15,7 @@ import { type LinkRecord, leftOutText, linkError, + PICK_DATE, previewLink, publishLink, resolveExpiry, @@ -32,6 +33,9 @@ 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."; @@ -54,7 +58,6 @@ export class ShareLinkModal extends Modal { constructor( private readonly plugin: PluginHost, private readonly file: TFile, - private readonly onCreated?: () => void, ) { super(plugin.app); } @@ -65,6 +68,11 @@ export class ShareLinkModal extends Modal { 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(); @@ -136,8 +144,8 @@ export class ShareLinkModal extends Modal { serial(async () => { failure.setText(""); const expiry = resolveExpiry(this.expiry, this.pickedDate, Date.now()); - if (typeof expiry === "string") { - failure.setText(expiry); + if (!expiry.ok) { + failure.setText(expiry.reason); return; } if (this.passphrase && this.passphrase.length < MIN_PASSPHRASE) { @@ -153,13 +161,8 @@ export class ShareLinkModal extends Modal { this.uploading = true; this.syncCreate(); try { - const record = await this.create( - passphrase, - snapshot, - expiry.expires, - ); - this.onCreated?.(); - if (!this.closed) this.renderResult(record, passphrase); + const record = await this.create(passphrase, snapshot, expiry.ttl); + this.renderResult(record, passphrase); } catch (err) { failure.setText(linkError(err).message); } finally { @@ -177,15 +180,13 @@ export class ShareLinkModal extends Modal { let dateField: TextComponent; const update = (): void => { const nowMs = Date.now(); - dateField.inputEl.toggle(this.expiry === "date"); + 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( - typeof expiry === "string" ? expiry : expiryLine(expiry.expires), - ); + setting.setDesc(expiry.ok ? expiryLine(expiry.expires) : expiry.reason); }; setting .addDropdown((dropdown) => @@ -212,18 +213,17 @@ export class ShareLinkModal extends Modal { private create( passphrase: string, snapshot: Snapshot, - expires: number | null, + ttl: number | null, ): Promise { const views = VIEW_CHOICES.find(({ key }) => key === this.views); return publishLink( this.plugin, this.file, { - expires, + ttl, maxViews: views?.views ?? null, passphrase: passphrase || null, - images: this.images, - title: this.showName, + showTitle: this.showName, }, snapshot, ); @@ -244,7 +244,14 @@ export class ShareLinkModal extends Modal { if (run !== this.previews || this.closed) return; this.snapshot = snapshot; const left = leftOutText(snapshot.left); - line.setText(left ? `Left out of the link: ${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); diff --git a/packages/plugin/tests/helpers/explorer-host.ts b/packages/plugin/tests/helpers/explorer-host.ts index 847e6c32..d9d4377a 100644 --- a/packages/plugin/tests/helpers/explorer-host.ts +++ b/packages/plugin/tests/helpers/explorer-host.ts @@ -31,7 +31,8 @@ export const LINK: LinkRecord = { id: "link", url: "https://example.com/link", path: "notes/published.md", - title: "Published", + showTitle: true, + detached: false, createdAt: 0, publishedAt: 0, expires: null, diff --git a/packages/plugin/tests/links/describe.test.ts b/packages/plugin/tests/links/describe.test.ts index 65b3f91d..2491ab4d 100644 --- a/packages/plugin/tests/links/describe.test.ts +++ b/packages/plugin/tests/links/describe.test.ts @@ -26,7 +26,8 @@ const record: LinkRecord = { id: "abcdefghijklmnopqrstuv", url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", path: "note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1_760_000_000_000, publishedAt: 1_760_000_000_000, expires: null, @@ -66,16 +67,24 @@ describe("resolveExpiry", () => { ["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({ expires: null }); + 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, }); }); @@ -85,6 +94,8 @@ describe("resolveExpiry", () => { (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, }, ); @@ -94,8 +105,11 @@ describe("resolveExpiry", () => { it.each([-MINUTE_MS, 0])( "rejects a date %i milliseconds ahead", (aheadMs) => { - expect(resolveExpiry("date", pickedAt(NOW_MS + aheadMs), NOW_MS)).toBe( - "Pick a date at least 1 minute ahead.", + expect(resolveExpiry("date", pickedAt(NOW_MS + aheadMs), NOW_MS)).toEqual( + { + ok: false, + reason: "Pick a date at least 1 minute ahead.", + }, ); }, ); @@ -103,7 +117,7 @@ describe("resolveExpiry", () => { it("rejects a date less than one minute ahead even by one millisecond", () => { expect( resolveExpiry("date", pickedAt(NOW_MS + MINUTE_MS), NOW_MS + 1), - ).toBe("Pick a date at least 1 minute ahead."); + ).toEqual({ ok: false, reason: "Pick a date at least 1 minute ahead." }); }); it("rejects a date beyond the maximum even by one millisecond", () => { @@ -113,20 +127,38 @@ describe("resolveExpiry", () => { pickedAt(NOW_MS + LINK_MAX_TTL_S * MS_PER_S), NOW_MS - 1, ), - ).toBe("Pick a date no more than 365 days ahead."); + ).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)).toBe( - "Pick a date and time.", - ); + 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)).toBe("Choose an expiry."); + expect(resolveExpiry("unknown", "", NOW_MS)).toEqual({ + ok: false, + reason: "Choose an expiry.", + }); }); }); diff --git a/packages/plugin/tests/links/images.test.ts b/packages/plugin/tests/links/images.test.ts index 2ca578f4..2a1f5fef 100644 --- a/packages/plugin/tests/links/images.test.ts +++ b/packages/plugin/tests/links/images.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import type { App, TFile } from "obsidian"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { IMAGE_MAX_BYTES, inlineImage } from "@/links/images"; const sourcePath = "notes/Trip.md"; @@ -16,6 +16,34 @@ function imageApp(extension = "png", bytes = new ArrayBuffer(0)) { 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([ @@ -36,6 +64,80 @@ describe("inlineImage", () => { 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(); diff --git a/packages/plugin/tests/links/publish.test.ts b/packages/plugin/tests/links/publish.test.ts index 9c930fd4..c69047b0 100644 --- a/packages/plugin/tests/links/publish.test.ts +++ b/packages/plugin/tests/links/publish.test.ts @@ -17,6 +17,7 @@ import { assert, beforeEach, describe, expect, it, vi } from "vitest"; import { noteText } from "@/links/note-text"; import { linkStatusOf, + previewLink, publishLink, revokeLinkRecord, type ShareOptions, @@ -27,11 +28,12 @@ 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 { linkStatus, putLink, revokeLink } from "@/storage"; +import { createLink, linkStatus, replaceLink, revokeLink } from "@/storage"; vi.mock("@/storage", async (original) => ({ ...(await original()), - putLink: vi.fn(async () => {}), + createLink: vi.fn(async () => 2_000_000_000), + replaceLink: vi.fn(async () => {}), linkStatus: vi.fn(async () => null), revokeLink: vi.fn(async () => {}), })); @@ -43,14 +45,15 @@ 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 = { - expires: 2_000_000_000, + ttl: 3600, maxViews: 5, passphrase: null, - images: true, - title: true, + showTitle: true, }; function host() { @@ -78,10 +81,11 @@ function record(file: TFile): LinkRecord { id, url: linkUrl(relay, id, newLinkKey()), path: file.path, - title: file.basename, + showTitle: true, + detached: false, createdAt: 1_760_000_000_000, publishedAt: 1_760_000_000_000, - expires: options.expires, + expires: 2_000_000_000, maxViews: options.maxViews, salt: null, images: true, @@ -96,26 +100,83 @@ function locationOf(record: LinkRecord) { } function uploadCall() { - const call = vi.mocked(putLink).mock.calls[0]; + const call = vi.mocked(createLink).mock.calls[0]; assert(call); return call; } beforeEach(() => { - vi.clearAllMocks(); + 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 published = await publishLink(plugin, file, options); + const looked = await previewLink(plugin, file, true); + const published = await publishLink(plugin, file, options, looked); const location = locationOf(published); - const [, id, sealed, uploadedOptions, update] = uploadCall(); + const [, id, sealed, uploadedOptions] = uploadCall(); expect(published.url).toBe( `${relay}/s/${published.id}#${toBase64Url(location.key)}`, @@ -123,20 +184,22 @@ describe("publishLink", () => { expect(location.id).toBe(published.id); expect(published).toMatchObject({ path: file.path, - title: file.basename, - expires: options.expires, + showTitle: true, + detached: false, + expires: 2_000_000_000, + publishedAt: looked.takenAt, maxViews: options.maxViews, salt: null, images: true, }); - expect(putLink).toHaveBeenCalledTimes(1); + expect(createLink).toHaveBeenCalledTimes(1); expect(uploadCall()[0]).toEqual(admin); expect(id).toBe(published.id); - expect(update).toBeFalsy(); expect(uploadedOptions).toEqual({ - expires: options.expires, + ttl: options.ttl, maxViews: options.maxViews, - protection: null, + gate: (await deriveLinkKeys(location.key)).gate, + salt: null, }); expect(plugin.settings.links).toEqual([existing, published]); expect(saveSettings).toHaveBeenCalledTimes(1); @@ -153,25 +216,30 @@ describe("publishLink", () => { expect(await openLinkPayload(id, sealed, keys.content)).toEqual({ html: snapshot.html, title: file.basename, - createdAt: expect.any(Number), + 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, - }); + 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(putLink).toHaveBeenCalledTimes(1); - expect(uploadedOptions.protection).toEqual({ + expect(createLink).toHaveBeenCalledTimes(1); + expect(uploadedOptions).toMatchObject({ gate: right.gate, salt: published.salt, }); @@ -197,14 +265,19 @@ describe("publishLink", () => { it("publishes an empty title when the title is disabled", async () => { const { plugin, file } = host(); - const published = await publishLink(plugin, file, { - ...options, - title: false, - }); + const published = await publishLink( + plugin, + file, + { + ...options, + showTitle: false, + }, + snapshot, + ); const [, id, sealed] = uploadCall(); const { content } = await deriveLinkKeys(locationOf(published).key); - expect(published.title).toBe(""); + expect(published.showTitle).toBe(false); expect(await openLinkPayload(id, sealed, content)).toMatchObject({ title: "", html: snapshot.html, @@ -213,32 +286,78 @@ describe("publishLink", () => { it("refuses a snapshot whose sealed bytes exceed the size cap", async () => { const { plugin, file, saveSettings } = host(); - vi.mocked(takeSnapshot).mockResolvedValueOnce({ + const large = { ...snapshot, html: randomBytes(LINK_MAX_SEALED_BYTES + 1024 * 1024).toString("base64"), - }); + }; - await expect(publishLink(plugin, file, options)).rejects.toThrow(TOO_LARGE); + await expect(publishLink(plugin, file, options, large)).rejects.toThrow( + TOO_LARGE, + ); - expect(putLink).not.toHaveBeenCalled(); + 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(); - vi.mocked(takeSnapshot).mockResolvedValueOnce({ - ...snapshot, - html: "a".repeat(40 * 1024 * 1024), - }); + const large = { ...snapshot, html: "a".repeat(40 * 1024 * 1024) }; - await expect(publishLink(plugin, file, options)).rejects.toThrow(TOO_LARGE); + await expect(publishLink(plugin, file, options, large)).rejects.toThrow( + TOO_LARGE, + ); - expect(putLink).not.toHaveBeenCalled(); + 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); @@ -250,12 +369,13 @@ describe("updateLink", () => { await updateLink(plugin, published, null); expect(getFileByPath).toHaveBeenCalledWith(published.path); - expect(putLink).toHaveBeenCalledTimes(1); - const [, id, sealed, uploadedOptions, update] = uploadCall(); - expect(uploadCall()[0]).toEqual(admin); + 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(update).toBe(true); - expect(uploadedOptions.protection).toBeNull(); + expect(gate).toBe((await deriveLinkKeys(locationOf(published).key)).gate); expect(published.url).toBe(url); expect(plugin.settings.links).toEqual([ { ...published, publishedAt: expect.any(Number) }, @@ -280,7 +400,7 @@ describe("updateLink", () => { `The note is no longer at "${published.path}".`, ); - expect(putLink).not.toHaveBeenCalled(); + expect(replaceLink).not.toHaveBeenCalled(); expect(takeSnapshot).not.toHaveBeenCalled(); expect(plugin.settings.links).toEqual([published]); }); @@ -294,7 +414,7 @@ describe("updateLink", () => { "Enter the link's passphrase.", ); - expect(putLink).not.toHaveBeenCalled(); + expect(replaceLink).not.toHaveBeenCalled(); }); it("updates the note where it is now, not where the list showed it", async () => { @@ -314,7 +434,7 @@ describe("updateLink", () => { "This link is no longer shared.", ); - expect(putLink).not.toHaveBeenCalled(); + expect(replaceLink).not.toHaveBeenCalled(); }); it("refuses a second update of a link while one uploads", async () => { @@ -322,7 +442,7 @@ describe("updateLink", () => { const published = record(file); plugin.settings.links = [published]; let finish = () => {}; - vi.mocked(putLink).mockImplementationOnce( + vi.mocked(replaceLink).mockImplementationOnce( () => new Promise((resolve) => (finish = resolve)), ); @@ -330,12 +450,12 @@ describe("updateLink", () => { await expect(updateLink(plugin, published, null)).rejects.toThrow( "This link is already updating.", ); - await vi.waitFor(() => expect(putLink).toHaveBeenCalled()); + await vi.waitFor(() => expect(replaceLink).toHaveBeenCalled()); finish(); await first; await updateLink(plugin, published, null); - expect(putLink).toHaveBeenCalledTimes(2); + expect(replaceLink).toHaveBeenCalledTimes(2); }); }); @@ -375,7 +495,11 @@ describe("revokeLinkRecord", () => { 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

" }; + const looked = { + ...snapshot, + html: "

What the owner saw

", + images: false, + }; await publishLink(plugin, file, options, looked); @@ -387,15 +511,20 @@ describe("a relay that changes meanwhile", () => { "

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(putLink).mockImplementationOnce(async () => { + vi.mocked(createLink).mockImplementationOnce(async () => { file.path = "moved/Trip.md"; + return 2_000_000_000; }); - const published = await publishLink(plugin, file, options); + const published = await publishLink(plugin, file, options, snapshot); expect(published.path).toBe("moved/Trip.md"); expect(plugin.settings.links).toEqual([published]); @@ -403,12 +532,12 @@ describe("a relay that changes meanwhile", () => { it("sends the upload to the relay its address names", async () => { const { plugin, file } = host(); - vi.mocked(takeSnapshot).mockImplementationOnce(async () => { + vi.mocked(createLink).mockImplementationOnce(async () => { plugin.settings.relayUrl = "https://other.example"; - return snapshot; + return 2_000_000_000; }); - const published = await publishLink(plugin, file, options); + const published = await publishLink(plugin, file, options, snapshot); expect(published.url.startsWith(`${relay}/s/`)).toBe(true); expect(uploadCall()[0]).toEqual(admin); @@ -418,10 +547,10 @@ describe("a relay that changes meanwhile", () => { const { plugin, file } = host(); plugin.settings.relaySecret = ""; - await expect(publishLink(plugin, file, options)).rejects.toThrow( + await expect(publishLink(plugin, file, options, snapshot)).rejects.toThrow( "Set up the relay", ); - expect(putLink).not.toHaveBeenCalled(); + expect(createLink).not.toHaveBeenCalled(); }); it("does not revoke, update or ask about a link of another relay", async () => { @@ -441,7 +570,7 @@ describe("a relay that changes meanwhile", () => { ); expect(revokeLink).not.toHaveBeenCalled(); - expect(putLink).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 index 439f2c78..4ad3501c 100644 --- a/packages/plugin/tests/links/record.test.ts +++ b/packages/plugin/tests/links/record.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { - isLinkRecord, + detachedLinks, type LinkRecord, + noteName, onRelay, + parseLinkRecord, renamedLinks, } from "@/links/record"; @@ -10,7 +12,8 @@ const record: LinkRecord = { id: "abcdefghijklmnopqrstuv", url: "https://relay.example/s/abcdefghijklmnopqrstuv#key", path: "a/b/note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1_760_000_000_000, publishedAt: 1_760_000_000_000, expires: null, @@ -21,45 +24,125 @@ const record: LinkRecord = { const relay = { relayUrl: "https://relay.example", relaySecret: "secret" }; -describe("isLinkRecord", () => { +describe("parseLinkRecord", () => { it.each([ record, { ...record, expires: 2_000_000_000, maxViews: 5, salt: "salt" }, - { ...record, title: "", images: false }, - ])("accepts a complete record %#", (value) => { - expect(isLinkRecord(value)).toBe(true); + { ...record, showTitle: false, images: false, detached: true }, + ])("keeps a complete record %#", (value) => { + expect(parseLinkRecord(value)).toEqual(value); }); - it.each([null, undefined, "link", 42, true, [], {}])( - "rejects a non-record %#", - (value) => { - expect(isLinkRecord(value)).toBe(false); + 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([ - ["id", 1], - ["url", null], - ["path", false], - ["title", 1], - ["createdAt", "today"], - ["expires", "never"], - ["maxViews", "unlimited"], - ["salt", 1], - ["images", "yes"], - ] as const)("rejects an invalid %s", (field, value) => { - expect(isLinkRecord({ ...record, [field]: value })).toBe(false); + ["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("requires every field, including nullable fields", () => { + it("only requires id and url", () => { for (const field of Object.keys(record)) { const incomplete = { ...record }; Reflect.deleteProperty(incomplete, field); - expect(isLinkRecord(incomplete), field).toBe(false); + 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"); diff --git a/packages/plugin/tests/links/sanitize.test.ts b/packages/plugin/tests/links/sanitize.test.ts index 4ad1a010..d23c1705 100644 --- a/packages/plugin/tests/links/sanitize.test.ts +++ b/packages/plugin/tests/links/sanitize.test.ts @@ -138,6 +138,7 @@ describe("sanitizeRendered on Obsidian output", () => { expect(sanitizeRendered(root)).toEqual({ images: [], embedsDropped: 0, + imagesDropped: 0, mermaidAsSource: 0, }); expect(root.outerHTML).toBe(html); @@ -185,18 +186,19 @@ describe("sanitizeRendered edge cases", () => { 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="]); + ).toEqual(["HTTPS://example.com/x.png", "DATA:IMAGE/png;base64,aGVsbG8="]); }); it("unwraps non-web links without changing allowed external links", () => { @@ -223,6 +225,14 @@ describe("sanitizeRendered edge cases", () => { ).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', diff --git a/packages/plugin/tests/links/shared-links.test.ts b/packages/plugin/tests/links/shared-links.test.ts index 201c0279..63e079d3 100644 --- a/packages/plugin/tests/links/shared-links.test.ts +++ b/packages/plugin/tests/links/shared-links.test.ts @@ -12,7 +12,8 @@ function link(id: string, path: string, extra?: Partial) { id, url: `https://relay.example/s/${id}#key`, path, - title: "Note", + showTitle: true, + detached: false, createdAt: PUBLISHED, publishedAt: PUBLISHED, expires: null, @@ -33,6 +34,25 @@ function store(links: LinkRecord[]) { } 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")]); diff --git a/packages/plugin/tests/settings/transfer-controller.test.ts b/packages/plugin/tests/settings/transfer-controller.test.ts index 41118f00..9d00c713 100644 --- a/packages/plugin/tests/settings/transfer-controller.test.ts +++ b/packages/plugin/tests/settings/transfer-controller.test.ts @@ -91,7 +91,8 @@ describe("settings import", () => { id: "one", url: "https://relay.example/s/one", path: "note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1, publishedAt: 1, expires: null, diff --git a/packages/plugin/tests/storage/link-broker.test.ts b/packages/plugin/tests/storage/link-broker.test.ts index 6732ace5..16a7c93a 100644 --- a/packages/plugin/tests/storage/link-broker.test.ts +++ b/packages/plugin/tests/storage/link-broker.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { - type LinkOptions, + createLink, linkStatus, - putLink, + type NewLink, + replaceLink, revokeLink, } from "@/storage/adapters/link-broker"; import { StorageHttpError } from "@/storage/adapters/util"; @@ -45,13 +46,14 @@ 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: LinkOptions = { +const options: NewLink = { maxViews: null, - expires: null, - protection: null, + ttl: null, + gate: "G".repeat(43), + salt: null, }; const actions = { - put: () => putLink(admin, ID, sealed, options), + put: () => createLink(admin, ID, sealed, options), status: () => linkStatus(admin, ID), revoke: () => revokeLink(admin, ID), }; @@ -65,15 +67,19 @@ 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); - await putLink(admin, ID, bytes, { - maxViews: 5, - expires: 2_000_000_000, - protection: { gate: "gate", salt: "salt" }, - }); + 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&expires=2000000000`, + url: `${URL}?maxViews=5&ttl=3600`, method: "PUT", headers: { "X-Mdsync-Admin": "secret", @@ -87,8 +93,10 @@ describe("the relay's share links", () => { expect(new Uint8Array(requests[0]?.body as ArrayBuffer)).toEqual(sealed); }); - it("omits the query and protection headers when options are null", async () => { - await actions.put(); + 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, @@ -97,21 +105,24 @@ describe("the relay's share links", () => { expect(requests[0]?.headers).toEqual({ "X-Mdsync-Admin": "secret", "Content-Type": "application/octet-stream", + "X-Mdsync-Gate": options.gate, }); }); - it("updates a standing link with its new options", async () => { - await putLink(admin, ID, sealed, { ...options, maxViews: 10 }, true); + it("replaces a standing link using only its gate", async () => { + await replaceLink(admin, ID, sealed, options.gate); expect(requests[0]).toMatchObject({ - url: `${URL}?maxViews=10&update=1`, + 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 putLink(admin, ID, sealed, options, true); + await replaceLink(admin, ID, sealed, options.gate); + expect(requests[0]?.headers).not.toHaveProperty("X-Mdsync-Salt"); expect(requests[0]?.url).toBe(`${URL}?update=1`); }); @@ -155,12 +166,26 @@ describe("the relay's share links", () => { await expect(refused).rejects.toBeInstanceOf(StorageRequestError); await expect(refused).rejects.toMatchObject({ userMessage: - "This relay does not support share links yet. Redeploy it with the Deploy Relay workflow.", + "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}' }, @@ -202,11 +227,20 @@ describe("the relay's share links", () => { }); }); + 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( - putLink(admin, ID, sealed, options, true), + replaceLink(admin, ID, sealed, options.gate), ).rejects.toMatchObject({ userMessage: "This link has ended. Create a new one.", }); @@ -228,7 +262,8 @@ describe("the relay's share links", () => { }); it("strips the relay URL's trailing slash", async () => { - await putLink( + replies = [{ status: 200, text: '{"expires":null}' }]; + await createLink( { ...admin, relayUrl: `${admin.relayUrl}/` }, ID, sealed, diff --git a/packages/plugin/tests/ui/link-actions.test.ts b/packages/plugin/tests/ui/link-actions.test.ts index 3920fe5b..d5ba9506 100644 --- a/packages/plugin/tests/ui/link-actions.test.ts +++ b/packages/plugin/tests/ui/link-actions.test.ts @@ -5,7 +5,7 @@ import { registerCommands } from "@/commands"; import { type LinkRecord, SharedLinks, updateLink } from "@/links"; import type { PluginHost } from "@/plugin/host"; import { notifyError, notifyInfo } from "@/ui/common"; -import { updateSharedLink } from "@/ui/links/link-actions"; +import { updateSharedLinks } from "@/ui/links/link-actions"; import { openPromptModal } from "@/ui/modals"; vi.mock("@/links", async (original) => ({ @@ -40,7 +40,8 @@ const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", path: "note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1, publishedAt: 1, expires: null, @@ -83,17 +84,23 @@ function host(records: LinkRecord[] = [RECORD]) { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(updateLink).mockResolvedValue(undefined); + vi.mocked(updateLink).mockReset().mockResolvedValue(undefined); vi.mocked(openPromptModal).mockResolvedValue("passphrase"); }); -describe("updateSharedLink", () => { +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 updateSharedLink(plugin, record); + await updateSharedLinks(plugin, [record]); expect(openPromptModal).not.toHaveBeenCalled(); - expect(updateLink).toHaveBeenCalledWith(plugin, record, null); + 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.", ); @@ -102,18 +109,82 @@ describe("updateSharedLink", () => { it("asks for a protected link's passphrase and skips a cancelled prompt", async () => { const { plugin } = host(); const record = { ...RECORD, salt: "salt" }; - await updateSharedLink(plugin, record); + await updateSharedLinks(plugin, [record]); expect(openPromptModal).toHaveBeenCalledWith( expect.objectContaining({ title: "Passphrase", confirmLabel: "Update" }), ); - expect(updateLink).toHaveBeenCalledWith(plugin, record, "passphrase"); + expect(updateLink).toHaveBeenCalledWith( + plugin, + record, + "passphrase", + expect.any(Map), + ); vi.mocked(updateLink).mockClear(); vi.mocked(notifyInfo).mockClear(); vi.mocked(openPromptModal).mockResolvedValueOnce(null); - await updateSharedLink(plugin, record); + 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", () => { diff --git a/packages/plugin/tests/ui/manage-links-modal.test.ts b/packages/plugin/tests/ui/manage-links-modal.test.ts index 0f80591b..862ac74d 100644 --- a/packages/plugin/tests/ui/manage-links-modal.test.ts +++ b/packages/plugin/tests/ui/manage-links-modal.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type LinkRecord, linkStatusOf, SharedLinks } from "@/links"; import type { PluginHost } from "@/plugin/host"; -import { updateSharedLink } from "@/ui/links/link-actions"; +import { updateSharedLinks } from "@/ui/links/link-actions"; import { ManageLinksModal } from "@/ui/links/manage-links-modal"; vi.mock("@/links", async (original) => ({ @@ -11,7 +11,7 @@ vi.mock("@/links", async (original) => ({ linkStatusOf: vi.fn(async () => null), })); vi.mock("@/ui/links/link-actions", () => ({ - updateSharedLink: vi.fn(async () => {}), + updateSharedLinks: vi.fn(async () => {}), })); vi.mock("@/ui/modals", () => ({ openConfirmModal: vi.fn() })); vi.mock("@/ui/common", async () => ({ @@ -87,7 +87,8 @@ const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", path: "note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1, publishedAt: 1, expires: null, @@ -103,7 +104,11 @@ const LIVE: LinkStatus = { size: 1, }; -function host(mtime: number | null, records = [RECORD]) { +function host( + mtime: number | null, + records = [RECORD], + path: string | null = RECORD.path, +) { const settings = { links: records, relayUrl: "https://relay.example", @@ -121,7 +126,7 @@ function host(mtime: number | null, records = [RECORD]) { vault: { getFileByPath: () => note, on: () => ({}), offref: () => {} }, }, } as unknown as PluginHost; - new ManageLinksModal(plugin, RECORD.path).onOpen(); + new ManageLinksModal(plugin, path ?? undefined).onOpen(); return { plugin, sharedLinks, note }; } @@ -136,6 +141,26 @@ afterEach(() => { }); 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); @@ -151,10 +176,23 @@ describe("ManageLinksModal", () => { expect(rows[0]?.desc).toBe("Checking…"); }); - it("uses the shared update flow", async () => { - const { plugin } = host(2); + 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(updateSharedLink).toHaveBeenCalledWith(plugin, RECORD); + expect(updateSharedLinks).toHaveBeenCalledExactlyOnceWith(plugin, [RECORD]); + expect(linkStatusOf).toHaveBeenCalledExactlyOnceWith(plugin, RECORD); }); it("turns a link that expires while open into a remove-only row", async () => { @@ -179,10 +217,12 @@ describe("ManageLinksModal", () => { await sharedLinks.move(RECORD.path, "moved.md"); expect(rows).toHaveLength(1); await rows[0]?.buttons.find(({ text }) => text === "Update")?.click(); - expect(updateSharedLink).toHaveBeenCalledWith(plugin, { - ...RECORD, - path: "moved.md", - }); + expect(updateSharedLinks).toHaveBeenCalledWith(plugin, [ + { + ...RECORD, + path: "moved.md", + }, + ]); }); it("removes an ended link through SharedLinks", async () => { diff --git a/packages/plugin/tests/ui/note-link-action.test.ts b/packages/plugin/tests/ui/note-link-action.test.ts index 7ce32d35..ae753a3a 100644 --- a/packages/plugin/tests/ui/note-link-action.test.ts +++ b/packages/plugin/tests/ui/note-link-action.test.ts @@ -12,7 +12,8 @@ const RECORD: LinkRecord = { id: "one", url: "https://relay.example/one", path: "note.md", - title: "Note", + showTitle: true, + detached: false, createdAt: 1, publishedAt: 1, expires: null, @@ -55,8 +56,8 @@ function host() { addAction, }); const leaves = [{ view }]; - const events = new Map void>(); - const on = (name: string, callback: () => void) => { + const events = new Map void>(); + const on = (name: string, callback: (file?: { path: string }) => void) => { events.set(name, callback); return {}; }; @@ -70,8 +71,9 @@ function host() { sharedLinks, app: { workspace: { - iterateAllLeaves: (visit: (leaf: object) => void) => + iterateAllLeaves: vi.fn((visit: (leaf: object) => void) => leaves.forEach(visit), + ), on, onLayoutReady: (ready: () => void) => ready(), }, @@ -101,7 +103,7 @@ describe("a note's share link action", () => { 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")?.(); + 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); @@ -113,6 +115,14 @@ describe("a note's share link action", () => { 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" }); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index cba31814..e9fc1f6d 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -3,6 +3,7 @@ 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 index 698c532b..c32a1a08 100644 --- a/packages/protocol/src/link.ts +++ b/packages/protocol/src/link.ts @@ -58,6 +58,9 @@ export interface LinkStatus { 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; @@ -65,8 +68,8 @@ export interface LinkProtection { export interface LinkKeys { content: CryptoKey; - /** What the relay checks before it serves a protected link; null when unprotected. */ - gate: string | null; + /** 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 { @@ -87,7 +90,7 @@ export function newLinkSalt(): Uint8Array { /** * 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. + * the fragment the relay cannot test a passphrase guess, even offline, and an id alone opens nothing. */ export async function deriveLinkKeys( key: Uint8Array, @@ -108,7 +111,6 @@ export async function deriveLinkKeys( false, ["encrypt", "decrypt"], ); - if (!protection) return { content, gate: null }; const gate = await subtle.deriveBits( hkdf(GATE_INFO), base, @@ -222,10 +224,10 @@ async function stretch({ passphrase, salt, }: LinkProtection): Promise { - // One passphrase typed on two systems may differ in Unicode form. + // One passphrase typed on two systems may differ in Unicode form or stray spaces from pasting. const base = await subtle.importKey( "raw", - utf8(passphrase.normalize("NFC")), + utf8(passphrase.trim().normalize("NFC")), "PBKDF2", false, ["deriveBits"], diff --git a/packages/protocol/tests/link.test.ts b/packages/protocol/tests/link.test.ts index c6c0d65e..14721b4e 100644 --- a/packages/protocol/tests/link.test.ts +++ b/packages/protocol/tests/link.test.ts @@ -5,6 +5,8 @@ import { fromBase64Url, isLinkId, LINK_GATE_PATTERN, + LINK_HREF_ALLOWED, + LINK_IMAGE_SRC_ALLOWED, LINK_KEY_BYTES, type LinkPayload, linkUrl, @@ -38,6 +40,33 @@ describe("base64url", () => { }); }); +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); @@ -142,8 +171,25 @@ describe("passphrase", () => { for (const other of gates) expect(other.gate).not.toBe(a.gate); }); - it("has no gate without a passphrase", async () => { - expect((await deriveLinkKeys(newLinkKey())).gate).toBeNull(); + 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 () => { diff --git a/packages/relay/src/link/client.ts b/packages/relay/src/link/client.ts index 431af881..5e5542ac 100644 --- a/packages/relay/src/link/client.ts +++ b/packages/relay/src/link/client.ts @@ -7,18 +7,37 @@ 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().split("::"); + 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); - return [...front, ...Array(gap).fill("0"), ...back] - .slice(0, IPV6_PREFIX_GROUPS) - .map((group) => group.replace(/^0+(?=.)/, "")) - .join(":"); + 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 index e29a52ab..03e3d19f 100644 --- a/packages/relay/src/link/durable-object.ts +++ b/packages/relay/src/link/durable-object.ts @@ -1,13 +1,12 @@ /** One share link: its sealed blob, view counter and gate. See store.ts for the rules. */ import { DurableObject } from "cloudflare:workers"; -import type { LinkMeta, LinkStatus } from "@mdsync/protocol"; +import type { LinkMeta, LinkPutMode, LinkStatus } from "@mdsync/protocol"; import { type LinkSettings, LinkStore, type OpenResult, - type PutMode, type PutResult, } from "./store"; import type { LinkEnv } from "./stub"; @@ -20,10 +19,10 @@ export class Link extends DurableObject { async put( blob: ArrayBuffer, settings: LinkSettings, - mode: PutMode, + mode: LinkPutMode, ): Promise { const result = this.store.put(blob, settings, mode); - if (result !== "stored") return result; + 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; diff --git a/packages/relay/src/link/routes.ts b/packages/relay/src/link/routes.ts index fc4f9488..e456530a 100644 --- a/packages/relay/src/link/routes.ts +++ b/packages/relay/src/link/routes.ts @@ -29,8 +29,8 @@ export interface LinkRouteEnv extends SecretEnv, LinkEnv {} const ROUTE = /^\/link\/([^/]+)(?:\/(meta|status|open))?$/; const SALT_PATTERN = /^[A-Za-z0-9_-]{22}$/; -/** Longer than any real gate; the cap only keeps a hostile body from being hashed. */ -const MAX_PRESENTED_GATE = 64; +/** 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. */ @@ -71,8 +71,8 @@ async function storeLink( url: URL, ): Promise { if (!(await isAdmin(request, env))) return adminUnauthorized(); - const settings = await readSettings(request, url); - if (typeof settings === "string") return badRequest(settings); + 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(); } @@ -80,14 +80,26 @@ async function storeLink( 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"), ); } - return fresh(json({ stored: true, size: blob.byteLength })); + // 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( @@ -121,9 +133,9 @@ async function openLink( env: LinkRouteEnv, id: string, ): Promise { - const body = await readJsonObject(request); + const body = await readJsonObject(request, MAX_OPEN_BODY_BYTES); const presented = - typeof body?.gate === "string" && body.gate.length <= MAX_PRESENTED_GATE + typeof body?.gate === "string" && LINK_GATE_PATTERN.test(body.gate) ? await fingerprint(body.gate) : null; const result = await linkStub(env, id).open( @@ -144,48 +156,47 @@ async function openLink( return new Response(result.blob, { headers }); } if (result.reason === "gone") return gone(); - const response = jsonError( - result.reason === "gate" ? 401 : 429, - result.reason, + const response = result.reason === "gate" - ? "Wrong passphrase." - : "Too many wrong passphrases.", - ); + ? 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); } -/** A string is the reason the request is refused. */ +/** 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 { - const now = Math.floor(Date.now() / 1000); +): Promise< + | { ok: true; settings: Omit; ttl: number | null } + | { ok: false; reason: string } +> { const maxViews = optionalInt( url.searchParams.get("maxViews"), 1, LINK_MAX_VIEWS, ); - const expires = optionalInt( - url.searchParams.get("expires"), - now + 1, - now + LINK_MAX_TTL_S, - ); - if (maxViews === "invalid") return "Invalid maxViews"; - if (expires === "invalid") return "Invalid expires"; + 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 ((gate === null) !== (salt === null)) - return "A passphrase needs a gate and a salt"; - if (gate !== null && salt !== null) { - if (!LINK_GATE_PATTERN.test(gate) || !SALT_PATTERN.test(salt)) { - return "Invalid gate or salt"; - } - return { maxViews, expires, gate: await fingerprint(gate), 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"); } - return { maxViews, expires, gate: null, salt: null }; + 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( @@ -200,6 +211,10 @@ function optionalInt( : "invalid"; } +function wrongGate(status = 403): Response { + return jsonError(status, "gate", "Wrong passphrase."); +} + function gone(): Response { return fresh(jsonError(404, "gone", GONE_TEXT)); } diff --git a/packages/relay/src/link/store.ts b/packages/relay/src/link/store.ts index f402d0fd..b6259e8a 100644 --- a/packages/relay/src/link/store.ts +++ b/packages/relay/src/link/store.ts @@ -7,6 +7,7 @@ import { LINK_MAX_SEALED_BYTES, type LinkMeta, + type LinkPutMode, type LinkStatus, } from "@mdsync/protocol"; @@ -24,8 +25,9 @@ export interface LinkSettings { maxViews: number | null; /** Unix seconds. */ expires: number | null; - /** Hash of the gate; null when the link has no passphrase. */ - gate: string | 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; } @@ -40,9 +42,7 @@ export type OpenResult = | { ok: false; reason: "gate"; retryAfter: number | null } | { ok: false; reason: "cooldown"; retryAfter: number }; -/** A creation never inherits a counter; only an update, which needs a link still standing, keeps one. */ -export type PutMode = "create" | "update"; -export type PutResult = "stored" | "exists" | "gone"; +export type PutResult = "stored" | "exists" | "gone" | "mismatch"; interface Row extends LinkSettings { views: number; @@ -62,7 +62,11 @@ export class LinkStore { private readonly onEnd: () => void = () => undefined, ) {} - put(blob: ArrayBuffer, settings: LinkSettings, mode: PutMode): PutResult { + /** + * 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"); } @@ -70,19 +74,15 @@ export class LinkStore { const old = this.row(); const standing = old !== null && this.stands(old); if (mode === "create" && standing) return "exists"; - if (mode === "update" && !standing) { - this.live(); - return "gone"; + if (mode === "update") { + if (!old || !standing) { + this.live(); + return "gone"; + } + if (!sameHash(old.gate, settings.gate)) return "mismatch"; + } else { + this.createTables(); } - 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, 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", - ); this.sql.exec("DELETE FROM chunks"); this.sql.exec("DELETE FROM throttle"); for (let at = 0, seq = 0; at < blob.byteLength; at += CHUNK_BYTES) { @@ -92,9 +92,12 @@ export class LinkStore { 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, ?, ?, ?, ?, ?, ?)", - standing && old ? old.views : 0, + "INSERT OR REPLACE INTO link(id, views, max_views, expires, gate, salt, size) VALUES(1, 0, ?, ?, ?, ?, ?)", settings.maxViews, settings.expires, settings.gate, @@ -121,7 +124,7 @@ export class LinkStore { retryAfter: Math.ceil((throttle.lockedUntil - now) / 1000), }; } - if (link.gate !== null && !(gate !== null && sameHash(link.gate, gate))) { + if (gate === null || !sameHash(link.gate, gate)) { return this.refuse(client, throttle, now); } const blob = this.read(link.size); @@ -145,7 +148,7 @@ export class LinkStore { views: link.views, maxViews: link.maxViews, expires: link.expires, - protected: link.gate !== null, + protected: link.salt !== null, size: link.size, } ); @@ -153,7 +156,7 @@ export class LinkStore { meta(): LinkMeta | null { const link = this.live(); - return link && { protected: link.gate !== null, salt: link.salt }; + return link && { protected: link.salt !== null, salt: link.salt }; } /** Ends the link now, whatever its state. */ @@ -218,7 +221,7 @@ export class LinkStore { views: Number(row.views), maxViews: nullable(row.max_views), expires: nullable(row.expires), - gate: typeof row.gate === "string" ? row.gate : null, + gate: String(row.gate), salt: typeof row.salt === "string" ? row.salt : null, size: Number(row.size), }; @@ -237,6 +240,18 @@ export class LinkStore { 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 ( 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 index 8c8faffb..791e39bd 100644 --- a/packages/relay/tests/link/client.test.ts +++ b/packages/relay/tests/link/client.test.ts @@ -12,6 +12,24 @@ describe("networkOf", () => { 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); @@ -30,10 +48,23 @@ describe("clientOf", () => { 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"); - expect(await clientOf(request())).toMatch(/^[0-9a-f]{16}$/); + 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 index f3eb1000..73a24a40 100644 --- a/packages/relay/tests/link/routes.test.ts +++ b/packages/relay/tests/link/routes.test.ts @@ -3,16 +3,13 @@ import { 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, - type PutMode, -} from "../../src/link/store"; +import { type LinkSettings, LinkStore } from "../../src/link/store"; import { fingerprint } from "../../src/secret"; import { memorySql } from "../helpers/memory-sql"; @@ -37,8 +34,11 @@ function makeEnv(): LinkRouteEnv { const LINK = { idFromName: (name: string) => name, get: (id: string) => ({ - put: async (blob: ArrayBuffer, settings: LinkSettings, mode: PutMode) => - storeOf(id).put(blob, settings, mode), + 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(), @@ -76,7 +76,7 @@ function put( return call(env, `/link/${id}${query}`, { method: "PUT", admin: true, - headers, + headers: { [LINK_HEADERS.gate]: GATE, ...headers }, body, }); } @@ -84,13 +84,13 @@ function put( const open = ( env: LinkRouteEnv, id: string, - gate?: string, + gate: string | null = GATE, ip = "203.0.113.7", ) => call(env, `/link/${id}/open`, { method: "POST", headers: { "CF-Connecting-IP": ip }, - body: gate === undefined ? undefined : JSON.stringify({ gate }), + body: gate === null ? undefined : JSON.stringify({ gate }), }); const protectedHeaders = { @@ -160,12 +160,41 @@ describe("owner routes", () => { 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&expires=${expires}`); - expect(await stored.json()).toEqual({ stored: true, size: BLOB.length }); + 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({ @@ -208,16 +237,23 @@ describe("owner routes", () => { it("update a link in place and keep its views", async () => { const env = makeEnv(); const id = newLinkId(); - await put(env, id, "?maxViews=5"); + await put(env, id, "?maxViews=5&ttl=3600"); await open(env, id); const updated = await put( env, id, - "?update=1&maxViews=5", + "?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]), @@ -227,6 +263,23 @@ describe("owner routes", () => { }); 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(); @@ -248,9 +301,9 @@ describe("storing validates", () => { ["a view limit above the cap", `?maxViews=${LINK_MAX_VIEWS + 1}`, {}], ["a fractional view limit", "?maxViews=1.5", {}], ["a text view limit", "?maxViews=many", {}], - ["an expiry in the past", `?expires=${NOW_S - 1}`, {}], - ["an expiry past the cap", `?expires=${NOW_S + LINK_MAX_TTL_S + 5}`, {}], - ["a gate without a salt", "", { [LINK_HEADERS.gate]: GATE }], + ["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", @@ -267,7 +320,19 @@ describe("storing validates", () => { it.each(reasons)("refuses %s", async (_name, query, headers) => { const env = makeEnv(); const id = newLinkId(); - expect((await put(env, id, query, headers)).status).toBe(400); + 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); }); @@ -293,11 +358,39 @@ describe("storing validates", () => { }); 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&expires=${expires}`); + 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"); @@ -324,7 +417,7 @@ describe("opening", () => { const expiring = newLinkId(); await put(env, spent, "?maxViews=1"); await open(env, spent); - await put(env, expiring, `?expires=${Math.floor(Date.now() / 1000) + 60}`); + await put(env, expiring, "?ttl=60"); vi.setSystemTime(Date.now() + 61_000); const answers = await Promise.all( [spent, expiring, newLinkId()].map(async (id) => { @@ -380,7 +473,7 @@ describe("passphrase", () => { 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)).status).toBe(401); + 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); }); diff --git a/packages/relay/tests/link/store.test.ts b/packages/relay/tests/link/store.test.ts index 950fb043..d6896461 100644 --- a/packages/relay/tests/link/store.test.ts +++ b/packages/relay/tests/link/store.test.ts @@ -7,7 +7,7 @@ import { memorySql } from "../helpers/memory-sql"; const OPEN: LinkSettings = { maxViews: null, expires: null, - gate: null, + gate: "ef".repeat(32), salt: null, }; const PROTECTED: LinkSettings = { ...OPEN, gate: "ab".repeat(32), salt: "s" }; @@ -40,7 +40,7 @@ function served(result: ReturnType): Uint8Array { return new Uint8Array(result.blob); } -const open = (store: LinkStore, gate: string | null = null, client = ME) => +const open = (store: LinkStore, gate: string | null = OPEN.gate, client = ME) => store.open(gate, client); describe("storing and serving", () => { @@ -117,10 +117,20 @@ describe("expiry", () => { }); 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)).toMatchObject({ ok: false, reason: "gate" }); + expect(open(store, null)).toMatchObject({ ok: false, reason: "gate" }); expect(open(store, RIGHT).ok).toBe(true); }); @@ -134,7 +144,10 @@ describe("passphrase gate", () => { 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(setup().store.meta()).toEqual({ protected: false, salt: null }); + 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", () => { @@ -214,23 +227,24 @@ describe("passphrase gate", () => { }); describe("update", () => { - it("replaces the blob and keeps the counter", () => { - const { store } = setup({ ...OPEN, maxViews: 5 }); + 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: 5 }, "update")).toBe( + expect(store.put(blob(50, 9), { ...OPEN, maxViews: 1 }, "update")).toBe( "stored", ); - expect(store.status()?.views).toBe(2); + expect(store.status()).toMatchObject({ views: 2, maxViews: 5, expires }); expect(served(open(store))).toEqual(new Uint8Array(blob(50, 9))); }); - it("leaves a link already past a lowered limit spent", () => { + 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)).toEqual({ ok: false, reason: "gone" }); + expect(open(store)).toMatchObject({ ok: true, viewsLeft: 2 }); }); it("never brings back a link that is spent, expired or revoked", () => { @@ -260,12 +274,21 @@ describe("update", () => { expect(open(store, RIGHT).ok).toBe(true); }); - it("can add or drop the passphrase", () => { - const { store } = setup(); - store.put(blob(10, 3), PROTECTED, "update"); - expect(store.meta()?.protected).toBe(true); - store.put(blob(10, 3), OPEN, "update"); - expect(store.meta()?.protected).toBe(false); + 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" }); }); }); diff --git a/packages/viewer/src/api.ts b/packages/viewer/src/api.ts index 345b9eb3..f6fad30f 100644 --- a/packages/viewer/src/api.ts +++ b/packages/viewer/src/api.ts @@ -17,7 +17,7 @@ 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 | null): Promise; + open(id: string, gate: string): Promise; } export function createApi(fetcher: typeof fetch = fetch): LinkApi { @@ -32,7 +32,7 @@ export function createApi(fetcher: typeof fetch = fetch): LinkApi { const response = await fetcher(`/link/${id}/open`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(gate === null ? {} : { gate }), + body: JSON.stringify({ gate }), }); if (response.ok) { const left = response.headers.get(LINK_HEADERS.viewsLeft); diff --git a/packages/viewer/src/cache.ts b/packages/viewer/src/cache.ts index 7f3e1309..3842b763 100644 --- a/packages/viewer/src/cache.ts +++ b/packages/viewer/src/cache.ts @@ -31,7 +31,6 @@ export function sessionCache( return { ...stored, sealed: fromBase64Url(stored.sealed), - expires: stored.expires ?? null, }; } catch { return null; diff --git a/packages/viewer/src/keys.ts b/packages/viewer/src/keys.ts index f9674a96..5ff27c50 100644 --- a/packages/viewer/src/keys.ts +++ b/packages/viewer/src/keys.ts @@ -1,6 +1,7 @@ import type { LinkKeys } from "@mdsync/protocol"; import { createStore, + del, get, promisifyRequest, set, @@ -14,8 +15,7 @@ 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; - /** With `stale`, only while those are still the kept ones: another tab may have kept newer. */ - drop(id: string, stale?: LinkKeys): Promise; + drop(id: string): Promise; } interface Entry { @@ -36,17 +36,9 @@ export function browserKeys(): RememberedKeys { } return store; }; - const drop = async (id: string, stale?: LinkKeys) => { + const drop = async (id: string) => { try { - // Read and delete in one transaction, so a newer entry kept meanwhile survives. - await db()("readwrite", (keys) => { - const kept = keys.get(id); - kept.onsuccess = () => { - const entry = kept.result as Entry | undefined; - if (!stale || entry?.keys.gate === stale.gate) keys.delete(id); - }; - return promisifyRequest(keys.transaction); - }); + await del(id, db()); } catch { // Nothing was kept. } diff --git a/packages/viewer/src/sanitize.ts b/packages/viewer/src/sanitize.ts index 8774be6a..673eb8e7 100644 --- a/packages/viewer/src/sanitize.ts +++ b/packages/viewer/src/sanitize.ts @@ -1,25 +1,29 @@ +import { LINK_HREF_ALLOWED, LINK_IMAGE_SRC_ALLOWED } from "@mdsync/protocol"; import DOMPurify from "dompurify"; -/** Anything else is a path into the owner's vault or the relay, and leads nowhere here. */ -const KEPT_LINK = /^(https?:|mailto:|#)/i; -const KEPT_IMAGE = /^(data:image\/|https:\/\/)/i; - const purify = DOMPurify(window); purify.addHook("afterSanitizeAttributes", (node) => { - if (node instanceof HTMLAnchorElement) { - const href = node.getAttribute("href"); - if (href !== null && !KEPT_LINK.test(href)) node.removeAttribute("href"); - if (/^https?:/i.test(href ?? "")) { - node.setAttribute("target", "_blank"); - node.setAttribute("rel", "noopener noreferrer nofollow"); + 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 (!KEPT_IMAGE.test(node.getAttribute("src") ?? "")) node.remove(); + 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", ""); } - if (node instanceof HTMLInputElement) node.setAttribute("disabled", ""); }); /** The note's HTML as a fragment safe to attach: no script, no handlers, no way out of the page. */ diff --git a/packages/viewer/src/session.ts b/packages/viewer/src/session.ts index 24eae84a..d05bd0d8 100644 --- a/packages/viewer/src/session.ts +++ b/packages/viewer/src/session.ts @@ -40,10 +40,10 @@ type Unlock = "once" | "remember" | "remembered"; const BROKEN = "This link is damaged: its address may have been cut short."; const WRONG = "Wrong passphrase."; -const CHANGED = "The passphrase has changed. Type the new one."; export class LinkSession { private meta: LinkMeta | null = null; + private cached: CachedLink | null = null; constructor(private readonly deps: SessionDeps) {} @@ -51,7 +51,8 @@ export class LinkSession { const { id, key, api, cache, keys, show } = this.deps; show({ kind: "loading" }); await this.guarded(async () => { - this.meta = cache.get(id) ?? (await api.meta(id)); + this.cached = cache.get(id); + this.meta = this.cached ?? (await api.meta(id)); if (!this.meta) { await keys.drop(id); return show({ kind: "gone" }); @@ -81,9 +82,9 @@ export class LinkSession { } private async open(keys: LinkKeys, unlock: Unlock): Promise { - const { id, cache, show } = this.deps; + const { show } = this.deps; // A reload costs no view; a wrong passphrase is only known by failing to open. - const cached = cache.get(id); + const cached = this.cached; const entry = cached ?? (await this.fetch(keys, unlock)); if (!entry) return; const payload = await this.read(entry.sealed, keys.content); @@ -91,12 +92,7 @@ export class LinkSession { if (!cached) return show({ kind: "error", message: BROKEN }); if (!this.meta?.protected) return show({ kind: "error", message: BROKEN }); - // This tab's copy may predate a new passphrase that the kept keys already have. - return show( - unlock === "remembered" - ? { kind: "passphrase" } - : { kind: "passphrase", problem: WRONG }, - ); + return show({ kind: "passphrase", problem: WRONG }); } await this.keep(keys, unlock, entry.expires); show({ @@ -120,14 +116,18 @@ export class LinkSession { return null; } if (outcome.kind === "gate" || outcome.kind === "cooldown") { - // Refused kept keys are stale: the owner set another passphrase. + // 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, keys); + await this.deps.keys.drop(id); } - const problem = unlock === "remembered" ? CHANGED : WRONG; show({ kind: "passphrase", - problem: outcome.retryAfter ? waitMessage(outcome.retryAfter) : problem, + problem: outcome.retryAfter ? waitMessage(outcome.retryAfter) : WRONG, }); return null; } @@ -140,6 +140,7 @@ export class LinkSession { expires: outcome.expires, }; cache.put(id, entry); + this.cached = entry; return entry; } diff --git a/packages/viewer/src/view.ts b/packages/viewer/src/view.ts index e5f2bf76..8b50e2d9 100644 --- a/packages/viewer/src/view.ts +++ b/packages/viewer/src/view.ts @@ -41,7 +41,7 @@ export function createView(root: HTMLElement, handlers: ViewHandlers): View { ); // A wrong passphrase draws the form again: the reader's choice must survive it. - let rememberChoice = true; + let rememberChoice = false; function passphraseForm(problem?: string): HTMLElement { const input = el("input", { diff --git a/packages/viewer/tests/api.test.ts b/packages/viewer/tests/api.test.ts index 1b647a7f..c4890d3b 100644 --- a/packages/viewer/tests/api.test.ts +++ b/packages/viewer/tests/api.test.ts @@ -36,7 +36,7 @@ describe("open", () => { }, }), ); - expect(await api.open("id", null)).toEqual({ + expect(await api.open("id", "G".repeat(43))).toEqual({ kind: "opened", sealed: Uint8Array.from([1, 2, 3]), viewsLeft: 2, @@ -46,7 +46,7 @@ describe("open", () => { it("leaves the count out for an unlimited link", async () => { const api = createApi(reply(200, { body: Uint8Array.from([1]) })); - expect(await api.open("id", null)).toMatchObject({ + expect(await api.open("id", "G".repeat(43))).toMatchObject({ viewsLeft: null, expires: null, }); @@ -58,7 +58,9 @@ describe("open", () => { const api = createApi( reply(200, { headers: { "X-Mdsync-Expires": expires } }), ); - expect(await api.open("id", null)).toMatchObject({ expires: null }); + expect(await api.open("id", "G".repeat(43))).toMatchObject({ + expires: null, + }); }, ); @@ -70,12 +72,12 @@ describe("open", () => { }) as typeof fetch); await api.open("id", "G".repeat(43)); expect(JSON.parse(sent)).toEqual({ gate: "G".repeat(43) }); - await api.open("id", null); - expect(JSON.parse(sent)).toEqual({}); + 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", null)).toEqual({ + expect(await createApi(reply(404)).open("id", "G".repeat(43))).toEqual({ kind: "gone", }); expect(await createApi(reply(401)).open("id", "g")).toEqual({ @@ -107,6 +109,8 @@ describe("open", () => { }); it("throws for any other answer", async () => { - await expect(createApi(reply(502)).open("id", null)).rejects.toThrow("502"); + 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 index c82a2401..f80af2a1 100644 --- a/packages/viewer/tests/cache.test.ts +++ b/packages/viewer/tests/cache.test.ts @@ -18,10 +18,11 @@ afterEach(() => { }); describe("session cache", () => { - it("gives back what it stored", () => { + it.each([link.expires, null])("round-trips an expiry of %s", (expires) => { const cache = sessionCache(); - cache.put("id", link); - expect(cache.get("id")).toEqual(link); + const entry = { ...link, expires }; + cache.put("id", entry); + expect(cache.get("id")).toEqual(entry); }); it("works without a cache when the browser blocks storage", () => { diff --git a/packages/viewer/tests/keys.test.ts b/packages/viewer/tests/keys.test.ts index 8b199e72..44fd145e 100644 --- a/packages/viewer/tests/keys.test.ts +++ b/packages/viewer/tests/keys.test.ts @@ -31,14 +31,12 @@ describe("browser keys", () => { expect(await store.get("a")).toBeNull(); }); - it("drops refused keys only while they are still the kept ones", async () => { + it("drops keys by id and tolerates repeated drops", async () => { const store = browserKeys(); - const old = await protectedKeys(); - const newer = await protectedKeys(); - await store.put("c", newer, null); - await store.drop("c", old); - expect((await store.get("c"))?.gate).toBe(newer.gate); - await store.drop("c", newer); + 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(); }); diff --git a/packages/viewer/tests/sanitize.test.ts b/packages/viewer/tests/sanitize.test.ts index 3ccc1876..0d1abf2b 100644 --- a/packages/viewer/tests/sanitize.test.ts +++ b/packages/viewer/tests/sanitize.test.ts @@ -38,6 +38,42 @@ describe("sanitizeNote", () => { 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( '', @@ -74,6 +110,17 @@ describe("sanitizeNote", () => { 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

', diff --git a/packages/viewer/tests/session.test.ts b/packages/viewer/tests/session.test.ts index 07f1684f..c1935356 100644 --- a/packages/viewer/tests/session.test.ts +++ b/packages/viewer/tests/session.test.ts @@ -9,7 +9,7 @@ import { sealLinkPayload, toBase64Url, } from "@mdsync/protocol"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { LinkApi, OpenOutcome } from "../src/api"; import { type CachedLink, type LinkCache, sessionCache } from "../src/cache"; @@ -39,9 +39,9 @@ async function link(passphrase?: string) { function fakeApi( meta: LinkMeta | null, - open: (gate: string | null) => OpenOutcome, -): LinkApi & { opens: (string | null)[]; metas: number } { - const calls = { opens: [] as (string | null)[], metas: 0 }; + open: (gate: string) => OpenOutcome, +): LinkApi & { opens: string[]; metas: number } { + const calls = { opens: [] as string[], metas: 0 }; return { get opens() { return calls.opens; @@ -77,10 +77,7 @@ function memoryKeys(): RememberedKeys & { stored, get: async (id) => stored.get(id)?.keys ?? null, put: async (id, keys, expires) => void stored.set(id, { keys, expires }), - drop: async (id, stale) => { - if (stale && stored.get(id)?.keys.gate !== stale.gate) return; - stored.delete(id); - }, + drop: async (id) => void stored.delete(id), }; } @@ -104,6 +101,34 @@ function harness(deps: { } 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; @@ -114,9 +139,11 @@ describe("an open link", () => { 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([null]); + expect(api.opens).toEqual([(await deriveLinkKeys(key)).gate]); expect(cache.get(id)).toEqual({ ...meta, sealed, viewsLeft: 4, expires }); }); @@ -131,8 +158,10 @@ describe("an open link", () => { })); 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, @@ -158,11 +187,16 @@ describe("session storage cache", () => { expect(sessionCache().get(id)).toEqual(entry); }); - it("reloads an older cached entry without an expiry or another view", async () => { + 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 }), + JSON.stringify({ + ...meta, + sealed: toBase64Url(sealed), + viewsLeft: 0, + expires: null, + }), ); const cache = sessionCache(); expect(cache.get(id)?.expires).toBeNull(); @@ -267,6 +301,7 @@ describe("a protected link", () => { 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" }); @@ -282,6 +317,7 @@ describe("a protected link", () => { viewsLeft: 2, expires: null, }); + expect(get).toHaveBeenCalledExactlyOnceWith(id); expect(api.opens).toHaveLength(1); }); @@ -319,7 +355,7 @@ describe("a protected link", () => { expect(keys.stored.size).toBe(0); }); - it("forgets kept keys the relay refuses, as after a new passphrase", async () => { + 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(); @@ -328,7 +364,7 @@ describe("a protected link", () => { await session.start(); expect(last()).toEqual({ kind: "passphrase", - problem: "The passphrase has changed. Type the new one.", + problem: "Wrong passphrase.", }); expect(keys.stored.size).toBe(0); }); @@ -358,7 +394,10 @@ describe("a protected link", () => { keys: first.keys, }); await reload.session.start(); - expect(reload.last()).toEqual({ kind: "passphrase" }); + expect(reload.last()).toEqual({ + kind: "passphrase", + problem: "Wrong passphrase.", + }); expect(first.keys.stored.get(id)?.keys).toBe(newer); }); diff --git a/packages/viewer/tests/view.test.ts b/packages/viewer/tests/view.test.ts index f8c7d66c..ce805052 100644 --- a/packages/viewer/tests/view.test.ts +++ b/packages/viewer/tests/view.test.ts @@ -85,11 +85,20 @@ describe("states", () => { const input = root.querySelector("input") as HTMLInputElement; input.value = "correct horse"; const form = root.querySelector("form"); - form?.dispatchEvent(new Event("submit", { cancelable: true })); const remember = root.querySelector(".remember input") as HTMLInputElement; - remember.checked = false; + 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); diff --git a/tests/e2e/link-client.ts b/tests/e2e/link-client.ts index bc506597..8c329791 100644 --- a/tests/e2e/link-client.ts +++ b/tests/e2e/link-client.ts @@ -69,9 +69,8 @@ export async function publish( keys.content, ); const query = options.maxViews ? `?maxViews=${options.maxViews}` : ""; - const headers: Record = keys.gate - ? { "X-Mdsync-Gate": keys.gate, "X-Mdsync-Salt": toBase64Url(salt) } - : {}; + 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-viewer.ts b/tests/e2e/links-viewer.ts index 66a12c63..c462f2ba 100644 --- a/tests/e2e/links-viewer.ts +++ b/tests/e2e/links-viewer.ts @@ -266,6 +266,7 @@ async function protectedNote(base: string, browser: Browser): Promise { "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( @@ -292,13 +293,17 @@ async function protectedNote(base: string, browser: Browser): Promise { await context.close(); const other = await visit(browser, link.url, ".passphrase"); - await other.page.locator(".remember input").uncheck(); + 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 when told not to remember", + "another browser asks, and asks again as it was not told to remember", await other.page.locator("h1").textContent(), "This note is protected", ); diff --git a/tests/e2e/links.ts b/tests/e2e/links.ts index 15e09f5c..d41dfa88 100644 --- a/tests/e2e/links.ts +++ b/tests/e2e/links.ts @@ -34,11 +34,13 @@ await runScenario("links e2e", async () => { await scenario(relay.url); await viewerScenario(relay.url); const survivor = await seal(newLinkId(), newLinkKey()); - await put(relay.url, survivor.id, survivor.sealed, "?maxViews=3"); - await open(relay.url, survivor.id); + 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); + 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)], @@ -52,14 +54,15 @@ await runScenario("links e2e", async () => { async function scenario(base: string): Promise { const id = newLinkId(); const key = newLinkKey(); - const { sealed, content } = await seal(id, key); + const { sealed, content, gate } = await seal(id, key); check( "a stored link reports its size", - await (await put(base, id, sealed)).json(), + await (await put(base, id, sealed, "", { "X-Mdsync-Gate": gate })).json(), { stored: true, size: sealed.length, + expires: null, }, ); check( @@ -67,7 +70,7 @@ async function scenario(base: string): Promise { (await fetch(`${base}/link/${id}/status`)).status, 401, ); - const opened = await open(base, id); + const opened = await open(base, id, gate); const blob = new Uint8Array(await opened.arrayBuffer()); check( "the sealed bytes come back whole and open", @@ -85,17 +88,18 @@ async function scenario(base: string): Promise { /** 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 } = await seal(id, newLinkKey()); - await put(base, id, sealed, "?maxViews=1"); + 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)).status), + 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)).status, 404); + 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, @@ -104,25 +108,29 @@ async function oneView(base: string): Promise { check( "an update cannot bring a spent link back", - (await put(base, id, sealed, "?update=1&maxViews=2")).status, + (await put(base, id, sealed, "?update=1&maxViews=2", headers)).status, 404, ); - await put(base, id, sealed, "?maxViews=2"); + await put(base, id, sealed, "?maxViews=2", headers); check( "a new link takes the id of a spent one", - (await open(base, id)).headers.get(VIEWS_LEFT), + (await open(base, id, gate)).headers.get(VIEWS_LEFT), "1", ); check( "a creation never replaces a link that stands", - (await put(base, id, sealed)).status, + (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)).status, 404); - await put(base, id, sealed); - check("and takes a new one again", (await open(base, id)).status, 200); + 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 { @@ -184,18 +192,19 @@ async function passphrase(base: string): Promise { async function update(base: string): Promise { const id = newLinkId(); const key = newLinkKey(); - const { content } = await deriveLinkKeys(key); + const { content, gate } = await deriveLinkKeys(key); const first = await sealLinkPayload(id, payload, content); - await put(base, id, first, "?maxViews=3"); - await open(base, id); + 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=3", + "?update=1&maxViews=1&ttl=60", + { "X-Mdsync-Gate": gate }, ); - const opened = await open(base, id); + const opened = await open(base, id, gate); check( "an update keeps the views already spent", opened.headers.get(VIEWS_LEFT), @@ -211,17 +220,21 @@ async function update(base: string): Promise { async function expiry(base: string): Promise { const id = newLinkId(); - const { sealed } = await seal(id, newLinkKey()); - await put(base, id, sealed, `?expires=${Math.floor(Date.now() / 1000) + 2}`); - check("a link opens before it expires", (await open(base, id)).status, 200); + 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)).status, 404); + 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 } = await deriveLinkKeys(newLinkKey()); + 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); @@ -230,8 +243,8 @@ async function bigNote(base: string): Promise { sealed.length > 3 * 1024 * 1024, true, ); - await put(base, id, sealed); - const blob = new Uint8Array(await (await open(base, id)).arrayBuffer()); + 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, @@ -241,11 +254,18 @@ async function bigNote(base: string): Promise { 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 } = await deriveLinkKeys(key); - return { id, sealed: await sealLinkPayload(id, payload, content), content }; + const { content, gate } = await deriveLinkKeys(key); + return { + id, + sealed: await sealLinkPayload(id, payload, content), + content, + gate, + }; } From 3deb09090826f61d7b4d54bf039b8345a7177175 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Thu, 8 Oct 2026 23:35:41 +0300 Subject: [PATCH 3/4] feat: copy note as Markdown, share page favicon, wide text image centering and switch fix --- .claude/share-links.md | 13 ++- CHANGELOG.md | 2 +- README.md | 2 +- packages/plugin/src/links/math.ts | 2 + packages/plugin/tests/links/math.test.ts | 2 + packages/viewer/package.json | 5 +- packages/viewer/src/clipboard.ts | 21 +++++ packages/viewer/src/code.ts | 14 +-- packages/viewer/src/images.ts | 8 ++ packages/viewer/src/index.html | 6 ++ packages/viewer/src/markdown.ts | 83 ++++++++++++++++++ packages/viewer/src/sidebar.css | 32 ++++--- packages/viewer/src/sidebar.ts | 12 ++- packages/viewer/src/turndown-plugin-gfm.d.ts | 5 ++ packages/viewer/src/view.ts | 17 +++- packages/viewer/src/viewer.css | 5 ++ packages/viewer/tests/images.test.ts | 28 ++++++ packages/viewer/tests/markdown.test.ts | 89 ++++++++++++++++++++ packages/viewer/tests/sidebar.test.ts | 25 +++++- pnpm-lock.yaml | 32 +++++++ tests/e2e/links-viewer.ts | 20 +++++ 21 files changed, 394 insertions(+), 29 deletions(-) create mode 100644 packages/viewer/src/images.ts create mode 100644 packages/viewer/src/markdown.ts create mode 100644 packages/viewer/src/turndown-plugin-gfm.d.ts create mode 100644 packages/viewer/tests/images.test.ts create mode 100644 packages/viewer/tests/markdown.test.ts diff --git a/.claude/share-links.md b/.claude/share-links.md index f6232431..bfe75df2 100644 --- a/.claude/share-links.md +++ b/.claude/share-links.md @@ -99,8 +99,17 @@ gone ("expired or reached its view limit"), error. 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`, - **Wide text** kept in `localStorage`) and `outline.ts`, the headings (3 or - more) marking the one being read. + **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/`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cacfdc..ce7a58c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The newest section must match `version` in `manifest.json`. Headings are `## [x. ### 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 and copyable code, 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. +- **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 diff --git a/README.md b/README.md index f73d13af..b19a9f4f 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ From the share's window you can see who is in it, revoke a person, pause the sha Publish one note as a link, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**. It needs the relay. The reader needs nothing installed: the link opens a page served by your relay. - **Expires** after 5 or 15 minutes, an hour, a day, 7 days (default) or 30 days, or never. **Pick a date…** sets an exact date and time, from 1 minute to 365 days ahead. **Views** limits how many times it opens (1, 5, 25, unlimited). Once either runs out the relay erases the note. -- An optional **passphrase** is asked before the note opens. Send it by a different route than the link. The reader can let their browser remember it until the link expires. +- An optional **passphrase** is asked before the note opens. Send it by a different route than the link. The reader can let their browser remember it until the link expires, and can copy the note as Markdown. - The note is drawn as Obsidian's reading view draws it (callouts, tasks, code, tables, math; a Mermaid diagram your vault has not allowed shows as its source), then encrypted on your device before it leaves. 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 of the note's headings, shows how many views and how long the link has left, and can widen the text. Headings fold with a click, and their `#` copies a link to that place in the note. Code blocks have a copy button. - Left out: properties, comments, links to other notes (their text stays), embedded notes, and files other than images. Images from your vault are embedded and shrunk to fit; turn that off with **Include images**. The window tells you what was left out before you create the link. diff --git a/packages/plugin/src/links/math.ts b/packages/plugin/src/links/math.ts index de153c67..94ea9e64 100644 --- a/packages/plugin/src/links/math.ts +++ b/packages/plugin/src/links/math.ts @@ -81,6 +81,8 @@ export function mathToMathML(root: HTMLElement): void { 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"); diff --git a/packages/plugin/tests/links/math.test.ts b/packages/plugin/tests/links/math.test.ts index 27ede89b..bfd50cfc 100644 --- a/packages/plugin/tests/links/math.test.ts +++ b/packages/plugin/tests/links/math.test.ts @@ -75,6 +75,8 @@ describe("math", () => { 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(); }); diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 51659223..2eff1ee3 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -12,9 +12,12 @@ "dependencies": { "@mdsync/protocol": "workspace:*", "dompurify": "^3.4.16", - "idb-keyval": "^6.3.0" + "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", diff --git a/packages/viewer/src/clipboard.ts b/packages/viewer/src/clipboard.ts index edf7f7d7..0f3b90af 100644 --- a/packages/viewer/src/clipboard.ts +++ b/packages/viewer/src/clipboard.ts @@ -1,3 +1,6 @@ +/** 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 { @@ -7,3 +10,21 @@ export async function copyText(text: string): Promise { 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 index 13f239c0..699c0579 100644 --- a/packages/viewer/src/code.ts +++ b/packages/viewer/src/code.ts @@ -1,7 +1,5 @@ -import { copyText } from "./clipboard"; +import { copyWithFeedback } from "./clipboard"; -/** How long a copy button shows how the copy went. */ -const RESULT_MS = 1500; const LABEL = "Copy"; /** Obsidian's copy button on each code block: the snapshot's own left without its script. */ @@ -11,13 +9,9 @@ export function addCopyButtons(body: HTMLElement): void { button.type = "button"; button.className = "copy-code-button"; button.textContent = LABEL; - button.addEventListener("click", async () => { - const copied = await copyText(code.textContent ?? ""); - button.textContent = copied ? "Copied" : "Copy failed"; - setTimeout(() => { - button.textContent = LABEL; - }, RESULT_MS); - }); + button.addEventListener("click", () => + copyWithFeedback(button, LABEL, () => code.textContent ?? ""), + ); code.after(button); } } 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 index c7778577..c62021fb 100644 --- a/packages/viewer/src/index.html +++ b/packages/viewer/src/index.html @@ -7,6 +7,12 @@ Shared note + + 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/sidebar.css b/packages/viewer/src/sidebar.css index 020fb4e8..b9bedaca 100644 --- a/packages/viewer/src/sidebar.css +++ b/packages/viewer/src/sidebar.css @@ -16,6 +16,20 @@ 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; @@ -29,16 +43,16 @@ cursor: pointer; } -/* A switch: the knob is a gradient layer that slides; the track is the background colour. */ +/* 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: 2.1rem; - height: 1.2rem; - border-radius: 1rem; + width: 36px; + height: 20px; + border-radius: 10px; background: - radial-gradient(circle closest-side, #fff 90%, transparent) no-repeat - 0.15rem 50% / 0.9rem 0.9rem var(--background-modifier-border); + 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; @@ -46,7 +60,7 @@ .wide-toggle[aria-pressed="true"]::after { background-color: var(--interactive-accent); - background-position: calc(100% - 0.15rem) 50%; + background-position: calc(100% - 4px) 50%; } .outline { @@ -151,9 +165,7 @@ } /* The text already fills a narrow screen. */ - .wide-toggle, - .link-card:not(:has(.link-fact)), - .sidebar:not(:has(.outline, .link-fact)) { + .wide-toggle { display: none; } } diff --git a/packages/viewer/src/sidebar.ts b/packages/viewer/src/sidebar.ts index 3501f994..b0127ff3 100644 --- a/packages/viewer/src/sidebar.ts +++ b/packages/viewer/src/sidebar.ts @@ -1,5 +1,7 @@ +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; @@ -18,6 +20,7 @@ export function createSidebar( reader: HTMLElement, outline: HTMLElement | null, facts: LinkFacts, + markdown: () => string, signal: AbortSignal, ): HTMLElement { const doc = reader.ownerDocument; @@ -28,7 +31,7 @@ export function createSidebar( toggle.setAttribute("aria-expanded", "false"); const panel = doc.createElement("div"); panel.className = "sidebar-panel"; - panel.append(card(reader, facts, signal)); + panel.append(card(reader, facts, markdown, signal)); if (outline) panel.append(outline); sidebar.append(toggle, panel); @@ -62,6 +65,7 @@ export function createSidebar( function card( reader: HTMLElement, facts: LinkFacts, + markdown: () => string, signal: AbortSignal, ): HTMLElement { const doc = reader.ownerDocument; @@ -95,7 +99,11 @@ function card( setWide(on); storeWide(on); }); - card.append(wide); + const copy = button(doc, "copy-markdown", COPY_LABEL); + copy.addEventListener("click", () => + copyWithFeedback(copy, COPY_LABEL, markdown), + ); + card.append(copy, wide); return card; } 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 index 8b50e2d9..e2a4f3f3 100644 --- a/packages/viewer/src/view.ts +++ b/packages/viewer/src/view.ts @@ -1,5 +1,7 @@ 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"; @@ -85,6 +87,7 @@ export function createView(root: HTMLElement, handlers: ViewHandlers): View { 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); @@ -107,7 +110,19 @@ export function createView(root: HTMLElement, handlers: ViewHandlers): View { body, ); const reader = el("div", { class: "reader" }, article); - reader.prepend(createSidebar(reader, outline, state, signal)); + reader.prepend( + createSidebar( + reader, + outline, + state, + () => + noteMarkdown( + title && !named ? title : "", + sanitizeNote(state.payload.html), + ), + signal, + ), + ); return reader; } diff --git a/packages/viewer/src/viewer.css b/packages/viewer/src/viewer.css index ee5418dd..b6d5539b 100644 --- a/packages/viewer/src/viewer.css +++ b/packages/viewer/src/viewer.css @@ -127,6 +127,11 @@ body { .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 { 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/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/sidebar.test.ts b/packages/viewer/tests/sidebar.test.ts index 471f2898..613486bd 100644 --- a/packages/viewer/tests/sidebar.test.ts +++ b/packages/viewer/tests/sidebar.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSidebar } from "../src/sidebar"; @@ -25,11 +25,13 @@ 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); @@ -60,6 +62,27 @@ describe("sidebar", () => { 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b408ecc..1d03413e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,16 @@ importers: 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 @@ -810,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} @@ -961,6 +973,9 @@ packages: '@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==} @@ -1449,6 +1464,13 @@ packages: 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'} @@ -2110,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 @@ -2223,6 +2247,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/whatwg-mimetype@3.0.2': optional: true @@ -2751,6 +2777,12 @@ snapshots: 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: {} diff --git a/tests/e2e/links-viewer.ts b/tests/e2e/links-viewer.ts index c462f2ba..09375883 100644 --- a/tests/e2e/links-viewer.ts +++ b/tests/e2e/links-viewer.ts @@ -171,6 +171,26 @@ async function outlineAndFolds(page: Page): Promise { 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 }); From ba16b95407ffe2e4740d136bb0623c016c3b8f3a Mon Sep 17 00:00:00 2001 From: kitbyte Date: Thu, 8 Oct 2026 23:52:44 +0300 Subject: [PATCH 4/4] docs: share links demo, shorter README section --- README.md | 24 ++++++++++++++---------- docs/demos/links.webp | Bin 0 -> 693198 bytes 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 docs/demos/links.webp diff --git a/README.md b/README.md index b19a9f4f..29d10a96 100644 --- a/README.md +++ b/README.md @@ -251,16 +251,20 @@ 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, from its menu in the file explorer (**MDSync: Share link**) or with **Share this note as a link**. It needs the relay. The reader needs nothing installed: the link opens a page served by your relay. - -- **Expires** after 5 or 15 minutes, an hour, a day, 7 days (default) or 30 days, or never. **Pick a date…** sets an exact date and time, from 1 minute to 365 days ahead. **Views** limits how many times it opens (1, 5, 25, unlimited). Once either runs out the relay erases the note. -- An optional **passphrase** is asked before the note opens. Send it by a different route than the link. The reader can let their browser remember it until the link expires, and can copy the note as Markdown. -- The note is drawn as Obsidian's reading view draws it (callouts, tasks, code, tables, math; a Mermaid diagram your vault has not allowed shows as its source), then encrypted on your device before it leaves. 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 of the note's headings, shows how many views and how long the link has left, and can widen the text. Headings fold with a click, and their `#` copies a link to that place in the note. Code blocks have a copy button. -- Left out: properties, comments, links to other notes (their text stays), embedded notes, and files other than images. Images from your vault are embedded and shrunk to fit; turn that off with **Include images**. The window tells you what was left out before you create the link. -- **Manage share links** (command, or **Settings → MDSync → Sync → Share links**) shows how many views are left, copies the link again, **Update**s it to the note as it is now (same link, views already used stay used) or stops it at once. -- A shared note shows a globe in the file tree and in its header; a dot means it changed since it was shared. Click either to update it, or run **Update share links of this note**. -- Opening the link in the same tab again costs no extra view. +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`. diff --git a/docs/demos/links.webp b/docs/demos/links.webp new file mode 100644 index 0000000000000000000000000000000000000000..5eface10c2ce3b498f3a83a9cb015da360d2cf99 GIT binary patch literal 693198 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�Mf{0am;V*dMG*_^V}Zu2 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%0D<${BuYOGp6-(>B*U!hy&*~svJdoa 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%0A#=7TJ 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

5Aq1dV2i*{rx^Z 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�m6VPKDo~O~d<~ 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 z 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 `J} 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

8#2-Au`w{RW+yP=`?X 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