Skip to content

feat(branding): white-label the app — anonymous assets, upload hardening, banner, presets, configurable footer - #237

Merged
antosubash merged 9 commits into
mainfrom
worktree-branding-improve
Aug 6, 2026
Merged

feat(branding): white-label the app — anonymous assets, upload hardening, banner, presets, configurable footer#237
antosubash merged 9 commits into
mainfrom
worktree-branding-improve

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Makes the branding module actually usable for a white-label deployment, using the IIASA.GeoWiki Branding module as the reference implementation.

Bug fixes

  • Logo and favicon 401'd for logged-out visitors. The published URLs pointed at file_storage's download route, which requires file-storage.download — a permission no anonymous request carries. Any deployment that uploaded a logo got a broken image on the sign-in page, the public landing page, and every favicon. Branding now serves its own anonymous GET /api/branding/{logo,logo-dark,favicon} via the register_public_routes hook (exact + GET-only, so upload/clear stay behind branding.manage, and only the ids held in branding settings are ever served).
  • Replacing an image orphaned the old file in file_storage forever — each upload mints a new id, so the previous one was dropped on the floor. Now reaped, best-effort so a storage fault can't fail an otherwise-successful rebrand.
  • Favicon flickered from the browser default on every page load, because it was applied only after React hydrated. Now server-rendered into the shell; the module supplies the URL so framework code never learns branding's route shape (SM009 boundary intact).
  • Applying a preset left the form stale (found by browser QA) — useState only seeds on mount, so a server-originated change never reached the fields.

Hardening

  • Uploads are magic-number checked. The declared content-type is caller-controlled, so a payload renamed logo.png was previously stored and served back under an image type.
  • SVG removed from the allow-list — it is an XML document that can carry <script>, so serving one from our own origin is stored XSS. Responses also carry Content-Disposition: attachment + nosniff as a second layer.
  • Footer link URLs are restricted to http(s) or a single-leading-slash app path. javascript: and data: are refused, and //host is treated as the off-site absolute URL it is rather than a path.

Features (ported from the GeoWiki reference)

  • Dark-background logo variant. The sidebar is near-black in every theme while the sign-in card is light, so one logo cannot read on both. Optional — unset falls back to the primary logo, so existing sites are unchanged.
  • Announcement banner (info / warning / danger) above every shell, guest pages included.
  • One-click presets. Restricted to appearance via PRESET_FIELDS; a preset can never overwrite the app name, an uploaded logo, or a live banner.
  • Configurable multi-column footer (6 columns x 8 links + social row). Previously the footer was rendered by the layout but its content was frozen framework constants, so a white-labelled site still advertised "SimpleModule / github.com/antosubash".
  • Versioned asset caching?v=<file id> makes URLs content-addressed: versioned requests get max-age=31536000, immutable, unversioned get max-age=3600, and a 404 is never cached.

Verification

  • Local CI green: make lint (ruff, ty, biome, tsc, 300-line cap, metadata) · 1618 Python tests · 48 JS tests · production build · 12 e2e tests
  • Browser QA: full cycle at /branding/, 11 scenarios. Verified end-to-end that an uploaded logo now loads on the logged-out login page, that a javascript: footer URL is rejected with 422 and never stored, and that cache headers are correct in both versioned and unversioned form. One P1 bug was found (stale form after applying a preset) and fixed in 41dddde.
  • 66 new tests across the branding module.

Notes for the reviewer

  • Regenerating packages/i18n also picked up keycloak.* keys missing from the checked-in file (it was last generated without keycloak installed). That is the generator's correct output for the current module set, kept rather than hand-editing a file marked do-not-edit.
  • make doctor reports SM020 (Keycloak + Users both installed as auth providers) and two SM003 warnings on keycloak/audit_log. All pre-existing on main and untouched here — but note it currently blocks a dev boot, so the QA run used SM_MODULES_ENABLED to exclude Keycloak.

Test plan

  • Upload a logo, sign out, confirm it renders on the sign-in page
  • Paste javascript:alert(1) as a footer link URL and confirm it is rejected
  • Apply a preset and confirm the colour field updates without a refresh, and that the app name / banner survive
  • CI is green

The published logo/favicon URLs pointed at file_storage's download route,
which is gated by `file-storage.download`. No logged-out request carries
that permission, so a deployment that uploaded a logo got a broken image
on the sign-in page, the public landing page and every `<link rel=icon>` —
precisely the screens a white-label deployment is judged on.

Modelled on the IIASA.GeoWiki Branding module, which solves the same
problem with an `[AllowAnonymous]` branding-image endpoint:

