Note backgrounds: Unsplash through our server, plus uploads #127

Closed
kayg wants to merge 3 commits from issue/102-note-backgrounds into dev
Owner

Closes #102

What

  • New plugin crate crates/backdrops: a server proxy for Unsplash plus uploads. The access key comes from CALTERNAL_UNSPLASH_KEY (optional CALTERNAL_UNSPLASH_API for tests/mirrors). The key only ever lives on the server.
  • Photos: list/search (/api/v1/backdrops/unsplash/photos?q=; empty q = a random handful; search answers kept 10 min), photographer credit (utm_source=calternal), pictures proxied and cached on disk (256 MiB cap, oldest out first), a pick is reported once per person and photo through Unsplash's download call. Picture URLs are only followed for images.unsplash.com (or the configured API host); downloads must be real JPEG/PNG/WebP, max 8 MiB.
  • Uploads: JPEG/PNG/WebP by content signature (ADR 0028), 10 MiB and 40 megapixels (read from the header, never decoded on the server), stored by content hash under users/<id>/backdrops/, at most 60 per person, readable only by their owner. Changes need X-Requested-With: calternal.
  • No key: status says so, the picker shows one plain sentence ("Photos from Unsplash aren't set up on this server yet. You can still upload your own.") and uploads still work.
  • UI: picker in the style inspector (Note tab, ADR 0035): Sky / Mountains / Plain, search, shuffle, upload, your pictures (remove), photographer credit. The choice is per note, in frontmatter calternal-backdrop (sky|photo|plain|unsplash:<id>|upload:<file>); no choice follows Settings. Page opacity now has a 40% floor (slider and parser).
  • CSP: no change needed. img-src 'self' data: blob: already covers our own /api/v1/backdrops/... addresses. Verified in the served-server e2e: no violation, every picture request is same-origin, nothing goes to Unsplash or the stub from the browser. ADR 0039 records the decision.
  • Added E2E_PORT_OFFSET to the Playwright config so two checkouts can run side by side.

Tests

  • Rust: 17 tests in crates/backdrops (no key, search/credit/own addresses, caching, pick reported once, foreign-host picture refused, non-picture refused, 403 becomes slow down, bad ids, upload dedupe/serve/delete, content sniffing, size and pixel bombs, header and sign-in checks, per-person privacy, cache cap, the real HTTP client against a local server) plus one --ignored test proving HTTPS to api.unsplash.com. Workspace tests, clippy -D warnings and fmt are clean.
  • Playwright (Unsplash stubbed by e2e/unsplash-stub.mjs): pick a photo (same-origin, credit, saved in the file, no layout shift, download reported with the key), upload and remove, refused non-picture, no-key picker, opacity floor, nothing fetched until the inspector opens, and a served-server CSP test. Passing on Chromium 1440/390 and (software-rendered) WebKit 1440/390. In a full 4-worker run on this loaded box only editor.spec "no layout shift (cpu x6)" timed out on a click; it passes at x1 and the rest of the file passes alone, so it is the box, not this change.

Perf

  • Web bundle vs dev: JS 768,670 to 774,807 bytes (gzip 253,241 to 255,541, +2.3 KB), CSS 94,581 to 96,984 (gzip 18,750 to 19,182). Nothing is fetched at startup (tested): the picker only calls the server when the inspector opens. There is no budget file in dev yet to check against.

