diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index 78522c0d..8c23bc30 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -30,20 +30,25 @@ def branding_head(request: Request) -> dict: """Branding metadata for the root template's ````. Reads the optional branding module's settings off ``app.state`` by name - (duck-typed, never imported) so the static ```` and ``theme-color`` - are already branded *before* React hydrates. Degrades to the framework - default when branding isn't installed. Only plain settings strings are - surfaced here — the favicon is applied client-side by ``BrandingHead`` via - Inertia's ``<Head>``, keeping file_storage's download-route shape out of - framework code. + (duck-typed, never imported) so the ``<title>``, ``theme-color`` and + favicon are already branded *before* React hydrates — otherwise the browser + paints the default favicon and only swaps on hydration, a visible flicker on + every full page load. Degrades to the framework default when branding isn't + installed. + + The favicon URL is read from the module rather than assembled here: branding + owns its route shape, and framework code must not reach into a plugin + (SM009). ``BrandingHead`` still applies it client-side too, so a favicon + changed at runtime updates without a reload. """ services = getattr(request.app.state, "branding", None) settings = getattr(services, "settings", None) if settings is None: - return {"app_name": _DEFAULT_APP_NAME, "theme_color": None} + return {"app_name": _DEFAULT_APP_NAME, "theme_color": None, "favicon_url": None} return { "app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME, "theme_color": getattr(settings, "primary_color", "") or None, + "favicon_url": getattr(services, "favicon_url", None), } diff --git a/framework/hosting/tests/test_branding_head.py b/framework/hosting/tests/test_branding_head.py index 47a8f393..fe3790b8 100644 --- a/framework/hosting/tests/test_branding_head.py +++ b/framework/hosting/tests/test_branding_head.py @@ -16,7 +16,7 @@ def _request(branding: object | None) -> SimpleNamespace: def test_defaults_when_branding_not_installed() -> None: meta = branding_head(_request(None)) - assert meta == {"app_name": "SimpleModule", "theme_color": None} + assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None} def test_reads_app_name_and_theme_color() -> None: @@ -29,4 +29,21 @@ def test_reads_app_name_and_theme_color() -> None: def test_blank_values_fall_back() -> None: settings = SimpleNamespace(app_name="", primary_color="") meta = branding_head(_request(SimpleNamespace(settings=settings))) - assert meta == {"app_name": "SimpleModule", "theme_color": None} + assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None} + + +def test_favicon_url_comes_from_the_module_not_from_here() -> None: + # The framework must not know branding's route shape (SM009), so it reads + # whatever the module exposes rather than assembling a URL itself. + services = SimpleNamespace( + settings=SimpleNamespace(app_name="Acme", primary_color=""), + favicon_url="/api/branding/favicon?v=abc", + ) + assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc" + + +def test_favicon_url_is_none_on_a_host_without_that_attribute() -> None: + # An older branding release exposes no favicon_url; the shell just omits + # the link rather than erroring, and BrandingHead still sets it client-side. + services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color="")) + assert branding_head(_request(services))["favicon_url"] is None diff --git a/host/templates/index.html b/host/templates/index.html index 0ba8fe17..4ca6d743 100644 --- a/host/templates/index.html +++ b/host/templates/index.html @@ -6,6 +6,7 @@ {% set _brand = branding_head(request) %} <title>{{ _brand.app_name }} {% if _brand.theme_color %}{% endif %} + {% if _brand.favicon_url %}{% endif %} diff --git a/modules/branding/README.md b/modules/branding/README.md index 6d9a96f8..c90d4c42 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,13 @@ 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`, `banner`, `footer`). `banner` and +`footer` are `null` when unconfigured, which is what makes the frontend fall +back to rendering nothing and to the framework footer respectively. 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 @@ -50,7 +56,49 @@ Programmatically, the current branding is available on every page through the (SYSTEM scope) — there is no branding database table. They hydrate into `app.state.branding.settings` at boot and hot-swap on save. - **Images.** Logo and favicon uploads are stored through the `file_storage` - module (referenced by UUID) and served from its download endpoint. + module (referenced by UUID). Branding serves them back from its own + **anonymous** routes, `GET /api/branding/logo` and `GET /api/branding/favicon` + — `file_storage`'s download endpoint requires `file-storage.download`, which + no logged-out visitor has, and the sign-in page is exactly where the logo + must appear. Only the two ids currently held in branding settings are served, + so this is not a way to read arbitrary files. Uploading and clearing on those + same paths stay behind `branding.manage` (the exemption is GET-only). +- **Caching.** The published URL carries `?v=`; a replaced image is a + new `file_storage` id, so the URL is content-addressed. Versioned requests + 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. +- **Announcement banner.** A message plus a severity (`info` / `warning` / + `danger`) rendered above every shell — app, public and auth — because an + outage notice is most useful to people who cannot sign in. An empty message + hides it. Severity colours are semantic, not brand-tinted: a warning wearing + the deployment's accent colour stops reading as a warning. +- **Presets.** One-click looks (`POST /api/branding/presets/{key}`), applied + through the ordinary update path so every validator still runs. A preset only + ever sets *appearance* (`PRESET_FIELDS` — primary colour, design pack); it + can never overwrite the app name, an uploaded logo or a live banner, and + `BrandingPreset` rejects any other field at construction. +- **Configurable footer.** Tagline, copyright owner, caption, up to 6 columns + of 8 links, and up to 8 social links (`PUT /api/branding/footer`, whole-object + replace). 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. With nothing configured the + framework's built-in footer renders unchanged. +- **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 + 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 + rejected. SVG is excluded on purpose: 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_logo_dark.py b/modules/branding/tests/test_logo_dark.py new file mode 100644 index 00000000..5e9e07d3 --- /dev/null +++ b/modules/branding/tests/test_logo_dark.py @@ -0,0 +1,169 @@ +"""A second logo for the app's always-dark surfaces. + +The sidebar and mobile bar sit on ``--color-app-sidebar`` (near-black) in every +theme, while the auth card and public page are light. One uploaded logo cannot +read on both: dark ink vanishes in the sidebar, white ink vanishes on the auth +card. IIASA.GeoWiki solves this with ``BrandingImageType.LogoLight``/ +``LogoDark``; this is the same idea, named for the *surface* rather than the +theme because here the sidebar is dark regardless of theme. + +Purely additive: with no dark variant uploaded the payload reports ``None`` and +the frontend falls back to the primary logo, so existing sites are unchanged. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +from branding.settings import BrandingSettings +from branding.shared_props import branding_payload +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(monkeypatch: pytest.MonkeyPatch) -> dict[uuid.UUID, StoredFile]: + rows: dict[uuid.UUID, StoredFile] = {} + + 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, + ) + rows[row.id] = row + return row + + async def fake_download(self: FileStorageService, file_id: uuid.UUID) -> StreamDownload: + async def body() -> AsyncIterator[bytes]: + yield _PNG + + return StreamDownload(file=rows[file_id], body=body()) + + async def fake_delete(self: FileStorageService, file_id: uuid.UUID) -> StoredFile: + return rows.pop(file_id) + + monkeypatch.setattr(FileStorageService, "upload", fake_upload, raising=True) + monkeypatch.setattr(FileStorageService, "download", fake_download, raising=True) + monkeypatch.setattr(FileStorageService, "delete", fake_delete, raising=True) + return rows + + +# ── Unit: settings + payload ─────────────────────────────────────────── + + +def test_defaults_to_no_dark_variant() -> None: + assert BrandingSettings().logo_dark_file_id == "" + + +def test_payload_reports_no_dark_variant_as_none() -> None: + # None is the signal for "fall back to logoUrl" on the frontend. + assert branding_payload(BrandingSettings(logo_file_id="abc"))["logoDarkUrl"] is None + + +def test_payload_carries_the_dark_variant_on_its_own_route() -> None: + payload = branding_payload(BrandingSettings(logo_file_id="abc", logo_dark_file_id="def")) + assert payload["logoUrl"] == "/api/branding/logo?v=abc" + assert payload["logoDarkUrl"] == "/api/branding/logo-dark?v=def" + + +# ── Integration: upload, serve, clear ────────────────────────────────── + + +async def test_uploading_a_dark_logo_leaves_the_primary_logo_alone( + stored: dict[uuid.UUID, StoredFile], + app, + authenticated_client: httpx.AsyncClient, +) -> None: + await authenticated_client.post( + "/api/branding/logo", files={"file": ("l.png", _PNG, "image/png")} + ) + primary = app.state.branding.settings.logo_file_id + + resp = await authenticated_client.post( + "/api/branding/logo-dark", files={"file": ("d.png", _PNG, "image/png")} + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert app.state.branding.settings.logo_file_id == primary + assert body["logo_url"] == f"/api/branding/logo?v={primary}" + assert body["logo_dark_url"].startswith("/api/branding/logo-dark?v=") + + +async def test_the_dark_logo_is_fetchable_by_a_logged_out_visitor( + stored: dict[uuid.UUID, StoredFile], + authenticated_client: httpx.AsyncClient, + client: httpx.AsyncClient, +) -> None: + # It renders in the sidebar, which a guest never sees — but the shared prop + # is emitted on guest pages too, so the route has to behave like its sibling. + resp = await authenticated_client.post( + "/api/branding/logo-dark", files={"file": ("d.png", _PNG, "image/png")} + ) + url = resp.json()["logo_dark_url"] + + guest = await client.get(url, follow_redirects=False) + + assert guest.status_code == 200 + assert guest.content == _PNG + + +async def test_uploading_a_dark_logo_still_requires_authentication( + client: httpx.AsyncClient, +) -> None: + resp = await client.post( + "/api/branding/logo-dark", files={"file": ("d.png", _PNG, "image/png")} + ) + assert resp.status_code in (401, 403) + + +async def test_clearing_the_dark_logo_reverts_to_the_primary( + stored: dict[uuid.UUID, StoredFile], + authenticated_client: httpx.AsyncClient, +) -> None: + await authenticated_client.post( + "/api/branding/logo", files={"file": ("l.png", _PNG, "image/png")} + ) + await authenticated_client.post( + "/api/branding/logo-dark", files={"file": ("d.png", _PNG, "image/png")} + ) + + resp = await authenticated_client.delete("/api/branding/logo-dark") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["logo_dark_url"] is None + assert body["logo_url"] is not None + + +async def test_replacing_the_dark_logo_reaps_the_previous_one( + stored: dict[uuid.UUID, StoredFile], + authenticated_client: httpx.AsyncClient, +) -> None: + first = await authenticated_client.post( + "/api/branding/logo-dark", files={"file": ("d.png", _PNG, "image/png")} + ) + first_id = first.json()["logo_dark_url"].split("=")[-1] + + await authenticated_client.post( + "/api/branding/logo-dark", files={"file": ("d2.png", _PNG, "image/png")} + ) + + assert uuid.UUID(first_id) not in stored, "the replaced dark logo was orphaned" + + +async def test_an_unset_dark_logo_is_a_404(client: httpx.AsyncClient) -> None: + # The frontend never requests it in this state — it falls back to logoUrl — + # but a hand-typed URL must not 500. + assert (await client.get("/api/branding/logo-dark")).status_code == 404 diff --git a/modules/branding/tests/test_presets.py b/modules/branding/tests/test_presets.py new file mode 100644 index 00000000..f50a0c93 --- /dev/null +++ b/modules/branding/tests/test_presets.py @@ -0,0 +1,116 @@ +"""One-click branding presets. + +Ported from IIASA.GeoWiki's ``BrandingPresets`` + ``ApplyPresetAsync``, with one +deliberate narrowing: GeoWiki presets set brand name and tagline because each +preset *is* a tenant, whereas here a preset is only a look. Overwriting the app +name or a freshly uploaded logo would destroy the work this page exists to do. +""" + +from __future__ import annotations + +import httpx +import pytest +from branding.presets import BUILTIN_PRESETS, PRESET_FIELDS, BrandingPreset, find_preset + +# ── Unit: definitions ────────────────────────────────────────────────── + + +def test_presets_ship_and_have_unique_keys() -> None: + keys = [p.key for p in BUILTIN_PRESETS] + assert len(keys) == len(set(keys)), f"duplicate preset keys: {keys}" + assert keys, "at least one built-in preset must ship" + + +def test_every_preset_only_touches_appearance() -> None: + # The guard that keeps a preset from clobbering deployment identity. + for preset in BUILTIN_PRESETS: + assert set(preset.values) <= PRESET_FIELDS, preset.key + + +def test_every_preset_sets_a_valid_hex_colour() -> None: + from branding.constants import HEX_COLOR_RE + + for preset in BUILTIN_PRESETS: + colour = preset.values.get("primary_color") + assert colour and HEX_COLOR_RE.match(colour), preset.key + + +def test_a_preset_cannot_declare_a_non_preset_field() -> None: + # Catches a future preset that tries to smuggle in an identity field. + with pytest.raises(ValueError, match="app_name"): + BrandingPreset("bad", "Bad", {"app_name": "Acme"}) + + +def test_swatch_exposes_the_colour_for_the_picker() -> None: + assert BrandingPreset("x", "X", {"primary_color": "#123456"}).swatch == "#123456" + assert BrandingPreset("y", "Y").swatch is None + + +def test_find_preset_misses_cleanly() -> None: + assert find_preset(BUILTIN_PRESETS[0].key) is BUILTIN_PRESETS[0] + assert find_preset("nope") is None + + +# ── Integration: applying ────────────────────────────────────────────── + + +async def test_applying_a_preset_changes_the_colour( + app, authenticated_client: httpx.AsyncClient +) -> None: + preset = BUILTIN_PRESETS[1] + + resp = await authenticated_client.post(f"/api/branding/presets/{preset.key}") + + assert resp.status_code == 200, resp.text + assert resp.json()["primary_color"] == preset.values["primary_color"] + assert app.state.branding.settings.primary_color == preset.values["primary_color"] + + +async def test_applying_a_preset_preserves_identity_and_banner( + app, authenticated_client: httpx.AsyncClient +) -> None: + # The whole point of restricting PRESET_FIELDS — a look must not wipe the + # name an admin set or an outage notice that is currently live. + await authenticated_client.put( + "/api/branding/", + json={"app_name": "Acme Corp", "banner_message": "Maintenance tonight"}, + ) + + resp = await authenticated_client.post(f"/api/branding/presets/{BUILTIN_PRESETS[2].key}") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["app_name"] == "Acme Corp" + assert body["banner_message"] == "Maintenance tonight" + + +async def test_applying_a_preset_keeps_an_uploaded_logo( + app, authenticated_client: httpx.AsyncClient +) -> None: + app.state.branding.settings.logo_file_id = "11111111-1111-1111-1111-111111111111" + + await authenticated_client.post(f"/api/branding/presets/{BUILTIN_PRESETS[0].key}") + + assert app.state.branding.settings.logo_file_id == "11111111-1111-1111-1111-111111111111" + + +async def test_an_unknown_preset_is_a_404(authenticated_client: httpx.AsyncClient) -> None: + resp = await authenticated_client.post("/api/branding/presets/does-not-exist") + assert resp.status_code == 404 + + +async def test_applying_a_preset_requires_the_manage_permission( + client: httpx.AsyncClient, +) -> None: + resp = await client.post(f"/api/branding/presets/{BUILTIN_PRESETS[0].key}") + assert resp.status_code in (401, 403) + + +async def test_the_manage_page_lists_the_presets( + authenticated_client: httpx.AsyncClient, +) -> None: + page = await authenticated_client.get("/branding/", follow_redirects=False) + + assert page.status_code == 200, page.status_code + assert "presets" in page.text + assert BUILTIN_PRESETS[0].label in page.text diff --git a/modules/branding/tests/test_public_assets.py b/modules/branding/tests/test_public_assets.py new file mode 100644 index 00000000..d6735046 --- /dev/null +++ b/modules/branding/tests/test_public_assets.py @@ -0,0 +1,200 @@ +"""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" + + +async def test_the_prehydration_shell_carries_the_branded_favicon( + stored_logo: StoredFile, + app, + authenticated_client: httpx.AsyncClient, +) -> None: + # Without a server-rendered the browser paints the + # default favicon and only swaps once React hydrates — a visible flicker + # on every full page load, most obviously on the sign-in page. + app.state.branding.settings.favicon_file_id = str(stored_logo.id) + + page = await authenticated_client.get("/branding/", follow_redirects=False) + + assert f'rel="icon" href="/api/branding/favicon?v={stored_logo.id}"' in page.text diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 2a79c229..7c4ad3a6 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -78,6 +78,12 @@ export default { 'background_tasks.toasts.retry_failed': '', 'branding.manage.app_name_help': '', 'branding.manage.app_name_label': '', + 'branding.manage.banner_help': '', + 'branding.manage.banner_label': '', + 'branding.manage.banner_placeholder': '', + 'branding.manage.banner_severity_danger': '', + 'branding.manage.banner_severity_info': '', + 'branding.manage.banner_severity_warning': '', 'branding.manage.description': '', 'branding.manage.design_pack_empty': '', 'branding.manage.design_pack_help': '', @@ -86,8 +92,27 @@ export default { 'branding.manage.error_toast': '', 'branding.manage.favicon_help': '', 'branding.manage.favicon_label': '', + 'branding.manage.footer_add_column': '', + 'branding.manage.footer_add_link': '', + 'branding.manage.footer_column_title': '', + 'branding.manage.footer_copyright_label': '', + 'branding.manage.footer_help': '', + 'branding.manage.footer_link_href': '', + 'branding.manage.footer_link_label': '', + 'branding.manage.footer_note_label': '', + 'branding.manage.footer_remove_column': '', + 'branding.manage.footer_remove_link': '', + 'branding.manage.footer_save_button': '', + 'branding.manage.footer_saved_toast': '', + 'branding.manage.footer_social_label': '', + 'branding.manage.footer_tagline_label': '', + 'branding.manage.footer_title': '', + 'branding.manage.logo_dark_help': '', + 'branding.manage.logo_dark_label': '', 'branding.manage.logo_help': '', 'branding.manage.logo_label': '', + 'branding.manage.preset_help': '', + 'branding.manage.preset_label': '', 'branding.manage.preview_title': '', 'branding.manage.primary_color_help': '', 'branding.manage.primary_color_label': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index fb055a3b..dc9c7220 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -113,6 +113,12 @@ export const keys = { manage: { app_name_help: 'branding.manage.app_name_help', app_name_label: 'branding.manage.app_name_label', + banner_help: 'branding.manage.banner_help', + banner_label: 'branding.manage.banner_label', + banner_placeholder: 'branding.manage.banner_placeholder', + banner_severity_danger: 'branding.manage.banner_severity_danger', + banner_severity_info: 'branding.manage.banner_severity_info', + banner_severity_warning: 'branding.manage.banner_severity_warning', description: 'branding.manage.description', design_pack_empty: 'branding.manage.design_pack_empty', design_pack_help: 'branding.manage.design_pack_help', @@ -121,8 +127,27 @@ export const keys = { error_toast: 'branding.manage.error_toast', favicon_help: 'branding.manage.favicon_help', favicon_label: 'branding.manage.favicon_label', + footer_add_column: 'branding.manage.footer_add_column', + footer_add_link: 'branding.manage.footer_add_link', + footer_column_title: 'branding.manage.footer_column_title', + footer_copyright_label: 'branding.manage.footer_copyright_label', + footer_help: 'branding.manage.footer_help', + footer_link_href: 'branding.manage.footer_link_href', + footer_link_label: 'branding.manage.footer_link_label', + footer_note_label: 'branding.manage.footer_note_label', + footer_remove_column: 'branding.manage.footer_remove_column', + footer_remove_link: 'branding.manage.footer_remove_link', + footer_save_button: 'branding.manage.footer_save_button', + footer_saved_toast: 'branding.manage.footer_saved_toast', + footer_social_label: 'branding.manage.footer_social_label', + footer_tagline_label: 'branding.manage.footer_tagline_label', + footer_title: 'branding.manage.footer_title', + logo_dark_help: 'branding.manage.logo_dark_help', + logo_dark_label: 'branding.manage.logo_dark_label', logo_help: 'branding.manage.logo_help', logo_label: 'branding.manage.logo_label', + preset_help: 'branding.manage.preset_help', + preset_label: 'branding.manage.preset_label', preview_title: 'branding.manage.preview_title', primary_color_help: 'branding.manage.primary_color_help', primary_color_label: 'branding.manage.primary_color_label', diff --git a/packages/ui/src/components/BrandingBanner.tsx b/packages/ui/src/components/BrandingBanner.tsx new file mode 100644 index 00000000..3242af77 --- /dev/null +++ b/packages/ui/src/components/BrandingBanner.tsx @@ -0,0 +1,40 @@ +import { usePage } from '@inertiajs/react'; +import type React from 'react'; +import type { SharedProps } from '../types'; + +/** + * Severity → colour. Deliberately semantic rather than brand-tinted: a warning + * that adopted the deployment's primary colour would stop reading as a warning. + */ +const SEVERITY_CLASS: Record = { + info: 'bg-sky-600 text-white', + warning: 'bg-amber-500 text-black', + danger: 'bg-red-600 text-white', +}; + +/** + * Site-wide announcement bar, rendered above every shell (app, public and + * auth), driven by the `branding.banner` shared prop. + * + * Renders nothing when no message is set, so the layouts can mount it + * unconditionally. Not dismissible by design — an admin sets it precisely + * because everyone should see it, and per-user dismissal state would need + * storage the branding module does not own. + */ +export function BrandingBanner(): React.ReactElement | null { + const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const banner = branding?.banner ?? null; + if (!banner?.message) return null; + + const tone = SEVERITY_CLASS[banner.severity] ?? SEVERITY_CLASS.info; + return ( +
+ {banner.message} +
+ ); +} diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx index 257cd400..1e17d0c0 100644 --- a/packages/ui/src/components/BrandingFooter.tsx +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -1,4 +1,5 @@ import { BRAND_ACCENT, BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; +import type { FooterShared } from '../types'; import { BrandingMark } from './BrandingMark'; /** Stable for the lifetime of the bundle — the year only matters at page load. */ @@ -14,41 +15,110 @@ interface BrandingFooterProps { * the full content width of the sidebar shell. */ variant?: 'app' | 'public'; + /** + * Admin-configured footer. When absent the framework footer below is used + * unchanged, so a deployment that never configures one is unaffected. + */ + footer?: FooterShared | null; +} + +function FooterLinkAnchor({ label, href }: { label: string; href: string }) { + // Server-side `validate_href` restricts these to http(s) and in-app paths. + // `noopener` still matters: an external target must not get window.opener. + const external = !href.startsWith('/'); + return ( + + {label} + + ); } /** - * App-wide footer: brand lockup on the left, framework links on the right. - * Presentational (props-driven) so it renders without Inertia context and is - * shared by both the authenticated shell and the public layout. + * App-wide footer: brand lockup on the left, links on the right. + * + * Renders one of two shapes. With no configured footer it keeps the framework's + * single row of project links. Once an admin configures columns or social + * links it becomes a multi-column footer with the brand block above a bottom + * bar. Presentational (props-driven) so it renders without Inertia context and + * is shared by both the authenticated shell and the public layout. */ export function BrandingFooter({ appName, logoUrl, variant = 'app', + footer = null, }: BrandingFooterProps): React.ReactElement { const container = variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; + const configured = footer && (footer.columns.length > 0 || footer.socialLinks.length > 0); + const caption = footer?.note || `© ${FOOTER_YEAR} · ${BRAND_LICENSE}`; + const mark = ( + + ); + + if (!configured) { + return ( +
+
+
{mark}
+ +
+
+ ); + } return (
-
-
- +
+
+
+
{mark}
+ {footer?.tagline && ( +

{footer.tagline}

+ )} +
+
+ {footer?.columns.map((column) => ( + + ))} +
- + {(footer?.copyrightOwner || (footer?.socialLinks.length ?? 0) > 0) && ( +
+ {footer?.copyrightOwner ? `© ${FOOTER_YEAR} ${footer.copyrightOwner}` : ''} + +
+ )}
); diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx index d7948bcc..df92cb5a 100644 --- a/packages/ui/src/layouts/AuthCardShell.tsx +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -1,5 +1,6 @@ import { usePage } from '@inertiajs/react'; import type React from 'react'; +import { BrandingBanner } from '../components/BrandingBanner'; import { BrandingHead } from '../components/BrandingHead'; import { BrandingMark } from '../components/BrandingMark'; import { BRAND_ACCENT, BRAND_DEFAULT_APP_NAME, BRAND_TECH } from '../lib/brand'; @@ -18,8 +19,13 @@ export function AuthCardShell({ children }: { children: React.ReactNode }) { const logoUrl = branding?.logoUrl ?? null; return ( -
+
+ {/* Absolute so the banner spans the shell's full width without the + centring flex column shrinking it to the card's width. */} +
+ +