Embeds as cards and link previews through our server (#165) #263

Closed
kayg wants to merge 3 commits from issue/165-cards into dev
Owner

Part 3 of #165: embeds as cards, and pasted links that become preview cards. Stacked on #261 → #262. Its own commits are the last two: the server previews and the web cards.

Server: previews.get { url } (new plugin crates/previews, read-only)

  • SSRF guards:
    • Only http/https on ports 80/443, and no credentials in the link.
    • Every address the host resolves to must be public. Refused: loopback, RFC 1918, link-local/169.254 (metadata), CGNAT, 0/8, multicast, broadcast, documentation, benchmarking, ULA, fe80, and IPv4 hidden in IPv6 (mapped, NAT64, 6to4, compatible).
    • Names refused outright: localhost, .local, .internal, .home.arpa.
    • The socket goes to the address that was checked, with no second lookup, so DNS rebinding doesn't work. Every redirect (at most 3) is checked again.
  • Caps: 512 KB of page, 300 KB of picture, 5 s for the whole fetch, 3 s to connect, 8 fetches at once server-wide. Compressed answers are refused (Accept-Encoding: identity).
  • Privacy: no cookies, no Referer, a fixed user agent. The picture comes back as a data: URL, so the browser never contacts the page. No SVG. Results are cached in memory per person: 24 h for a card, 5 min for a failure.
  • Never in the way: a page that can't be read still gives a card with its site name.
  • Stack: HTTP is hyper, already in the tree. TLS is OpenSSL, already linked for passkeys, through tokio-openssl. One small new crate; no new TLS stack. Note: #127 (backgrounds) brings ureq for its Unsplash fetch. Once both land, one shared fetcher would be worth it.
  • Tests (18): address and shape rules, the HTML head reader, and a local web server covering redirects into 10/8 and to 169.254.169.254, redirect loops, the size cap, the time cap, compression, SVG, oversized pictures and bare cards. It also asserts that no cookie or referrer is ever sent.
  • Checked by hand against the real internet:
    • example.com: 0.46 s.
    • rust-lang.org: title, description and picture.
    • Refused at once: 169.254.169.254, 127.0.0.1, localhost, file://.

Web: cards (apps/web/src/lib/embeds/, styles/embeds.css)

  • Note card: a line that is only ![Title](<Note.md>) draws as a note card. The title opens the note. The note's text shows read-only and stays live: it is read again when that note changes.
    • Closed, it shows 4 lines with a fade. The chevron opens it on the morph spring by pointer, or at once by keyboard.
    • A missing note says so plainly, and the card comes back when the note is restored.
  • Web card: a line that is only ![Title](<https://…>) draws as a web card with the picture, title, a line about the page, and the site with ↗.
    • It opens in a new tab with noopener noreferrer.
    • Until the preview arrives it keeps the same shape with the site's name, so nothing shifts. On phones the picture goes on top.
  • Paste offer: pasting a bare link alone on a line shows a quiet “Show as card” pill. Tab or a tap turns it into ![Page title](<url>). Typing on leaves the link as it is.
  • Source mode and caret: the caret on the line shows the markdown, and source mode shows every character. A new syntaxHidden facet in the editor tells state fields which mode is on.
  • Not in this PR:
    • Pictures and files from the Home (![](<file>)) need attachment storage on the server, so they stay as text for now.
    • The ↗ link pill style is #162's.
    • Editing inside a card comes later.

Tests: e2e/cards.spec.ts (4) passes in Chromium and WebKit at 390 and 1440. The picker, editor, links and shortcuts specs still pass on the rebased stack (58/58). Screenshots at 320, 390 and 1440, light and dark, are on #165.

🤖 Generated with Claude Code

Part 3 of #165: embeds as cards, and pasted links that become preview cards. **Stacked on #261 → #262.** Its own commits are the last two: the server previews and the web cards. **Server: `previews.get { url }`** (new plugin `crates/previews`, read-only) - **SSRF guards:** - Only `http`/`https` on ports 80/443, and no credentials in the link. - Every address the host resolves to must be public. Refused: loopback, RFC 1918, link-local/169.254 (metadata), CGNAT, 0/8, multicast, broadcast, documentation, benchmarking, ULA, fe80, and IPv4 hidden in IPv6 (mapped, NAT64, 6to4, compatible). - Names refused outright: `localhost`, `.local`, `.internal`, `.home.arpa`. - The socket goes to the address that was checked, with no second lookup, so DNS rebinding doesn't work. Every redirect (at most 3) is checked again. - **Caps:** 512 KB of page, 300 KB of picture, 5 s for the whole fetch, 3 s to connect, 8 fetches at once server-wide. Compressed answers are refused (`Accept-Encoding: identity`). - **Privacy:** no cookies, no Referer, a fixed user agent. The picture comes back as a `data:` URL, so the browser never contacts the page. No SVG. Results are cached in memory per person: 24 h for a card, 5 min for a failure. - **Never in the way:** a page that can't be read still gives a card with its site name. - **Stack:** HTTP is hyper, already in the tree. TLS is OpenSSL, already linked for passkeys, through `tokio-openssl`. One small new crate; no new TLS stack. Note: #127 (backgrounds) brings `ureq` for its Unsplash fetch. Once both land, one shared fetcher would be worth it. - **Tests (18):** address and shape rules, the HTML head reader, and a local web server covering redirects into 10/8 and to 169.254.169.254, redirect loops, the size cap, the time cap, compression, SVG, oversized pictures and bare cards. It also asserts that no cookie or referrer is ever sent. - **Checked by hand against the real internet:** - example.com: 0.46 s. - rust-lang.org: title, description and picture. - Refused at once: `169.254.169.254`, `127.0.0.1`, `localhost`, `file://`. **Web: cards** (`apps/web/src/lib/embeds/`, `styles/embeds.css`) - **Note card:** a line that is only `![Title](<Note.md>)` draws as a note card. The title opens the note. The note's text shows read-only and stays live: it is read again when that note changes. - Closed, it shows 4 lines with a fade. The chevron opens it on the morph spring by pointer, or at once by keyboard. - A missing note says so plainly, and the card comes back when the note is restored. - **Web card:** a line that is only `![Title](<https://…>)` draws as a web card with the picture, title, a line about the page, and the site with ↗. - It opens in a new tab with `noopener noreferrer`. - Until the preview arrives it keeps the same shape with the site's name, so nothing shifts. On phones the picture goes on top. - **Paste offer:** pasting a bare link alone on a line shows a quiet “Show as card” pill. Tab or a tap turns it into `![Page title](<url>)`. Typing on leaves the link as it is. - **Source mode and caret:** the caret on the line shows the markdown, and source mode shows every character. A new `syntaxHidden` facet in the editor tells state fields which mode is on. - **Not in this PR:** - Pictures and files from the Home (`![](<file>)`) need attachment storage on the server, so they stay as text for now. - The ↗ link pill style is #162's. - Editing inside a card comes later. Tests: `e2e/cards.spec.ts` (4) passes in Chromium and WebKit at 390 and 1440. The picker, editor, links and shortcuts specs still pass on the rebased stack (58/58). Screenshots at 320, 390 and 1440, light and dark, are on #165. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Web: @ and [[ offer notes again, from search (#165)
Some checks failed
CI / web (pull_request) Failing after 43s
CI / rust (pull_request) Successful in 2m45s
33c702bae5
Why they showed nothing: the editor's link shortcuts (@ and [[) only
offer notes when the app hands them a search, and Editor.svelte never
did. The completion source returned nothing on every keystroke.

- One search client (lib/search.ts) for ⌘K and the link picker, on
  search.query, so ranking, typo fixes and "used lately" match ⌘K.
- lib/link-targets.ts turns hits into link targets; offline or on an
  error it falls back to notes whose title matches. Never offers the
  note itself. A typed [[Title]] links the note with that title.
- The picker sits on <body> with the palette's glass and the app font,
  a set width, 12px from the screen's sides, so it never clips or
  overflows at 320.
- e2e/picker.spec.ts: [[ and @ find a note by search and Enter writes a
  standard [Title](<Title.md>) link, Chromium and WebKit, 390 and 1440.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Editor + web: the full @ / [[ picker: embeds, New note, instant (#165)
Some checks failed
CI / web (pull_request) Failing after 29s
CI / rust (pull_request) Successful in 2m48s
cc4c0499d0
- ![[ and !@ use the same picker and write an embed: ![Title](<Note.md>)
  for a note, ![](<file>) for a picture or file. A hand-typed
  ![[Title]] becomes an embed too.
- The first answer comes on the keystroke's own frame, from the note
  list already in memory (titles that contain the words, newest first,
  no sort of thousands of matches); a moment later the one search
  (same as ⌘K) replaces it in place. Offline the first answer stays.
  The editor no longer waits 100 ms before asking.
- "New note “…”" is the last choice when no note has exactly that
  title: it links at once and makes the note (with its H1); if the
  server names the file differently, the link is corrected.
- Tab chooses like Enter while the list is open; otherwise Tab keeps
  nesting lists.
- Each choice shows the app's own icon (note, daily note, picture,
  file, plus for New note). Choosing ranks the note higher next time
  (search.visit).
- Tests: packages/editor/test/picker.test.ts (links, embeds, pictures,
  instant then better answers, addresses aren't triggers, New note and
  its correction); e2e picker.spec.ts adds ![[ + Tab and New note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
A new plugin, crates/previews, with one read-only action:
previews.get { url } -> { url, site, title, description, image }.

- Safe: only http/https on ports 80/443, no credentials in the link,
  and every address the host resolves to must be public (loopback,
  private, link-local/metadata, CGNAT, multicast, documentation,
  IPv4-mapped/NAT64/6to4 forms all refused). The connection goes to
  the address that was checked (no second lookup, so no DNS
  rebinding), and every redirect (at most 3) is checked again.
- Small and quick: at most 512 KB of page and 300 KB of picture, 5 s
  for the whole fetch, 3 s to connect, 8 fetches at once server-wide.
  Compressed answers are refused, so nothing unpacks into more.
- Private: no cookies, no Referer, a plain user agent. The picture
  comes back as a data: URL, so the browser never contacts the page.
  No SVG. Results are kept in memory per person (24 h; a failed fetch
  5 min), so nobody can tell from timing what someone else previewed.
- A page that can't be read still gives a card with its site name;
  only links that may never be fetched are refused.
- HTTP over hyper (already in the tree) and OpenSSL (already linked
  for passkeys) through tokio-openssl; no new TLS stack.

Tests: address rules (25 refused, 8 public), link shapes, the HTML
head reader, and a local web server for redirects into the private
network, the metadata address, redirect loops, the size cap, the time
cap, compression, SVG and oversized pictures, and bare cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Web: embeds as cards, and pasted links that become preview cards (#165)
Some checks failed
CI / web (pull_request) Failing after 44s
CI / rust (pull_request) Successful in 2m41s
88a95e8467
- A line that is only ![Title](<Note.md>) draws as a note card: the
  title (opens the note) and the note's text, live and read-only.
  Closed, it shows the first four lines with a fade; the chevron opens
  it to the whole note on the morph spring (at once by keyboard). It
  reads the note again when the note changes, and a missing note says
  so plainly and comes back when restored.
- A line that is only ![Title](<https://...>) draws as a web card:
  picture, title, a line about it and the site with ↗, all from
  previews.get. It opens in a new tab with noopener, noreferrer. Until
  it's ready it has the same shape with the site's name, so nothing
  moves. On phones the picture goes on top.
- Pasting a bare web link alone on a line offers "Show as card" right
  after it; Tab or a tap turns it into ![Page title](<url>). Typing on
  leaves the link as it is.
- The text never changes: the caret on the line shows the markdown,
  and source mode shows every character (a new syntaxHidden facet in
  the editor tells state fields which mode is on).
- e2e/cards.spec.ts: a card that opens, closes and follows its note,
  source mode, the paste offer and Tab, and typing on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Closed by kayg (2026-10-16). Link previews and embed cards come back after #465, mockup first.

Closed by kayg (2026-10-16). Link previews and embed cards come back after #465, mockup first.
kayg closed this pull request 2026-10-16 03:57:37 +00:00
Some checks failed
CI / web (pull_request) Failing after 44s
CI / rust (pull_request) Successful in 2m41s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!263
No description provided.