The second audit pass. Four things, and the first two were reported. The Prompts page put a screen of variables and a screen of preview above the editor, so the tabs began two screens down and switching one had to drag the whole page to be any use -- and on a short tab it could not drag far enough, leaving the panel stranded above a screenful of nothing. Editor first, reference after, bar sticky. Custom themes were three fixed slots: fifty-seven empty colour boxes on a fresh instance and no way to make a fourth theme. One block per theme plus a blank one, colours behind a disclosure. Both measured rather than argued about -- rendered through TestClient and driven under headless Chromium, where the tab bar moved 385->642px before and does not move now, and the themes page went from 5495px to 2820px. Asking where generated images go found the other two. Deleting a chat cascades to the attachment rows and leaves every file on disk; the helper written for exactly that was called from one place, and it was not the delete button, a schedule's chat, a helper's chat or deleting an account. Underneath it, `claim` bound message_id and never chat_id, so anything picked before a chat existed kept an empty chat_id forever -- which six readers filter on, so those files were also unnamed in the prompt, unopenable in the canvas, and invisible to the one caller the cleanup had. And folders nest now. The route has handled parent_id since folders existed, with a cycle guard and a depth cap the move path never applied; the sidebar has always drawn a tree. Nothing could ask for one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
226 lines
8.9 KiB
Python
226 lines
8.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
|
|
|
|
# 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
|