"""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 # How many custom themes an instance may keep. Not a design limit -- there is # nothing in `theme_css` that cares -- but the whole set lives in one settings # row read into a process-level snapshot on every render, and the page offers a # blank block whenever there is room, so *some* number has to say when to stop # offering. Twelve is far past what anybody wants and small enough that the # stylesheet stays a stylesheet. MAX_THEMES = 12 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], "max_themes": MAX_THEMES, "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. The indices need not be contiguous and are not renumbered. The page renders one block per theme plus a blank one, so clearing an id in the middle leaves a gap -- and a gap is simply an index with no id, which the loop already skips. Renumbering would be work in aid of nothing. """ 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()) }, } ) # Enforced here as well as in the template, because the template's job is to # stop offering and this one's is to stop accepting -- a crafted POST is not # the page. themes = themes[:MAX_THEMES] _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