mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
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>
195 lines
6.2 KiB
Python
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())
|