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:
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -395,6 +395,7 @@ app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||
app.include_router(dav_router.router, tags=["dav"])
|
||||
|
||||
@@ -139,6 +139,20 @@ class UserSettings(Base):
|
||||
user = relationship("User", back_populates="settings")
|
||||
|
||||
|
||||
class InstanceSettings(Base):
|
||||
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
|
||||
Applies to everyone; a user's own settings still override the default theme."""
|
||||
__tablename__ = "instance_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True) # always 1
|
||||
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
|
||||
# client's built-in defaults. A user's own colour wins over this.
|
||||
default_theme = Column(Text, nullable=True)
|
||||
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
|
||||
logo_filename = Column(String(255), nullable=True)
|
||||
favicon_filename = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
class AppPassword(Base):
|
||||
"""Per-device app-specific password for CalDAV (Basic Auth).
|
||||
|
||||
|
||||
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