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>
This commit is contained in:
194
backend/routers/admin_router.py
Normal file
194
backend/routers/admin_router.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user