Backgrounds: per-user background setting, Unsplash via server, uploads (backend) #86

Closed
opened 2026-09-25 12:36:45 +00:00 by kayg · 5 comments
Owner

Owner decisions DESIGN §35 (grill 2026-09-25). Backend and API for Settings → Appearance backgrounds. Sources: theme gradient (default), solid colour, Unsplash photo, uploaded image. Per user, synced across devices. Unsplash: server-side search plus a one-time download into the user's Home; the photographer credit is stored and returned. The admin sets the Unsplash access key as an instance setting (never in the repo or logs); Unsplash is hidden without a key. Images carry a dim/tint value. Light/dark pairing is still open (grill round 2): model the setting so a separate light and dark choice can be added without a migration.

Owner decisions DESIGN §35 (grill 2026-09-25). Backend and API for Settings → Appearance backgrounds. Sources: theme gradient (default), solid colour, Unsplash photo, uploaded image. Per user, synced across devices. Unsplash: server-side search plus a one-time download into the user's Home; the photographer credit is stored and returned. The admin sets the Unsplash access key as an instance setting (never in the repo or logs); Unsplash is hidden without a key. Images carry a dim/tint value. Light/dark pairing is still open (grill round 2): model the setting so a separate light and dark choice can be added without a migration.
Author
Owner

Starting #86 on branch job/backgrounds-backend, based on dev at 490970d66e2493a8a63307ccdf92bba680a4c48e. I am tracing the existing user settings, safe Home writes, Files upload, instance secret, auth freshness, and API generation paths before implementation. No design approval is needed; I will report any implementation choices the design leaves open in the completion comment.

Starting #86 on branch `job/backgrounds-backend`, based on `dev` at `490970d66e2493a8a63307ccdf92bba680a4c48e`. I am tracing the existing user settings, safe Home writes, Files upload, instance secret, auth freshness, and API generation paths before implementation. No design approval is needed; I will report any implementation choices the design leaves open in the completion comment.
Author
Owner

Finding: Photos has a read/merge/write helper for .calternal/settings.json, but no shared EXIF/GPS stripping helper. The appearance setting now merges its key into that same file under the Root mutation lock; Photos now reads inside the lock too. Background upload validation checks and bounds decoding but preserves the uploaded bytes and metadata, as requested when no stripping helper exists.

Unsplash's current API guidelines require hotlinked photo.urls for image uses and a photo.links.download_location request on selection. The requested Home copy and private server proxy use the API URL for the thumbnail, store the selected original in Home, and trigger the tracking endpoint. Unsplash says applications that use their own image infrastructure should contact them for a photo views alternative; instance owners may need to obtain that approval before enabling this integration.

Finding: Photos has a read/merge/write helper for `.calternal/settings.json`, but no shared EXIF/GPS stripping helper. The appearance setting now merges its key into that same file under the Root mutation lock; Photos now reads inside the lock too. Background upload validation checks and bounds decoding but preserves the uploaded bytes and metadata, as requested when no stripping helper exists. Unsplash's current [API guidelines](https://help.unsplash.com/en/articles/2511245-unsplash-api-guidelines) require hotlinked `photo.urls` for image uses and a `photo.links.download_location` request on selection. The requested Home copy and private server proxy use the API URL for the thumbnail, store the selected original in Home, and trigger the tracking endpoint. Unsplash says applications that use their own image infrastructure should contact them for a photo views alternative; instance owners may need to obtain that approval before enabling this integration.
Author
Owner

Finding: Photos PUT /api/v1/photos/roots read .calternal/settings.json before taking the Root mutation lock. Appearance and Photos update separate keys in that same file, so concurrent writes could replace each other's changes. Moved the Photos read/merge under the lock; the concurrent Appearance test also checks preservation of unrelated settings.

