mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 07:37:56 +09:00
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.
Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
"""Themes API — /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
|
|
|
|
from app.exceptions import NotFoundError
|
|
from app.services import theme_service
|
|
from app.services.audit_service import AuditService
|
|
from app.models.audit_log import AuditLog
|
|
from app.middleware.rbac import require_admin_user
|
|
from app.api._request import json_object
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
themes_bp = Blueprint('themes', __name__)
|
|
|
|
|
|
@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 = require_admin_user()
|
|
|
|
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 = require_admin_user()
|
|
ok, derr = theme_service.delete_theme(slug)
|
|
if not ok:
|
|
if derr is theme_service.THEME_NOT_FOUND:
|
|
raise NotFoundError(derr)
|
|
return jsonify({'error': derr}), 400
|
|
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 = require_admin_user()
|
|
body = json_object(optional=True)
|
|
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 = require_admin_user()
|
|
from app.services import theme_registry_service
|
|
theme = theme_registry_service.install(slug)
|
|
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())
|