Files
Calendarr/backend/routers/admin_router.py
Scarriffle cea96660d9 feat(web): admin area with instance default theme, custom logo/favicon; .theme.json export
- Admin settings tab (admin-only) now holds user management plus a server-wide
  default theme editor (live preview + discard) and logo/favicon branding.
- New singleton InstanceSettings model + /api/instance router (public GET for
  the login screen; admin-gated theme PUT and logo/favicon upload/delete,
  reusing the avatar PIL/validation pattern).
- Instance default theme is the base users inherit and the target a per-colour
  "Reset" returns to (baseColor: instance default -> built-in).
- Custom favicon overrides the primary-colour tinting; custom logo replaces the
  top-left glyph+text, hard-capped so it only scales down and never breaks layout.
  Branding + defaults load before login (public endpoint).
- Theme export now uses the recognised .theme.json extension (old .theme still
  imports); import stays partial/unknown-key aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:19:15 +02:00

195 lines
6.5 KiB
Python

"""Instance-wide (singleton) settings: an admin-defined default theme plus a
custom logo and favicon. The GET endpoint is public (needed on the login screen,
before auth); all writes require admin. Branding files are stored under
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
import io
import json
import re
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_admin
from database import DATA_DIR, get_db
router = APIRouter()
BRANDING_DIR = DATA_DIR / "branding"
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# Colour keys an admin may set as the instance default theme. Keep in sync with
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
THEME_COLOR_KEYS = {
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
"line_color", "surface_color", "month_divider_color", "month_label_color",
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
}
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _get_or_create(db: Session) -> models.InstanceSettings:
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
if not inst:
inst = models.InstanceSettings(id=1)
db.add(inst)
db.commit()
db.refresh(inst)
return inst
def _mtime(filename: Optional[str]) -> int:
if not filename:
return 0
p = BRANDING_DIR / filename
try:
return int(p.stat().st_mtime)
except OSError:
return 0
def _public_dict(inst: models.InstanceSettings) -> dict:
theme = {}
if inst.default_theme:
try:
theme = json.loads(inst.default_theme) or {}
except (ValueError, TypeError):
theme = {}
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
return {
"default_theme": theme,
"has_logo": has_logo,
"has_favicon": has_favicon,
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
}
# ── Public read ───────────────────────────────────────────
@router.get("/")
def get_instance(db: Session = Depends(get_db)):
return _public_dict(_get_or_create(db))
@router.get("/logo")
def get_logo(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.logo_filename:
raise HTTPException(404, "No logo")
path = BRANDING_DIR / inst.logo_filename
if not path.exists():
raise HTTPException(404, "No logo")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
@router.get("/favicon")
def get_favicon(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.favicon_filename:
raise HTTPException(404, "No favicon")
path = BRANDING_DIR / inst.favicon_filename
if not path.exists():
raise HTTPException(404, "No favicon")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
# ── Admin writes ──────────────────────────────────────────
class ThemeUpdate(BaseModel):
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
@router.put("/theme")
def set_default_theme(
data: ThemeUpdate,
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
# Keep only known colour keys with valid hex values.
clean = {
k: v.upper()
for k, v in (data.default_theme or {}).items()
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
}
inst = _get_or_create(db)
inst.default_theme = json.dumps(clean) if clean else None
db.commit()
return {"ok": True, "default_theme": clean}
async def _save_branding(file: UploadFile, kind: str) -> str:
"""Validate + normalise an uploaded image and store it. Returns the filename."""
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
raw = await file.read()
if len(raw) > MAX_BRANDING_SIZE:
raise HTTPException(400, "File too large (max 5 MB)")
try:
img = Image.open(io.BytesIO(raw)).convert("RGBA")
except Exception:
raise HTTPException(400, "Invalid image")
if kind == "favicon":
img = img.resize((128, 128), Image.LANCZOS)
else: # logo: keep aspect ratio, cap the longest edge at 256px
img.thumbnail((256, 256), Image.LANCZOS)
filename = f"{kind}.png"
img.save(str(BRANDING_DIR / filename), "PNG")
return filename
@router.post("/logo")
async def upload_logo(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.logo_filename = await _save_branding(file, "logo")
db.commit()
return {"ok": True}
@router.delete("/logo")
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.logo_filename:
p = BRANDING_DIR / inst.logo_filename
if p.exists():
p.unlink()
inst.logo_filename = None
db.commit()
return {"ok": True}
@router.post("/favicon")
async def upload_favicon(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.favicon_filename = await _save_branding(file, "favicon")
db.commit()
return {"ok": True}
@router.delete("/favicon")
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.favicon_filename:
p = BRANDING_DIR / inst.favicon_filename
if p.exists():
p.unlink()
inst.favicon_filename = None
db.commit()
return {"ok": True}