- 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>
195 lines
6.5 KiB
Python
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}
|