"""Making an instance somebody else's. One page, four cards, one settings group. Everything it writes goes through `branding.stored_only`, so a field left at its shipped wording is never written down and a later release can still improve it — the prompt-fragment rule, and the reason this page can afford to render every flavour string as an editable box without freezing all of them the first time somebody presses Save. `branding.forget()` after every write, and this is the only module that calls it. The snapshot is a process-level cache read by a Jinja global; a save that did not drop it would take effect on the next restart, which is the shape of failure this codebase keeps cataloguing. """ from __future__ import annotations import logging from fastapi import APIRouter, File, Form, Request, Response, UploadFile, status from fastapi.responses import RedirectResponse from lembas.api.deps import AdminUser, Db from lembas.services import branding as branding_service from lembas.services import settings_store, uploads from lembas.web.templating import render log = logging.getLogger(__name__) router = APIRouter(prefix="/admin/customization", tags=["admin-branding"]) MAX_CUSTOM_CSS = 40_000 def _page(request: Request, db: Db, saved: str = "", error: str = "") -> Response: values = settings_store.get_group(db, branding_service.BRANDING) brand = branding_service.for_db(db) return render( request, "admin/customization.html", { "values": values, "current": brand, # The flavour table drives the form, so a string added in code # appears here with its default in the box and no template change. "flavour": [ { "key": key, "label": label, "hint": hint, "default": default, "value": str(values.get(f"text_{key}") or ""), } for key, (label, hint, default) in branding_service.FLAVOUR.items() ], "tokens": branding_service.THEME_TOKENS, "custom_themes": [t for t in brand.themes if not t.built_in], "bases": [name for name, _, _ in branding_service.BUILT_IN], "saved": saved, "error": error, }, ) @router.get("") async def customization_page(request: Request, db: Db, user: AdminUser, saved: str = ""): return _page(request, db, saved=saved) def _write(db: Db, changes: dict) -> None: """Store a change and drop the cache, in that order and always together.""" settings_store.update(db, changes, key=branding_service.BRANDING) branding_service.forget() @router.post("/identity") async def save_identity( request: Request, db: Db, user: AdminUser, instance_name: str = Form(""), tagline: str = Form(""), logo: UploadFile | None = File(None), favicon: UploadFile | None = File(None), remove_logo: bool = Form(False), remove_favicon: bool = Form(False), ) -> Response: stored = settings_store.get_group(db, branding_service.BRANDING) changes: dict = { "instance_name": instance_name.strip()[:120], "tagline": tagline.strip()[:200], } if remove_logo: for name in (stored.get("logo_path"), *(stored.get("icon_paths") or {}).values()): uploads.delete_branding_image(str(name or "")) changes["logo_path"] = "" changes["icon_paths"] = {} if remove_favicon: uploads.delete_branding_image(str(stored.get("favicon_path") or "")) changes["favicon_path"] = "" try: if logo is not None and logo.filename: payload = await logo.read() changes["logo_path"] = uploads.save_branding_image(payload, logo.content_type or "") # Derived here rather than on demand: a launcher asks for a 512px # PNG and will not scale one itself, and doing it per request would # mean resizing an image on the path that serves it. changes["icon_paths"] = uploads.derive_icons(payload) if favicon is not None and favicon.filename: payload = await favicon.read() changes["favicon_path"] = uploads.save_branding_image( payload, favicon.content_type or "" ) except uploads.UploadError as exc: return _page(request, db, error=str(exc)) _write(db, branding_service.stored_only(changes)) log.info("branding identity changed by %s", user.email) return RedirectResponse( "/admin/customization?saved=Identity+saved.", status_code=status.HTTP_303_SEE_OTHER ) @router.post("/flavour") async def save_flavour(request: Request, db: Db, user: AdminUser) -> Response: """The Middle-earth strings. Read from the raw form rather than declared as parameters, because the set is `branding.FLAVOUR` and a parameter list would be a second copy of it that goes stale the first time a string is added. A key that was not submitted is left alone; one submitted empty falls back to its default, which is what makes "clear the box" mean "give me the shipped wording back" rather than "show nothing here". """ form = await request.form() changes = { f"text_{key}": str(form.get(f"text_{key}") or "").strip()[:400] for key in branding_service.FLAVOUR if f"text_{key}" in form } _write(db, branding_service.stored_only(changes)) return RedirectResponse( "/admin/customization?saved=Wording+saved.", status_code=status.HTTP_303_SEE_OTHER ) @router.post("/css") async def save_css(db: Db, user: AdminUser, custom_css: str = Form("")) -> Response: _write(db, {"custom_css": custom_css.strip()[:MAX_CUSTOM_CSS]}) log.info("custom CSS changed by %s", user.email) return RedirectResponse( "/admin/customization?saved=Stylesheet+saved.", status_code=status.HTTP_303_SEE_OTHER ) @router.post("/themes") async def save_themes(request: Request, db: Db, user: AdminUser) -> Response: """Every custom theme, replaced wholesale. One form for the lot rather than a row each, because a theme is a handful of colours and the whole set fits on a screen — and because replacing the list means a theme removed here is gone, with no reconciliation between what was posted and what was stored. Nothing is validated here beyond shape. `branding._theme_from` validates on every **read**, so a theme written straight into the settings table by hand, or stored by an earlier version, still has to produce a stylesheet that parses. Validating only on save would put that guarantee in the wrong place. """ form = await request.form() themes = [] for index in range(_theme_count(form)): theme_id = str(form.get(f"theme_{index}_id") or "").strip().lower() if not theme_id: continue themes.append( { "id": theme_id, "label": str(form.get(f"theme_{index}_label") or "").strip(), "base": str(form.get(f"theme_{index}_base") or "moria"), "tokens": { name: value for name, _ in branding_service.THEME_TOKENS if (value := str(form.get(f"theme_{index}_{name}") or "").strip()) }, } ) _write(db, {"themes": themes}) log.info("%d custom theme(s) saved by %s", len(themes), user.email) return RedirectResponse( "/admin/customization?saved=Themes+saved.", status_code=status.HTTP_303_SEE_OTHER ) def _theme_count(form) -> int: """How many theme blocks the form carried. Counted from the submitted keys rather than from a hidden field, so a form rendered by an older page still saves what it holds. """ indices = [ int(key.split("_")[1]) for key in form if key.startswith("theme_") and key.split("_")[1].isdigit() ] return max(indices) + 1 if indices else 0