Finding: Photos `PUT /api/v1/photos/roots` read `.calternal/settings.json` before taking the Root mutation lock. Appearance and Photos update separate keys in that same file, so concurrent writes could replace each other's changes. Moved the Photos read/merge under the lock; the concurrent Appearance test also checks preservation of unrelated settings.
Author
Owner

Finding: the first Unsplash file-path implementation inserted the validated remote photo ID into a destination string. Even with a strict character check, that ID is upstream input and should not become a filesystem component. Changed the Files helper to store by a BLAKE3 digest under a RelPath and use the same helper for deduplication; added a regression test for traversal IDs.

Finding: the first Unsplash file-path implementation inserted the validated remote photo ID into a destination string. Even with a strict character check, that ID is upstream input and should not become a filesystem component. Changed the Files helper to store by a BLAKE3 digest under a `RelPath` and use the same helper for deduplication; added a regression test for traversal IDs.
Author
Owner

backgrounds-backend finished (branch job/backgrounds-backend, head ccdc528, dev 35aa36b merged in; not pushed or merged)

Continued a stopped job. Already on the branch: instance secret helpers, background image validation in the Files Tus path (20 MB cap at create, decode check before install), GET/PUT /api/v1/appearance, the Unsplash search/select/thumbnail proxy, the admin key endpoint, serialized settings.json writes shared with Photos, hashed Unsplash IDs in paths, first adversarial probes.

Added: nested target/ ignore (removed stray apps/web/target/); Unsplash credit links carry utm_source=calternal&utm_medium=referral; selecting an Unsplash photo keeps the current dim; tests for background Tus uploads (413 / 400 plus staging cleanup / indexed JPEG), upload-source PUT validation (folder, owner, decodable), search cache and 429 budget, admin key on the full app (member 403, hostile keys 4xx, key never returned, capability flips); more adversarial probes (SSRF credit links, CSS in colour, traversal in item/photo IDs, dim range, unknown fields, huge body); clippy fixes; merge of dev.

API

  • GET /api/v1/appearance -> {background: {light: Background, dark: Background|null}, capabilities: {unsplash_available}}
  • PUT /api/v1/appearance body {background: {light, dark}} -> same view
  • Background = {kind:"theme"} | {kind:"solid", color:"#rrggbb"} | {kind:"image", image:{item_id, source:"upload"|"unsplash", credit:{name, profile_url, photo_url}|null, dim:0..1}}
  • GET /api/v1/appearance/unsplash/search?q=&page= -> {results:[{id, description, thumbnail_url:"/api/v1/appearance/unsplash/images/{id}", credit}], total, total_pages, page}
  • GET /api/v1/appearance/unsplash/images/{photo_id} thumbnail proxy
  • POST /api/v1/appearance/unsplash/{photo_id} -> view, with light set to the saved image
  • GET/PUT /api/v1/admin/appearance/unsplash-key -> {configured}; PUT {access_key} (fresh admin); empty string clears
  • No key: Unsplash endpoints 404 and unsplash_available:false.

Decisions

  • dark is always accepted and stored but no UI or rule uses it yet (light/dark pairs are OPEN in §35); null = same as light.
  • EXIF/GPS is not stripped: no Photos strip helper exists. The file is private in the owner's Home.
  • The Unsplash photo is downloaded once into .calternal/backgrounds/unsplash/<blake3(id)>.<ext>, per §35. Unsplash's API guidelines prefer hotlinking; the owner's privacy decision wins. Flag it if the key application needs hotlinking.
  • Select works only for a photo the same User found in a search within the last 5 minutes (the server never builds Unsplash URLs from client input).
  • Budgets: 30 searches and 10 selects per User per minute; search cache 5 min, thumbnail cache 60 s; 10 s timeout, redirects off, hosts fixed to api.unsplash.com and images.unsplash.com over https.

Gates

