mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +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.
132 lines
5.0 KiB
Python
132 lines
5.0 KiB
Python
"""REST surface for Setup Health — "how set up is this panel".
|
|
|
|
Mounted at /api/v1/setup-health (registered in app/__init__.py). Admin-only.
|
|
|
|
Contract:
|
|
GET / -> {'items': [<check>...], 'summary': {critical_open, recommended_open,
|
|
ok, total, score}}
|
|
|
|
Each ``item`` is a doctor-style check dict (key/title/status/detail/
|
|
repairable/repair_ref) plus the setup extras ``severity``/``scope``/``why``/
|
|
``fix`` (fix is a deep-link ``{kind:'link', to}`` or a repair ``{kind:'repair',
|
|
ref}``). The doctor report carries the same items as its ``setup.*`` section, so
|
|
this endpoint and Monitoring → Doctor stay in lockstep.
|
|
"""
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from ..middleware.rbac import admin_required, auth_required, get_current_user
|
|
from ..services.setup_health_service import SetupHealthService
|
|
from ..services.setup_reconcile_service import (
|
|
DNS_BACKFILL_JOB_KIND,
|
|
URL_FIX_JOB_KIND,
|
|
SetupReconcileService,
|
|
)
|
|
from app.api._request import json_object
|
|
|
|
setup_health_bp = Blueprint('setup_health', __name__)
|
|
|
|
|
|
@setup_health_bp.route('', methods=['GET'])
|
|
@setup_health_bp.route('/', methods=['GET'])
|
|
@admin_required
|
|
def get_setup_health():
|
|
"""Evaluate the setup-health registry (panel scope) and return items +
|
|
summary. Cheap — DB/settings probes only, no host calls."""
|
|
return jsonify(SetupHealthService.evaluate(scope='panel'))
|
|
|
|
|
|
@setup_health_bp.route('/account', methods=['GET'])
|
|
@auth_required()
|
|
def get_account_security():
|
|
"""The requesting user's own setup-health items (scope 'user') — e.g. the
|
|
'secure your account' nudge. Any authenticated user, about themselves."""
|
|
user = get_current_user()
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
return jsonify(SetupHealthService.evaluate(scope='user', user=user))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Snooze / dismiss (Phase 6). Panel items require admin; personal (user-scope)
|
|
# items may be snoozed by the owning user.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _snooze_guard(key):
|
|
"""Resolve the requesting user and enforce admin for panel-scoped items.
|
|
Returns (user, None) when allowed, or (None, (json, status)) on error."""
|
|
user = get_current_user()
|
|
if not user:
|
|
return None, (jsonify({'error': 'User not found'}), 404)
|
|
scope = SetupHealthService._item_scope(key)
|
|
if scope is None:
|
|
return None, (jsonify({'error': f'Unknown setup item: {key}'}), 400)
|
|
if scope == 'panel' and not user.is_admin:
|
|
return None, (jsonify({'error': 'Admin access required'}), 403)
|
|
return user, None
|
|
|
|
|
|
@setup_health_bp.route('/snooze', methods=['POST'])
|
|
@auth_required()
|
|
def snooze_item():
|
|
"""Snooze a setup item (mutes it; it still renders, just quietly)."""
|
|
data = json_object(optional=True)
|
|
key = data.get('key')
|
|
if not key:
|
|
return jsonify({'error': "Body must carry an item 'key'."}), 400
|
|
user, err = _snooze_guard(key)
|
|
if err:
|
|
return err
|
|
return jsonify(SetupHealthService.snooze(key, days=data.get('days', 30), user=user))
|
|
|
|
|
|
@setup_health_bp.route('/snooze', methods=['DELETE'])
|
|
@auth_required()
|
|
def unsnooze_item():
|
|
"""Clear a snooze so the item is active again."""
|
|
data = json_object(optional=True)
|
|
key = data.get('key') or request.args.get('key')
|
|
if not key:
|
|
return jsonify({'error': "An item 'key' is required."}), 400
|
|
user, err = _snooze_guard(key)
|
|
if err:
|
|
return err
|
|
return jsonify(SetupHealthService.unsnooze(key, user=user))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Reconcile-on-connect (Phase 3) — preview is synchronous; apply enqueues the
|
|
# job so a large backfill/heal runs off the request thread.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@setup_health_bp.route('/reconcile/dns/preview', methods=['POST'])
|
|
@admin_required
|
|
def dns_backfill_preview():
|
|
"""Dry-run: the managed hosts a DNS backfill would ensure records for."""
|
|
return jsonify(SetupReconcileService.dns_backfill_preview())
|
|
|
|
|
|
@setup_health_bp.route('/reconcile/dns/apply', methods=['POST'])
|
|
@admin_required
|
|
def dns_backfill_apply():
|
|
"""Enqueue the DNS backfill job (loops ensure_a_record, ownership-guarded)."""
|
|
from app.jobs.service import JobService
|
|
job = JobService.enqueue(DNS_BACKFILL_JOB_KIND, payload={}, max_attempts=1)
|
|
return jsonify({'job_id': job.id}), 202
|
|
|
|
|
|
@setup_health_bp.route('/reconcile/url-fix/preview', methods=['POST'])
|
|
@admin_required
|
|
def url_fix_preview():
|
|
"""Dry-run: WordPress sites whose siteurl/home still point at localhost or a
|
|
stale IP, with the per-site swap they'd get."""
|
|
return jsonify(SetupReconcileService.url_fix_preview())
|
|
|
|
|
|
@setup_health_bp.route('/reconcile/url-fix/apply', methods=['POST'])
|
|
@admin_required
|
|
def url_fix_apply():
|
|
"""Enqueue the WordPress URL-fix job (loops change_site_url)."""
|
|
from app.jobs.service import JobService
|
|
job = JobService.enqueue(URL_FIX_JOB_KIND, payload={}, max_attempts=1)
|
|
return jsonify({'job_id': job.id}), 202
|