b8e7745311
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the Middle-earth strings as data; themes as token sets; and a stylesheet for what none of that reaches. All four are on one page, in one settings group. The snapshot is a Jinja global over a process-level cache, because render() has no session and four render paths never reach it at all -- the sign-in page, the error pages, the offline page and the SSE fragments. A context value would have had to be threaded through every one and would still have missed those. It being a global is also what lets mark() branch on an uploaded logo without any of its six call sites learning about branding; the macro that renders the sidebar link is called brandlink now, because a macro imported as `brand` shadows the global for the whole template and took out every page at once. Defaults in code and overrides in the database, as the prompt fragments do, with one difference stated in the module: an empty fragment means off, an empty flavour string means the shipped wording. And blanked rather than dropped -- settings_store.update merges, so an omitted key leaves what was stored last time and "I typed the default back in" would store something different from "I changed nothing". A custom theme sets a handful of tokens and inherits the rest, and the inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as [data-theme="shire"], so a custom light theme lands on parchment rather than four light colours on near-black. Values are validated on read rather than on save, because a theme written straight into the settings table still has to produce a stylesheet that parses -- a `}` in a value ends the rule and silently breaks every rule after it. The soft variants are derived from the accent, or a changed accent leaves focus rings in the old hue and reads as half-working. /branding.css is a route, not an inline block: an external stylesheet has no HTML context to escape from. The link carries a content hash, so a save is not left to the browser's cache, and it is deliberately outside the service worker's precache list, which is versioned by the release. The instance name moved off /admin/general rather than being duplicated there. An upgrade keeps it: the general row is read as a seed exactly while the branding row has never mentioned the name, which is `key in row` and not `row[key] is truthy` -- the two read alike would resurrect the old name underneath a cleared one. The theme list stops being a hard-coded pair in five places. Every failure mode in that area is silent, so it is driven under a DOM stub as well as tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
"""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
|