- Branding now serves its own `GET /api/branding/{logo,favicon}`, exempted
  through the `register_public_routes` hook. The rules are exact + GET-only,
  so upload/clear on the same paths stay behind `branding.manage`. Only the
  two ids held in branding settings are served — not arbitrary file_storage
  content.
- Published URLs carry `?v=<file id>`. A replaced image is a new id, so the
  URL is content-addressed: versioned requests get
  `max-age=31536000, immutable`, an unversioned one gets `max-age=3600` so it
  self-corrects, and a 404 is never cached (mirrors BrandingImageCache).
- Uploads are magic-number checked. The declared content-type is
  caller-controlled, so a payload renamed `logo.png` was previously stored
  and served back under an image type.
- SVG is dropped from the allow-list: it is an XML document that can carry
  `<script>`, so serving one from our own origin is stored XSS. Responses
  also carry `Content-Disposition: attachment` + `nosniff` as a second layer
  (both ignored by `<img>`/`<link rel=icon>`, so images still render).

Locale copy, the file picker's `accept`, and the README follow the narrowed
allow-list. 68 branding tests pass (33 new); full suite 1539 passed, JS 44
passed, `make lint` clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
Every branding upload mints a new file_storage id — that is what makes the
published URL content-addressed — so the previous file was simply dropped
on the floor. Nothing else references it and nothing reaps it, meaning each
logo tweak left another orphan in the store.

IIASA.GeoWiki has no equivalent problem: it overwrites one blob at a fixed
key per image type, so there is nothing to clean up. Carrying a fresh id per
upload is what buys the free cache-busting, and the cost is owning cleanup.

BrandingService now captures the outgoing id and reaps it after the settings
write succeeds. Cleanup is best effort and logged, never fatal: the rebrand
has already been persisted and is what the admin asked for, so a storage
fault must not surface as a 500 on a successful save. The service takes the
storage handle as an optional keyword so the published constructor stays
backwards compatible; branding's own dependency always supplies one, sharing
the request session so the delete commits with the settings write.

5 new tests: replace reaps the old file and keeps the new one, clear reaps,
a second clear is harmless, logo and favicon are reaped independently, and a
failing delete still returns 200.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
The sidebar and mobile bar sit on --color-app-sidebar (near-black) in every
theme, while the sign-in card and public page are light. A single uploaded
logo therefore cannot read on both surfaces: dark ink disappears in the
sidebar, white ink disappears on the sign-in card. IIASA.GeoWiki solves this
with BrandingImageType.LogoLight/LogoDark.

Adds a second slot on the same footing as the existing images — settings
field, anonymous GET /api/branding/logo-dark, gated upload/clear, its own
?v= cache-busting and the same reap-on-replace cleanup. Named for the
*surface* rather than the theme, because here the sidebar is dark whatever
the theme is.

Purely additive: with nothing uploaded the payload reports logoDarkUrl null
and callers fall back to logoUrl, so existing deployments are unchanged.
`darkSurfaceLogo()` in packages/ui/lib/brand states that fallback once; only
the two always-dark call sites in SidebarLayout use it, and the footer keeps
the primary logo since it follows the theme on bg-background.

ImageField moves out of Manage.tsx into its own component — a third slot
would have pushed the page past the 300-line cap, and the split is by
responsibility rather than to squeeze under it.

Note: regenerating packages/i18n also picked up keycloak.* keys that were
missing from the checked-in file (it was last generated without keycloak
installed). That is the generator's correct output for the current module
set, kept rather than hand-editing a file marked do-not-edit.

11 new branding tests + 4 for the fallback helper. Full suite 1553 passed,
JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
The root template branded <title> and theme-color server-side but left the
favicon to BrandingHead on the client, with a stated reason: keeping
file_storage's download-route shape out of framework code.

That reason no longer holds. Since branding serves its own favicon the URL
is its own stable path, not file_storage's. Meanwhile the cost was real —
the browser painted the default favicon on every full page load and only
swapped once React hydrated.

branding_head now forwards a favicon_url that the *module* supplies, via a
new BrandingServices.favicon_url property read duck-typed like app_name and
primary_color already are. The framework never learns the route shape, so
the SM009 framework→plugin boundary stays intact, and a host running an
older branding release simply gets None and omits the link. BrandingHead
still applies it client-side, so a favicon changed at runtime updates
without a reload.

Full suite 1556 passed, JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
_reap swallows cleanup faults, but that cannot rescue a failure during
flush — the shared request session is left dirty and the commit at request
end surfaces it regardless. Reaping in the request session is the deliberate
choice (the delete stays atomic with the settings write rather than
orphaning on a late rollback), so say so instead of implying the guard is
total. The test comment now names what it actually covers.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
Ported from IIASA.GeoWiki's top banner (MaxTopBannerMessageLength,
AllowedTopBannerSeverities, NormalizeTopBannerSeverity). Nothing in this
repo's layouts offered any equivalent.

