diff --git a/CHANGELOG.md b/CHANGELOG.md index 14610b0e3..02681dd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ All notable changes to this project are documented in this file. The format is b - `keycloak` follows the module layout: `keycloak.core.{constants,settings,state}`, `keycloak.identity.{provider,oidc,renewal,directory,persistence,tokens,jwks}` and `keycloak.web.endpoints`. `keycloak.{constants,settings,provider,jwks,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone. - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. +- `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. ### Fixed diff --git a/docs/module-layout.md b/docs/module-layout.md index ee59fb857..339edb0eb 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -216,7 +216,7 @@ when a later module PR moves files. | audit_log | 13 | 18 | flat | | auth | 8 | 20 | flat | | background_tasks | 49 | 83 | flat | -| branding | 13 | 20 | flat | +| branding | 2 (+6 shims) | 0 | follows this layout | | dashboard | 2 | 0 | follows this layout | | feature_flags | 3 (+1 shim) | 0 | follows this layout | | file_storage | 39 | 65 | flat | diff --git a/docs/modules/branding.md b/docs/modules/branding.md index 00a57c154..6ace06e08 100644 --- a/docs/modules/branding.md +++ b/docs/modules/branding.md @@ -167,7 +167,7 @@ Note the exact property: the bytes must look like **some** allowed image format, ### Asset lifecycle -Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in `file_storage` — system and tenant images alike (`branding.reaper`): +Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in `file_storage` — system and tenant images alike (`branding.assets.reaper`): - **After commit.** The delete is queued with `register_on_commit` and runs, in a session of its own, once the settings write is durable. Deleting in the request removed the bytes before that: a late rollback left the setting pointing at a file that was gone. - **Only when unreferenced.** A file another image field of the same owner still holds — the logo and the dark logo sharing one upload — is kept; the check runs again at reap time. @@ -265,7 +265,7 @@ page translates, with the English text as fallback. Server-side error details (validator messages) are still shown as sent — they come from pydantic and are not keyed. -Reads resolve per request (`branding.tenant_branding.resolve`): the request's +Reads resolve per request (`branding.core.tenant_branding.resolve`): the request's tenant overrides on top of the system theme, falling back field by field. The provider leaves the merged object on `request.state.branding`, which the root template's pre-hydration `
` prefers. A hand-edited invalid override diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 2cb3bbc7c..15a2b69fb 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -34,6 +34,7 @@ # Add named domain areas here only when they contain several related features. DOMAIN_AREAS: dict[str, frozenset[str]] = { "api_tokens": frozenset({"credentials"}), + "branding": frozenset({"assets"}), "keycloak": frozenset({"identity"}), "mail": frozenset({"delivery"}), "oidc": frozenset({"identity"}), @@ -66,12 +67,6 @@ workers_state.py """.split() ), - "branding": frozenset( - """ - components constants.py deps.py endpoints images.py presets.py reaper.py service.py - services.py settings.py shared_props.py tenant_branding.py tenant_definitions.py - """.split() - ), "file_storage": frozenset( """ aggregates.py audit_labels.py backend_guard.py backends body_limits.py checksum.py diff --git a/modules/branding/branding/assets/__init__.py b/modules/branding/branding/assets/__init__.py new file mode 100644 index 000000000..a5b1c8095 --- /dev/null +++ b/modules/branding/branding/assets/__init__.py @@ -0,0 +1 @@ +"""Branding images: upload validation and reaping files nothing references.""" diff --git a/modules/branding/branding/assets/images.py b/modules/branding/branding/assets/images.py new file mode 100644 index 000000000..eb1323631 --- /dev/null +++ b/modules/branding/branding/assets/images.py @@ -0,0 +1,121 @@ +"""Upload validation for branding images. + +The declared ``Content-Type`` on a multipart part is chosen by the caller, so +it is a claim rather than a fact. An admin who uploads ``payload.html`` renamed +``logo.png`` would otherwise have it stored and later served back under an +``image/*`` type. Every accepted format therefore has to also *look* like +itself in its first bytes. + +Mirrors the allow-list and signature check in the IIASA.GeoWiki Branding module +(``GeoWikiBrandingConsts``), including its deliberate omission of SVG. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from fastapi import HTTPException, UploadFile + +if TYPE_CHECKING: + from file_storage.contracts.schemas import StoredFileOut + from file_storage.service import FileStorageService + +# Raster + icon only. SVG is excluded on purpose: it is an XML document that can +# carry