feat(branding): per-scope favicon, global-only v1 (LC-142) #162

Merged
nrupard merged 2 commits from feat/lc-142-branding-favicon into main 2026-05-20 18:08:44 +02:00
Owner

Summary

Per-scope favicon (global-only v1), deferred from LC-96. Operators who set a custom logo can now set a matching favicon on /admin/branding, served from a dynamic /branding/favicon route mirroring /branding/logo.

Changes

  • Schema: migration 0036 adds nullable favicon_upload_id to branding (FK-free, same rationale as logo_upload_id). Branding struct, get/resolve, and upsert carry the column; upsert grew a favicon_upload_id parameter (both callers updated). Enclave form passes the existing value through (favicons are global-only).
  • Serving: GET /branding/favicon returns the custom favicon when set, else falls back to the bundled assets/favicon.svg, so base.html links it unconditionally with no per-page threading. The <link> drops its type hint (served format varies). The unread-mention favicon swap restores to /branding/favicon on clear.
  • Upload: shared multipart parser gains a favicon field; persist_logo_field generalized to persist_brand_file(is_favicon) which widens accepted types to SVG + ICO. SVG (text) is sniffed via looks_like_svg since infer can't classify it. 1 MiB cap + content-addressed storage unchanged.
  • Admin form: favicon input + current-favicon preview (gated on has_favicon).

Notes

  • Per-enclave favicon is a stretch goal, intentionally skipped (browsers cache favicons aggressively; marginal payoff). The column exists on all scope rows for symmetry but only the global row is written.
  • Browsers cache favicons hard; a change may need a hard refresh. Noted in the form hint.

Tests

Four new cases in routes_branding.rs: favicon route falls back to the static SVG when unset, upsert round-trips favicon_upload_id, base.html links the dynamic favicon, admin page shows the preview when set. Migration 0036 appended to the hand-rolled migration lists. just check, just test, just test-saas all green.

🤖 Generated with Claude Code

## Summary Per-scope favicon (global-only v1), deferred from LC-96. Operators who set a custom logo can now set a matching favicon on `/admin/branding`, served from a dynamic `/branding/favicon` route mirroring `/branding/logo`. ## Changes - **Schema**: migration `0036` adds nullable `favicon_upload_id` to `branding` (FK-free, same rationale as `logo_upload_id`). `Branding` struct, `get`/`resolve`, and `upsert` carry the column; `upsert` grew a `favicon_upload_id` parameter (both callers updated). Enclave form passes the existing value through (favicons are global-only). - **Serving**: `GET /branding/favicon` returns the custom favicon when set, else falls back to the bundled `assets/favicon.svg`, so `base.html` links it unconditionally with no per-page threading. The `<link>` drops its `type` hint (served format varies). The unread-mention favicon swap restores to `/branding/favicon` on clear. - **Upload**: shared multipart parser gains a `favicon` field; `persist_logo_field` generalized to `persist_brand_file(is_favicon)` which widens accepted types to SVG + ICO. SVG (text) is sniffed via `looks_like_svg` since `infer` can't classify it. 1 MiB cap + content-addressed storage unchanged. - **Admin form**: favicon input + current-favicon preview (gated on `has_favicon`). ## Notes - Per-enclave favicon is a stretch goal, intentionally skipped (browsers cache favicons aggressively; marginal payoff). The column exists on all scope rows for symmetry but only the global row is written. - Browsers cache favicons hard; a change may need a hard refresh. Noted in the form hint. ## Tests Four new cases in `routes_branding.rs`: favicon route falls back to the static SVG when unset, `upsert` round-trips `favicon_upload_id`, `base.html` links the dynamic favicon, admin page shows the preview when set. Migration `0036` appended to the hand-rolled migration lists. `just check`, `just test`, `just test-saas` all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(branding): per-scope favicon, global-only v1 (LC-142)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m7s
7a50a65539
LC-96 follow-up: operators who set a custom logo can now set a matching favicon. Adds a favicon upload to /admin/branding and serves it from a dynamic /branding/favicon route that mirrors /branding/logo.

Schema: migration 0036 adds a nullable favicon_upload_id to branding (FK-free, same rationale as logo_upload_id). The Branding struct, get/resolve, and upsert carry the new column; upsert grew a favicon_upload_id parameter and both callers (admin global form, enclave form) were updated. Favicons are global-only in v1, so the enclave form passes the row's existing value through (always None for enclaves).

Serving: GET /branding/favicon returns the operator's custom favicon when set, otherwise falls back to the bundled assets/favicon.svg, so base.html links the route unconditionally with no per-page branding threading. The link drops its explicit type hint since the served format varies (PNG/ICO/SVG). The unread-mention favicon swap in layout.html restores to /branding/favicon (custom or default) when the count clears, keeping the static dot only for the unread state.

Upload: the shared multipart parser gains a favicon field. persist_logo_field is generalized to persist_brand_file with an is_favicon flag that widens accepted types to SVG and ICO on top of the logo's PNG/JPEG/WebP/GIF. SVG is text, so infer cannot classify it; a leading-bytes sniff (looks_like_svg) accepts it. 1 MiB cap and content-addressed storage are unchanged.

Admin form: a favicon file input + current-favicon preview, gated on has_favicon (added to BrandingPage).

Tests: four new cases in routes_branding.rs (favicon route falls back to the static SVG when unset, upsert round-trips favicon_upload_id, base.html links the dynamic favicon, admin page shows the preview when set). Existing upsert call sites updated for the new parameter. Migration 0036 appended to the hand-rolled migration lists. just check, just test, and just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(branding): sandbox served favicon SVG; consistent cache-bust (LC-142)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m18s
7c67283d38
Admins can upload an SVG favicon, which is served from the directly-navigable /branding/favicon route as image/svg+xml. SVGs can embed <script>, which runs when the resource is opened directly in a tab (not when used as a <link rel=icon>). Add Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox to the served-upload branch so an uploaded SVG is inert on direct access. The favicon still renders normally; the logo route is unaffected (it never accepted SVG). New test uploads a script-bearing SVG and asserts the sandbox CSP is present.

Also align the unread-mention favicon swap with the static link: both now use /branding/favicon?v={asset_version}, so clearing mentions does not refetch an unversioned URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-142-branding-favicon 2026-05-20 18:08:44 +02:00
Sign in to join this conversation.
No reviewers
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
psa-systems/lets-chat!162
No description provided.