Not done

  • No Unsplash key yet, so real photos were never fetched (only HTTPS reachability). Set CALTERNAL_UNSPLASH_KEY in the container environment; the deploy file is not touched, to avoid a missing-file start failure.
  • The photographer credit shows in the inspector, not on the page itself.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Closes #102 **What** - New plugin crate `crates/backdrops`: a server proxy for Unsplash plus uploads. The access key comes from `CALTERNAL_UNSPLASH_KEY` (optional `CALTERNAL_UNSPLASH_API` for tests/mirrors). The key only ever lives on the server. - Photos: list/search (`/api/v1/backdrops/unsplash/photos?q=`; empty q = a random handful; search answers kept 10 min), photographer credit (`utm_source=calternal`), pictures proxied and cached on disk (256 MiB cap, oldest out first), a pick is reported once per person and photo through Unsplash's download call. Picture URLs are only followed for `images.unsplash.com` (or the configured API host); downloads must be real JPEG/PNG/WebP, max 8 MiB. - Uploads: JPEG/PNG/WebP by content signature (ADR 0028), 10 MiB and 40 megapixels (read from the header, never decoded on the server), stored by content hash under `users/<id>/backdrops/`, at most 60 per person, readable only by their owner. Changes need `X-Requested-With: calternal`. - No key: `status` says so, the picker shows one plain sentence ("Photos from Unsplash aren't set up on this server yet. You can still upload your own.") and uploads still work. - UI: picker in the style inspector (Note tab, ADR 0035): Sky / Mountains / Plain, search, shuffle, upload, your pictures (remove), photographer credit. The choice is per note, in frontmatter `calternal-backdrop` (`sky|photo|plain|unsplash:<id>|upload:<file>`); no choice follows Settings. Page opacity now has a 40% floor (slider and parser). - CSP: no change needed. `img-src 'self' data: blob:` already covers our own `/api/v1/backdrops/...` addresses. Verified in the served-server e2e: no violation, every picture request is same-origin, nothing goes to Unsplash or the stub from the browser. ADR 0039 records the decision. - Added `E2E_PORT_OFFSET` to the Playwright config so two checkouts can run side by side. **Tests** - Rust: 17 tests in `crates/backdrops` (no key, search/credit/own addresses, caching, pick reported once, foreign-host picture refused, non-picture refused, 403 becomes slow down, bad ids, upload dedupe/serve/delete, content sniffing, size and pixel bombs, header and sign-in checks, per-person privacy, cache cap, the real HTTP client against a local server) plus one `--ignored` test proving HTTPS to api.unsplash.com. Workspace tests, clippy `-D warnings` and fmt are clean. - Playwright (Unsplash stubbed by `e2e/unsplash-stub.mjs`): pick a photo (same-origin, credit, saved in the file, no layout shift, download reported with the key), upload and remove, refused non-picture, no-key picker, opacity floor, nothing fetched until the inspector opens, and a served-server CSP test. Passing on Chromium 1440/390 and (software-rendered) WebKit 1440/390. In a full 4-worker run on this loaded box only `editor.spec` "no layout shift (cpu x6)" timed out on a click; it passes at x1 and the rest of the file passes alone, so it is the box, not this change. **Perf** - Web bundle vs `dev`: JS 768,670 to 774,807 bytes (gzip 253,241 to 255,541, +2.3 KB), CSS 94,581 to 96,984 (gzip 18,750 to 19,182). Nothing is fetched at startup (tested): the picker only calls the server when the inspector opens. There is no budget file in `dev` yet to check against. **Not done** - No Unsplash key yet, so real photos were never fetched (only HTTPS reachability). Set `CALTERNAL_UNSPLASH_KEY` in the container environment; the deploy file is not touched, to avoid a missing-file start failure. - The photographer credit shows in the inspector, not on the page itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
The key stays on the server (CALTERNAL_UNSPLASH_KEY). Photos and uploads are
served from our own address, so the CSP stays at 'self'. The picker lives in
the style inspector; a note's choice is saved in its frontmatter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Backdrop picker: fit the sheet, shorter search placeholder, first-paint test, e2e port offset (#102)
Some checks failed
CI / rust (pull_request) Failing after 2m16s
CI / web (pull_request) Failing after 12m49s
ec56d6cf42
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Superseded by #270 (rebased on current dev, with kayg's decisions). Closing.

Superseded by #270 (rebased on current dev, with kayg's decisions). Closing.
kayg closed this pull request 2026-10-14 12:34:38 +00:00
Some checks failed
CI / rust (pull_request) Failing after 2m16s
CI / web (pull_request) Failing after 12m49s

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!127
No description provided.