cargo fmt --check                                   -> exit 0
cargo clippy --workspace --all-targets -- -D warnings -> Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.63s
cargo test --workspace                              -> exit 0; 60 suites, 1015 passed, 0 failed, 9 ignored
bash packages/api-client/check-generated.sh         -> exit 0 (no diff)
bun run check  -> COMPLETED 1381 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
bun run test   -> Test Files  35 passed (35) / Tests  244 passed (244)
bash tests/adversarial/run.sh -> ==== FINDINGS 0 / ==== ROUND 2 FINDINGS 0
**backgrounds-backend finished** (branch `job/backgrounds-backend`, head `ccdc528`, dev `35aa36b` merged in; not pushed or merged) Continued a stopped job. Already on the branch: instance secret helpers, background image validation in the Files Tus path (20 MB cap at create, decode check before install), `GET/PUT /api/v1/appearance`, the Unsplash search/select/thumbnail proxy, the admin key endpoint, serialized settings.json writes shared with Photos, hashed Unsplash IDs in paths, first adversarial probes. Added: nested `target/` ignore (removed stray `apps/web/target/`); Unsplash credit links carry `utm_source=calternal&utm_medium=referral`; selecting an Unsplash photo keeps the current dim; tests for background Tus uploads (413 / 400 plus staging cleanup / indexed JPEG), upload-source PUT validation (folder, owner, decodable), search cache and 429 budget, admin key on the full app (member 403, hostile keys 4xx, key never returned, capability flips); more adversarial probes (SSRF credit links, CSS in colour, traversal in item/photo IDs, dim range, unknown fields, huge body); clippy fixes; merge of dev. API - `GET /api/v1/appearance` -> `{background: {light: Background, dark: Background|null}, capabilities: {unsplash_available}}` - `PUT /api/v1/appearance` body `{background: {light, dark}}` -> same view - `Background` = `{kind:"theme"}` | `{kind:"solid", color:"#rrggbb"}` | `{kind:"image", image:{item_id, source:"upload"|"unsplash", credit:{name, profile_url, photo_url}|null, dim:0..1}}` - `GET /api/v1/appearance/unsplash/search?q=&page=` -> `{results:[{id, description, thumbnail_url:"/api/v1/appearance/unsplash/images/{id}", credit}], total, total_pages, page}` - `GET /api/v1/appearance/unsplash/images/{photo_id}` thumbnail proxy - `POST /api/v1/appearance/unsplash/{photo_id}` -> view, with light set to the saved image - `GET/PUT /api/v1/admin/appearance/unsplash-key` -> `{configured}`; PUT `{access_key}` (fresh admin); empty string clears - No key: Unsplash endpoints 404 and `unsplash_available:false`. Decisions - `dark` is always accepted and stored but no UI or rule uses it yet (light/dark pairs are OPEN in §35); null = same as light. - EXIF/GPS is not stripped: no Photos strip helper exists. The file is private in the owner's Home. - The Unsplash photo is downloaded once into `.calternal/backgrounds/unsplash/<blake3(id)>.<ext>`, per §35. Unsplash's API guidelines prefer hotlinking; the owner's privacy decision wins. Flag it if the key application needs hotlinking. - Select works only for a photo the same User found in a search within the last 5 minutes (the server never builds Unsplash URLs from client input). - Budgets: 30 searches and 10 selects per User per minute; search cache 5 min, thumbnail cache 60 s; 10 s timeout, redirects off, hosts fixed to api.unsplash.com and images.unsplash.com over https. Gates ``` cargo fmt --check -> exit 0 cargo clippy --workspace --all-targets -- -D warnings -> Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.63s cargo test --workspace -> exit 0; 60 suites, 1015 passed, 0 failed, 9 ignored bash packages/api-client/check-generated.sh -> exit 0 (no diff) bun run check -> COMPLETED 1381 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS bun run test -> Test Files 35 passed (35) / Tests 244 passed (244) bash tests/adversarial/run.sh -> ==== FINDINGS 0 / ==== ROUND 2 FINDINGS 0 ```
kayg closed this issue 2026-09-25 14:03:06 +00:00
Sign in to join this conversation.
No labels
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#86
No description provided.