Files
ServerKit/backend/app/api/themes.py
T
Juan DenisandClaude Opus 4.8 c20a9f14f2 feat(themes): backend model, API, and registry pipeline (plan 60 Phase 2-3)
Themes are data, not code — so the backend is a simple validated-JSON store,
no zips/checksums/permissions.

- Theme model + migration 078_themes
- theme_tokens.py: server-side mirror of the token whitelist + validators
  (the gate: a malicious/malformed theme never reaches the DB)
- theme_service: bundled-seed loading, import/list/delete, panel default,
  pre-auth public/active
- /api/v1/themes: /installed, /import (admin), DELETE (admin), /default
  get+set (admin), unauthenticated /public/active for login/setup, plus
  /registry + /registry/<slug>/install
- theme_registry_service: offline-tolerant registry fetch (TTL + last-good +
  bundled fallback), mirrors the extension registry pattern
- bundled seed themes synced to app/data/themes + themes_index.json
- 18 proving tests (server-side validation sanitizes url()/;/} smuggling,
  admin gate, panel-default → public/active, offline fallback, install flow)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:24:00 -04:00

195 lines
6.2 KiB
Python

"""Themes API (plan 60) — /api/v1/themes.
A theme is data, not code: a validated map of CSS custom-property tokens. This
blueprint lists themes (bundled seeds + installed), imports a pasted/uploaded
theme.json, sets the panel-wide default, and exposes an UNAUTHENTICATED
`GET /public/active` so the login/setup screens can paint themselves with the
panel default before anyone signs in.
Registry browse/install routes are added in Phase 3.
"""
import json
import logging
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.user import User
from app.services import theme_service
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
logger = logging.getLogger(__name__)
themes_bp = Blueprint('themes', __name__)
def _current_user():
return User.query.get(get_jwt_identity())
def _require_admin():
user = _current_user()
if not user or not user.is_admin:
return None, (jsonify({'error': 'Admin access required'}), 403)
return user, None
@themes_bp.route('/installed', methods=['GET'])
@jwt_required()
def list_installed():
"""All selectable themes (bundled seeds + installed) plus the panel default.
Any authenticated user may read this — themes are per-user cosmetic."""
return jsonify({
'themes': theme_service.list_all(),
'default': theme_service.get_default_slug(),
})
@themes_bp.route('/<slug>', methods=['GET'])
@jwt_required()
def get_theme(slug):
theme = theme_service.get_theme(slug)
if not theme:
return jsonify({'error': 'Theme not found'}), 404
return jsonify(theme)
@themes_bp.route('/import', methods=['POST'])
@jwt_required()
def import_theme():
"""Import a theme from a pasted JSON body or an uploaded theme.json (admin).
Accepts either a raw theme object as the JSON body, an object wrapped as
``{"theme": {...}}``, or a multipart upload under ``file``. The token
whitelist + value validators run server-side before anything is stored.
"""
user, err = _require_admin()
if err:
return err
raw = None
upload = request.files.get('file')
if upload is not None:
try:
raw = json.loads(upload.read().decode('utf-8'))
except Exception:
return jsonify({'error': 'Uploaded file is not valid JSON'}), 400
else:
body = request.get_json(silent=True)
if isinstance(body, dict) and isinstance(body.get('theme'), dict):
raw = body['theme']
else:
raw = body
if not isinstance(raw, dict):
return jsonify({'error': 'No theme provided'}), 400
source = 'studio' if request.args.get('source') == 'studio' else 'import'
theme, verr = theme_service.import_theme(raw, source=source)
if verr:
return jsonify({'error': verr}), 400
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE,
user_id=user.id,
target_type='theme',
target_id=theme['slug'],
details={'name': theme['name'], 'source': source},
)
return jsonify(theme), 201
@themes_bp.route('/<slug>', methods=['DELETE'])
@jwt_required()
def delete_theme(slug):
"""Uninstall an installed theme (admin). Bundled seeds cannot be removed."""
user, err = _require_admin()
if err:
return err
ok, derr = theme_service.delete_theme(slug)
if not ok:
code = 404 if derr == 'Theme not found' else 400
return jsonify({'error': derr}), code
AuditService.log(
action=AuditLog.ACTION_RESOURCE_DELETE,
user_id=user.id,
target_type='theme',
target_id=slug,
details={'slug': slug},
)
return jsonify({'success': True}), 200
@themes_bp.route('/default', methods=['GET'])
@jwt_required()
def get_default():
return jsonify({'default': theme_service.get_default_slug()})
@themes_bp.route('/default', methods=['POST'])
@jwt_required()
def set_default():
"""Set the panel-wide default theme (admin) — what login/setup and new
users get before picking their own."""
user, err = _require_admin()
if err:
return err
body = request.get_json(silent=True) or {}
slug = body.get('slug')
if not isinstance(slug, str) or not slug:
return jsonify({'error': 'Missing slug'}), 400
ok, derr = theme_service.set_default_slug(slug)
if not ok:
return jsonify({'error': derr}), 400
AuditService.log(
action=AuditLog.ACTION_SETTINGS_UPDATE,
user_id=user.id,
target_type='theme',
target_id=slug,
details={'default_theme': slug},
)
return jsonify({'default': slug}), 200
@themes_bp.route('/registry', methods=['GET'])
@jwt_required()
def list_registry():
"""Remote-registry themes (with live install state), for the Browse gallery.
Read-only; offline-tolerant (falls back to the bundled index)."""
from app.services import theme_registry_service
return jsonify({
'themes': theme_registry_service.list_catalog(),
'source': theme_registry_service.registry_source_label(),
})
@themes_bp.route('/registry/<slug>/install', methods=['POST'])
@jwt_required()
def install_registry(slug):
"""Install a theme from the registry by slug (admin). Fetches the theme.json,
validates it server-side, and stores it — no zips, no checksums."""
user, err = _require_admin()
if err:
return err
from app.services import theme_registry_service
theme, ierr = theme_registry_service.install(slug)
if ierr:
code = 404 if 'not found' in ierr.lower() else 400
return jsonify({'error': ierr}), code
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE,
user_id=user.id,
target_type='theme',
target_id=theme['slug'],
details={'name': theme['name'], 'source': 'registry'},
)
return jsonify(theme), 201
@themes_bp.route('/public/active', methods=['GET'])
def public_active():
"""UNAUTHENTICATED — the panel-default theme's tokens for the login/setup
screens. Extends the existing pre-auth branding channel; returns only
cosmetic token data, never anything sensitive."""
return jsonify(theme_service.get_public_active())