An admin sets a message and a severity (info/warning/danger); an empty
message hides the bar. It renders above all three shells — app, public and
auth — because an outage notice is most useful to people who cannot sign in,
and the shared prop already reaches guest pages.

Validation splits the way design_pack already does here: the settings
validator normalises an unknown severity to "info" (settings hydrate from
the DB and must never stop a boot), while the update DTO rejects it with a
422 so a typo is reported rather than silently downgraded.

Severity colours are semantic rather than brand-tinted — a warning wearing
the deployment's primary colour stops reading as a warning. The bar is
role="status", not "alert": ambient page context should not interrupt a
screen-reader user mid-task. Not dismissible, matching the reference —
per-user dismissal would need storage branding does not own.

16 new tests. Full suite 1573 passed, JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
Ported from IIASA.GeoWiki's BrandingPresets + ApplyPresetAsync: a named
bundle of values applied through the ordinary update path, so every existing
validator still runs (including the design-pack registry check — a preset is
not trusted just because it ships with the module).

One deliberate narrowing. GeoWiki presets set brand name and tagline because
each preset *is* a specific tenant (Global Canopy Atlas, Forest Observation
System). This module ships a generic app, so a preset that overwrote app_name
— or a logo an admin had just uploaded, or a live outage banner — would
destroy exactly the work the branding page exists to do. PRESET_FIELDS
restricts presets to appearance (primary_color, design_pack) and
BrandingPreset.__post_init__ rejects anything else at construction, so a
future preset cannot quietly smuggle in an identity field.

Kept in the module rather than made a registry, matching the reference: a
module that wants to contribute a look ships a design pack, which is already
the registry-backed mechanism for exactly that.

Applying is a direct server action, not staged local state — a preset is a
jump to a known-good look, and a pending-edit affordance would misrepresent
the button.

12 new tests. Full suite 1585 passed, JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
Ported from IIASA.GeoWiki's FooterAppService — brand text, up to 6 columns of
8 links, and a social row, stored as two JSON blobs in the settings store
since there is no branding table. Whole-object replace on PUT, as there: a
partial merge into nested link lists has no obvious semantics.

Previously the footer was rendered by the layout but its content was frozen
framework constants, so a white-labelled deployment still advertised
"SimpleModule / MIT / github.com/antosubash". BrandingFooter now renders the
configured shape when one exists and keeps its original single row otherwise,
so untouched deployments look identical.

validate_href is the security-relevant part and is ported faithfully. These
URLs are admin-authored and rendered into an anchor on every page, guest ones
included, so only http(s) and single-leading-slash app paths are allowed —
javascript: and data: are refused, and "//host" is correctly treated as the
off-site absolute URL it is rather than an in-app path. Reads are lenient
(a mangled settings row degrades to "no footer" rather than breaking every
render); writes are bounded in count and serialised size.

Editor rows carry a client-only id rather than being keyed by array index.
Biome flagged the index keys and was right to: removing a middle row makes
React reuse the wrong DOM node, so the inputs below the gap keep the deleted
row's text. stripIds drops them before the payload is sent.

Two files crossed the 300-line cap as a result, so both were split by
responsibility — FooterCard (its own draft state and save) out of Manage.tsx,
and SidebarUserMenu out of SidebarLayout.tsx.

22 new tests. Full suite 1618 passed, JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
Found by browser QA. Clicking a preset persisted the new colour correctly but
the page kept showing the old one until a manual refresh — the colour field
stayed on the placeholder and no preset showed as active, so the one-click
feature looked broken.

useState only seeds on mount. Every other control on the page edits local
state first, so local and server agree; a preset is the one change that
*originates on the server*, and router.reload() refreshes Inertia props
without re-running the state initialisers.

Sync the fields from the branding shared prop, keyed on the primitive values
rather than the object: its identity changes on every reload, which would
otherwise wipe out whatever the admin was mid-way through typing. Verified
both directions in the browser — applying a preset now updates the field and
the active-preset highlight immediately, and in-progress typing survives.

Full suite 1618 passed, JS 48 passed, make lint clean.

Claude-Session: https://claude.ai/code/session_01XHXqKzFARAVjxuw5yFUNCa
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 41dddde
Status: ✅  Deploy successful!
Preview URL: https://3afc1a35.simple-module-python.pages.dev
Branch Preview URL: https://worktree-branding-improve.simple-module-python.pages.dev

View logs

@antosubash
antosubash merged commit 33b6c1b into main Aug 6, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant