From db239bc1c0a56df714678795e8d18256533e635b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 5 Aug 2026 17:37:29 +0200 Subject: [PATCH 1/9] fix(branding): serve logo + favicon anonymously, harden image uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` — 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=`. 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 `", + b"RIFF\x00\x00\x00\x00WAVE12345", # RIFF container, but not WEBP + ], +) +def test_rejects_anything_without_an_image_signature(data: bytes) -> None: + assert not has_recognized_image_signature(data) + + +def test_svg_is_not_an_allowed_type() -> None: + # Excluded on purpose: an SVG is an XML document that can carry " + with pytest.raises(HTTPException) as exc: + await validate_image(_make(svg, "image/svg+xml")) + assert exc.value.status_code == 415 + + +async def test_rejects_content_type_spoofing() -> None: + # An HTML payload renamed logo.png and declared image/png. Without the + # signature check it would be stored and later served under an image type. + with pytest.raises(HTTPException) as exc: + await validate_image(_make(b"", "image/png")) + assert exc.value.status_code == 415 + assert "does not look like an image" in str(exc.value.detail) + + +async def test_rejects_an_empty_upload() -> None: + with pytest.raises(HTTPException) as exc: + await validate_image(_make(b"", "image/png")) + assert exc.value.status_code == 415 + + +async def test_rejects_an_oversized_image() -> None: + with pytest.raises(HTTPException) as exc: + await validate_image(_make(PNG + b"\x00" * MAX_IMAGE_BYTES, "image/png")) + assert exc.value.status_code == 413 diff --git a/modules/branding/tests/test_public_assets.py b/modules/branding/tests/test_public_assets.py new file mode 100644 index 00000000..268574d0 --- /dev/null +++ b/modules/branding/tests/test_public_assets.py @@ -0,0 +1,185 @@ +"""Branding's logo + favicon must load for a *logged-out* visitor. + +Both are rendered on guest surfaces — ``AuthCardShell`` (sign-in / register), +``PublicLayout`` (the marketing page) and the ```` emitted by +``BrandingHead`` on every page. A white-label deployment is judged on exactly +those screens, so the URL branding publishes has to be anonymously fetchable. + +``file_storage``'s own download route is gated by ``file-storage.download``, +which no anonymous request carries, so branding serves its two assets itself. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +from file_storage.models import StoredFile +from file_storage.service import FileStorageService, StreamDownload + +_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + + +@pytest.fixture +def stored_logo(monkeypatch: pytest.MonkeyPatch) -> StoredFile: + """Stub file_storage so upload + download work without a real backend.""" + row = StoredFile( + id=uuid.uuid4(), + key="2026/06/logo.png", + filename="logo.png", + content_type="image/png", + size_bytes=len(_PNG), + backend="local", + checksum_sha256="a" * 64, + ) + + async def fake_upload(self: FileStorageService, upload: Any) -> StoredFile: + return row + + async def fake_download(self: FileStorageService, file_id: uuid.UUID) -> StreamDownload: + assert file_id == row.id, f"asked for {file_id}, only {row.id} is stored" + + async def body() -> AsyncIterator[bytes]: + yield _PNG + + return StreamDownload(file=row, body=body()) + + monkeypatch.setattr(FileStorageService, "upload", fake_upload, raising=True) + monkeypatch.setattr(FileStorageService, "download", fake_download, raising=True) + return row + + +async def _upload_logo(authenticated_client: httpx.AsyncClient) -> str: + resp = await authenticated_client.post( + "/api/branding/logo", + files={"file": ("logo.png", _PNG, "image/png")}, + ) + assert resp.status_code == 200, resp.text + url = resp.json()["logo_url"] + assert url is not None + return url + + +async def test_logo_url_is_fetchable_by_a_logged_out_visitor( + stored_logo: StoredFile, + authenticated_client: httpx.AsyncClient, + client: httpx.AsyncClient, +) -> None: + # The sign-in page renders this exact URL in an . If it 401s the + # deployment shows a broken image on the one screen every user starts on. + url = await _upload_logo(authenticated_client) + + resp = await client.get(url, follow_redirects=False) + + assert resp.status_code == 200, f"guest got {resp.status_code} for {url}" + assert resp.headers["content-type"].startswith("image/png") + assert resp.content == _PNG + + +async def test_the_published_url_is_versioned_by_the_stored_file_id( + stored_logo: StoredFile, + authenticated_client: httpx.AsyncClient, +) -> None: + # Replacing the logo stores a new file, so the id doubles as a + # content-address: the URL changes and caches invalidate without a purge. + assert await _upload_logo(authenticated_client) == f"/api/branding/logo?v={stored_logo.id}" + + +# ── The anonymous exemption is GET-only ──────────────────────────────── + + +async def test_uploading_a_logo_still_requires_authentication( + client: httpx.AsyncClient, +) -> None: + # The public-route rule is exact + GET, so it must not open the sibling + # POST on the same path to the world. + resp = await client.post("/api/branding/logo", files={"file": ("l.png", _PNG, "image/png")}) + assert resp.status_code in (401, 403) + + +async def test_clearing_a_logo_still_requires_authentication(client: httpx.AsyncClient) -> None: + resp = await client.delete("/api/branding/logo") + assert resp.status_code in (401, 403) + + +# ── Cache policy ─────────────────────────────────────────────────────── + + +async def test_a_versioned_request_is_cached_for_a_year_and_immutable( + stored_logo: StoredFile, + authenticated_client: httpx.AsyncClient, + client: httpx.AsyncClient, +) -> None: + url = await _upload_logo(authenticated_client) + + cache_control = (await client.get(url)).headers["cache-control"] + + assert "public" in cache_control + assert "max-age=31536000" in cache_control + assert "immutable" in cache_control + + +@pytest.mark.parametrize("query", ["", "?v="]) +async def test_a_request_without_a_usable_version_is_not_immutable( + query: str, + stored_logo: StoredFile, + authenticated_client: httpx.AsyncClient, + client: httpx.AsyncClient, +) -> None: + # That URL is stable, so the same address can serve new bytes later. + # Pinning it for a year would strand a stale logo with no way to bust it. + await _upload_logo(authenticated_client) + + cache_control = (await client.get(f"/api/branding/logo{query}")).headers["cache-control"] + + assert "max-age=3600" in cache_control + assert "immutable" not in cache_control + + +async def test_an_unset_image_is_an_uncached_404(client: httpx.AsyncClient) -> None: + # Caching the miss would mask the next upload. + resp = await client.get("/api/branding/logo") + + assert resp.status_code == 404 + assert "cache-control" not in resp.headers + + +async def test_a_dangling_file_reference_is_a_404_not_a_500( + app, + client: httpx.AsyncClient, +) -> None: + # Settings hydrate from the DB, so the referenced file can be gone. + app.state.branding.settings.logo_file_id = str(uuid.uuid4()) + + assert (await client.get("/api/branding/logo")).status_code == 404 + + +async def test_a_non_uuid_file_reference_is_a_404_not_a_500( + app, + client: httpx.AsyncClient, +) -> None: + app.state.branding.settings.logo_file_id = "not-a-uuid" + + assert (await client.get("/api/branding/logo")).status_code == 404 + + +# ── Response hardening ───────────────────────────────────────────────── + + +async def test_the_asset_response_cannot_be_rendered_as_a_document( + stored_logo: StoredFile, + authenticated_client: httpx.AsyncClient, + client: httpx.AsyncClient, +) -> None: + # and ignore Content-Disposition, so the image + # still renders — but a direct visit downloads instead of executing at our + # origin, and nosniff pins the declared type. + url = await _upload_logo(authenticated_client) + + resp = await client.get(url) + + assert resp.headers["content-disposition"] == "attachment" + assert resp.headers["x-content-type-options"] == "nosniff" From bcb2a1fa79c799da1235060e01cfd957cfeb904a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 5 Aug 2026 17:51:35 +0200 Subject: [PATCH 2/9] fix(branding): delete the image a replace or clear stops referencing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/branding/README.md | 4 + modules/branding/branding/deps.py | 8 +- modules/branding/branding/service.py | 53 ++++++- .../branding/tests/test_asset_lifecycle.py | 143 ++++++++++++++++++ 4 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 modules/branding/tests/test_asset_lifecycle.py diff --git a/modules/branding/README.md b/modules/branding/README.md index 8a05c6da..f0cab2a8 100644 --- a/modules/branding/README.md +++ b/modules/branding/README.md @@ -62,6 +62,10 @@ Programmatically, the current branding is available on every page through the are served `public, max-age=31536000, immutable`; a request without a usable version gets `public, max-age=3600` so it self-corrects, and a 404 is never cached. +- **Lifecycle.** Replacing or clearing an image deletes the file it stopped + referencing, so repeated logo tweaks don't leave orphans in `file_storage`. + Cleanup is best effort: the setting change has already been persisted, so a + storage fault is logged rather than failing an otherwise-successful rebrand. - **Upload validation.** PNG, JPEG, WEBP, GIF and ICO up to 2 MB. The declared content-type is caller-controlled, so the first bytes are also checked against each format's magic number — a payload renamed `logo.png` is diff --git a/modules/branding/branding/deps.py b/modules/branding/branding/deps.py index 18a7f4d4..98df5730 100644 --- a/modules/branding/branding/deps.py +++ b/modules/branding/branding/deps.py @@ -5,6 +5,8 @@ from typing import Annotated from fastapi import Depends, Request +from file_storage.deps import get_file_storage_service +from file_storage.service import FileStorageService from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession @@ -14,8 +16,12 @@ async def get_branding_service( request: Request, db: AsyncSession = Depends(get_db), + storage: FileStorageService = Depends(get_file_storage_service), ) -> BrandingService: - return BrandingService(request.app, db) + # FastAPI caches dependencies per request, so ``storage`` shares this very + # session: reaping a replaced image commits or rolls back with the settings + # write rather than in a transaction of its own. + return BrandingService(request.app, db, storage) BrandingServiceDep = Annotated[BrandingService, Depends(get_branding_service)] diff --git a/modules/branding/branding/service.py b/modules/branding/branding/service.py index 07970e2e..f4c5be82 100644 --- a/modules/branding/branding/service.py +++ b/modules/branding/branding/service.py @@ -8,6 +8,8 @@ from __future__ import annotations +import logging +import uuid from typing import TYPE_CHECKING, Any from branding.constants import FAVICON_URL, LOGO_URL, PACKAGE @@ -16,15 +18,27 @@ if TYPE_CHECKING: from fastapi import FastAPI + from file_storage.service import FileStorageService from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger(__name__) + class BrandingService: """Read/update the application's branding.""" - def __init__(self, app: FastAPI, db: AsyncSession) -> None: + def __init__( + self, + app: FastAPI, + db: AsyncSession, + storage: FileStorageService | None = None, + ) -> None: self.app = app self.db = db + # Optional so the published constructor stays backwards compatible. + # Without it a replaced image simply isn't reaped — the rebrand itself + # is unaffected. The module's own dependency always supplies one. + self.storage = storage def current(self) -> BrandingOut: settings = self.app.state.branding.settings @@ -49,14 +63,43 @@ async def apply(self, changes: dict[str, Any]) -> BrandingOut: await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes) return self.current() + async def _swap_asset(self, field: str, file_id: str) -> BrandingOut: + """Point *field* at *file_id* ("" to clear) and reap what it replaced. + + Every upload mints a new ``file_storage`` id, so the file we stop + referencing here would otherwise sit in the store forever with nothing + left to reference or reap it. + """ + previous = getattr(self.app.state.branding.settings, field, "") + out = await self.apply({field: file_id}) + if previous and previous != file_id: + await self._reap(previous) + return out + + async def _reap(self, file_id: str) -> None: + """Delete a no-longer-referenced branding image, best effort. + + The setting change has already been persisted and is what the admin + asked for, so a storage fault (or a hand-edited, non-UUID setting) must + be logged rather than turned into a 500 on a successful rebrand. + """ + if self.storage is None: + return + try: + await self.storage.delete(uuid.UUID(file_id)) + except Exception: + # Deliberately broad: any failure here is a cleanup problem, never + # a reason to reject a rebrand the admin already succeeded at. + logger.warning("Could not delete replaced branding image %s.", file_id, exc_info=True) + async def set_logo(self, file_id: str) -> BrandingOut: - return await self.apply({"logo_file_id": file_id}) + return await self._swap_asset("logo_file_id", file_id) async def set_favicon(self, file_id: str) -> BrandingOut: - return await self.apply({"favicon_file_id": file_id}) + return await self._swap_asset("favicon_file_id", file_id) async def clear_logo(self) -> BrandingOut: - return await self.apply({"logo_file_id": ""}) + return await self._swap_asset("logo_file_id", "") async def clear_favicon(self) -> BrandingOut: - return await self.apply({"favicon_file_id": ""}) + return await self._swap_asset("favicon_file_id", "") diff --git a/modules/branding/tests/test_asset_lifecycle.py b/modules/branding/tests/test_asset_lifecycle.py new file mode 100644 index 00000000..c215d445 --- /dev/null +++ b/modules/branding/tests/test_asset_lifecycle.py @@ -0,0 +1,143 @@ +"""Replacing or clearing a branding image must not leak the previous file. + +Each upload mints a *new* ``file_storage`` id (that is what makes the published +URL content-addressed), so unlike IIASA.GeoWiki — which overwrites one blob at a +fixed key per image type and so has nothing to clean up — this module has to +delete the file it just stopped referencing. Otherwise every logo tweak leaves +another orphan in the store that nothing will ever reference or reap. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import httpx +import pytest +from file_storage.models import StoredFile +from file_storage.service import FileStorageService, StoredFileNotFoundError + +_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + + +class FakeStore: + """Mints a fresh row per upload and records what got deleted.""" + + def __init__(self) -> None: + self.uploaded: list[uuid.UUID] = [] + self.deleted: list[uuid.UUID] = [] + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + store = self + + async def fake_upload(self: FileStorageService, upload: Any) -> StoredFile: + row = StoredFile( + id=uuid.uuid4(), + key=f"2026/06/{uuid.uuid4()}.png", + filename="logo.png", + content_type="image/png", + size_bytes=len(_PNG), + backend="local", + checksum_sha256="a" * 64, + ) + store.uploaded.append(row.id) + return row + + async def fake_delete(self: FileStorageService, file_id: uuid.UUID) -> StoredFile: + if file_id in store.deleted: + raise StoredFileNotFoundError(str(file_id)) + store.deleted.append(file_id) + return StoredFile( + id=file_id, + key="k", + filename="logo.png", + content_type="image/png", + size_bytes=0, + backend="local", + checksum_sha256="a" * 64, + ) + + monkeypatch.setattr(FileStorageService, "upload", fake_upload, raising=True) + monkeypatch.setattr(FileStorageService, "delete", fake_delete, raising=True) + + +@pytest.fixture +def store(monkeypatch: pytest.MonkeyPatch) -> FakeStore: + fake = FakeStore() + fake.install(monkeypatch) + return fake + + +async def _upload(client: httpx.AsyncClient, kind: str) -> None: + resp = await client.post( + f"/api/branding/{kind}", + files={"file": (f"{kind}.png", _PNG, "image/png")}, + ) + assert resp.status_code == 200, resp.text + + +async def test_replacing_a_logo_deletes_the_previous_file( + store: FakeStore, authenticated_client: httpx.AsyncClient +) -> None: + await _upload(authenticated_client, "logo") + await _upload(authenticated_client, "logo") + + first, second = store.uploaded + assert store.deleted == [first], "the replaced logo was left orphaned in file_storage" + assert second not in store.deleted, "the logo now in use must survive" + + +async def test_clearing_a_logo_deletes_the_file( + store: FakeStore, authenticated_client: httpx.AsyncClient +) -> None: + await _upload(authenticated_client, "logo") + + resp = await authenticated_client.delete("/api/branding/logo") + + assert resp.status_code == 200, resp.text + assert store.deleted == store.uploaded + + +async def test_clearing_twice_is_harmless( + store: FakeStore, authenticated_client: httpx.AsyncClient +) -> None: + # Nothing referenced any more, so the second clear has nothing to delete. + await _upload(authenticated_client, "logo") + await authenticated_client.delete("/api/branding/logo") + + resp = await authenticated_client.delete("/api/branding/logo") + + assert resp.status_code == 200, resp.text + assert len(store.deleted) == 1 + + +async def test_logo_and_favicon_are_reaped_independently( + store: FakeStore, authenticated_client: httpx.AsyncClient +) -> None: + await _upload(authenticated_client, "logo") + await _upload(authenticated_client, "favicon") + + await authenticated_client.delete("/api/branding/logo") + + logo_id, favicon_id = store.uploaded + assert store.deleted == [logo_id] + assert favicon_id not in store.deleted + + +async def test_a_failed_cleanup_does_not_fail_the_rebrand( + store: FakeStore, authenticated_client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + # The setting change already succeeded and is what the admin asked for. A + # storage blip while reaping the old file must not surface as a 500 on an + # otherwise-successful rebrand. + await _upload(authenticated_client, "logo") + + async def boom(self: FileStorageService, file_id: uuid.UUID) -> StoredFile: + raise RuntimeError("backend unavailable") + + monkeypatch.setattr(FileStorageService, "delete", boom, raising=True) + + resp = await authenticated_client.delete("/api/branding/logo") + + assert resp.status_code == 200, resp.text + assert resp.json()["logo_url"] is None From fa1ec4b734084efaf5bfdb4d37850cc949e1fdd5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 5 Aug 2026 18:01:53 +0200 Subject: [PATCH 3/9] feat(branding): optional dark-background logo variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/branding/README.md | 17 +- .../branding/components/ImageField.tsx | 81 +++++++++ modules/branding/branding/constants.py | 2 + .../branding/branding/contracts/schemas.py | 2 + modules/branding/branding/endpoints/api.py | 16 ++ modules/branding/branding/endpoints/assets.py | 8 + modules/branding/branding/locales/en.json | 2 + modules/branding/branding/module.py | 2 +- modules/branding/branding/pages/Manage.tsx | 85 ++------- modules/branding/branding/service.py | 9 +- modules/branding/branding/settings.py | 4 + modules/branding/branding/shared_props.py | 10 +- modules/branding/tests/test_branding.py | 2 + modules/branding/tests/test_logo_dark.py | 169 ++++++++++++++++++ packages/i18n/src/generated-resources.ts | 2 + packages/i18n/src/keys.generated.ts | 2 + packages/ui/src/layouts/SidebarLayout.tsx | 9 +- packages/ui/src/lib/brand.test.ts | 21 +++ packages/ui/src/lib/brand.ts | 14 ++ packages/ui/src/types.ts | 6 + 20 files changed, 384 insertions(+), 79 deletions(-) create mode 100644 modules/branding/branding/components/ImageField.tsx create mode 100644 modules/branding/tests/test_logo_dark.py create mode 100644 packages/ui/src/lib/brand.test.ts diff --git a/modules/branding/README.md b/modules/branding/README.md index f0cab2a8..e05e1429 100644 --- a/modules/branding/README.md +++ b/modules/branding/README.md @@ -2,8 +2,9 @@ Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps. -An administrator can set the **application name**, **logo**, **favicon** and -**primary brand colour** from the admin UI (`/branding`), and those values are +An administrator can set the **application name**, **logo** (plus an optional +**dark-background variant**), **favicon** and **primary brand colour** from the +admin UI (`/branding`), and those values are applied everywhere the framework would otherwise show the default identity — the sidebar/header logo and name, the browser tab title, the favicon, and the primary accent colour. @@ -41,8 +42,10 @@ modules to be installed too. favicon. Changes apply immediately across the app. Programmatically, the current branding is available on every page through the -`branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`, -`faviconUrl`). +`branding` Inertia shared prop (`appName`, `primaryColor`, `designPack`, +`logoUrl`, `logoDarkUrl`, `faviconUrl`). For a dark surface use +`darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies +the `logoDarkUrl → logoUrl` fallback in one place. ## How it works @@ -62,6 +65,12 @@ Programmatically, the current branding is available on every page through the are served `public, max-age=31536000, immutable`; a request without a usable version gets `public, max-age=3600` so it self-corrects, and a 404 is never cached. +- **Dark-background logo.** The sidebar and mobile bar sit on a near-black + surface in every theme, while the sign-in card and public page are light — so + a single logo cannot read on both. Uploading a *Logo (dark backgrounds)* + variant swaps it in on those surfaces only. It is optional: with none set the + shared prop reports `logoDarkUrl: null` and the frontend falls back to + `logoUrl`, so single-logo deployments look exactly as they did. - **Lifecycle.** Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in `file_storage`. Cleanup is best effort: the setting change has already been persisted, so a diff --git a/modules/branding/branding/components/ImageField.tsx b/modules/branding/branding/components/ImageField.tsx new file mode 100644 index 00000000..c4c392fc --- /dev/null +++ b/modules/branding/branding/components/ImageField.tsx @@ -0,0 +1,81 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Label } from '@simple-module-py/ui/components/ui/label'; +import { type ChangeEvent, useRef } from 'react'; + +/** + * Mirrors the server allow-list in `branding/images.py`. SVG is absent on + * purpose — it is an XML document that can carry