Files
Juan Denis 55d8fb1e44 Read route bodies through json_object()
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.
2026-10-10 20:53:43 -04:00

127 lines
4.6 KiB
Python

"""Site imports API — migrate a control-panel backup archive into ServerKit.
Mounted at /api/v1/imports (see app/__init__.py blueprint registration).
All routes are admin-only: an import writes files, creates apps and touches
the database engine.
"""
from flask import Blueprint, jsonify, request
from app.middleware.rbac import admin_required, get_current_user
from app.services.site_import_service import SiteImportError, SiteImportService
from app.api._request import json_object
site_imports_bp = Blueprint('site_imports', __name__)
@site_imports_bp.route('/upload', methods=['POST'])
@admin_required
def upload_archive():
"""Accept a backup archive upload; returns the token to pass as
source.upload_path when creating the import."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
uploaded = request.files['file']
if not uploaded or not uploaded.filename:
return jsonify({'error': 'No file selected'}), 400
try:
upload_path = SiteImportService.save_upload(uploaded)
except SiteImportError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({'upload_path': upload_path}), 201
@site_imports_bp.route('/ssh/probe', methods=['POST'])
@admin_required
def probe_ssh_source():
"""Preflight an SSH import source: test the connection, list the docroot,
sniff the stack. Linux-only runtime — returns 501 on a host
that can't run ssh (e.g. Windows dev) so the wizard can message it."""
data = json_object(optional=True)
source = data.get('source') or data
try:
result = SiteImportService.probe_ssh(source)
except SiteImportError as exc:
return jsonify({'error': str(exc)}), 400
except RuntimeError as exc:
return jsonify({'error': str(exc), 'code': 'LINUX_ONLY'}), 501
return jsonify(result), 200
@site_imports_bp.route('', methods=['POST'])
@admin_required
def create_import():
data = json_object(optional=True)
user = get_current_user()
try:
imp = SiteImportService.create(
source_type=data.get('source_type') or 'cpanel',
source=data.get('source') or {},
options=data.get('options') or {},
user_id=user.id if user else None,
)
except SiteImportError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({'import': imp.to_dict()}), 201
@site_imports_bp.route('', methods=['GET'])
@admin_required
def list_imports():
return jsonify({'imports': [i.to_dict(log_lines=0) for i in
SiteImportService.list()]})
@site_imports_bp.route('/<int:import_id>', methods=['GET'])
@admin_required
def get_import(import_id):
imp = SiteImportService.get(import_id)
if not imp:
return jsonify({'error': 'Import not found'}), 404
return jsonify({'import': imp.to_dict(log_lines=500)})
@site_imports_bp.route('/<int:import_id>/analyze', methods=['POST'])
@admin_required
def analyze_import(import_id):
imp = SiteImportService.get(import_id)
if not imp:
return jsonify({'error': 'Import not found'}), 404
if imp.status in ('analyzing', 'running'):
return jsonify({'error': f'Import is currently {imp.status}'}), 409
job = SiteImportService.enqueue_analyze(imp)
return jsonify({'job_id': job.id}), 202
@site_imports_bp.route('/<int:import_id>/run', methods=['POST'])
@admin_required
def run_import(import_id):
imp = SiteImportService.get(import_id)
if not imp:
return jsonify({'error': 'Import not found'}), 404
if imp.status not in ('analyzed', 'failed'):
return jsonify({'error': "Import must be analysed before it can run "
f"(status: {imp.status})"}), 409
data = json_object(optional=True)
from_step = data.get('from_step') or None
# Merge wizard-provided run options into the stored ones (body wins),
# e.g. {'skip_db': True, 'skip_crontab': True}.
body_options = data.get('options') or {}
if body_options:
from app import db
imp.set_options({**imp.get_options(), **body_options})
db.session.commit()
job = SiteImportService.enqueue_run(imp, from_step=from_step)
return jsonify({'job_id': job.id}), 202
@site_imports_bp.route('/<int:import_id>', methods=['DELETE'])
@admin_required
def delete_import(import_id):
imp = SiteImportService.get(import_id)
if not imp:
return jsonify({'error': 'Import not found'}), 404
if imp.status in ('analyzing', 'running'):
return jsonify({'error': f'Import is currently {imp.status}'}), 409
SiteImportService.delete(imp)
return jsonify({'message': 'Import deleted'})