Files

254 lines
8.5 KiB
Python

"""
Admin routes for backup, verify and restore.
Restores are deliberately awkward: they need an explicit typed confirmation
and, for a wipe, a second flag. The CLI (`flask backup restore`) is the
better path on a fresh Docker instance where no admin user exists yet.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import os
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, send_file, current_app, abort)
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app.routes.admin import admin_required
from app.services import backup_service as bsvc
backup_bp = Blueprint('backup', __name__, url_prefix='/admin/backup')
@backup_bp.route('/')
@login_required
@admin_required
def index():
"""Backup management page."""
backups = bsvc.list_backups()
total_bytes = sum(b['size_bytes'] for b in backups)
uploads_root = bsvc.upload_folder()
upload_files = list(bsvc.walk_uploads(uploads_root))
uploads_bytes = sum(os.path.getsize(p) for p, _ in upload_files)
expected = bsvc.referenced_files()
on_disk = {rel for _, rel in upload_files}
missing = sorted(expected - on_disk)
orphaned = sorted(on_disk - expected)
return render_template(
'admin/backup.html',
backups=backups,
backup_folder=bsvc.backup_folder(),
backup_total_human=bsvc.human_size(total_bytes),
uploads_root=uploads_root,
upload_file_count=len(upload_files),
uploads_human=bsvc.human_size(uploads_bytes),
missing=missing,
orphaned=orphaned,
dialect=bsvc.database_dialect(),
sqlite_path=bsvc.sqlite_db_path(),
keep=current_app.config.get('BACKUP_KEEP'),
max_upload=current_app.config.get('BACKUP_MAX_UPLOAD'),
)
@backup_bp.route('/create', methods=['POST'])
@login_required
@admin_required
def create():
"""Start a backup in the background and hand back a job id."""
label = (request.form.get('label') or '').strip() or None
job_id = bsvc.start_backup_job(
current_app._get_current_object(),
created_by=current_user.username,
label=label,
keep=current_app.config.get('BACKUP_KEEP'),
)
return jsonify({'job_id': job_id})
@backup_bp.route('/status/<job_id>')
@login_required
@admin_required
def status(job_id):
job = bsvc.get_job(job_id)
if not job:
return jsonify({'error': 'Unknown job'}), 404
return jsonify(job)
@backup_bp.route('/download/<path:filename>')
@login_required
@admin_required
def download(filename):
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
abort(404)
return send_file(path, as_attachment=True, download_name=safe)
@backup_bp.route('/verify/<path:filename>')
@login_required
@admin_required
def verify(filename):
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
return jsonify({'error': 'Not found'}), 404
report = bsvc.verify_backup(path)
return jsonify({
'ok': report['ok'],
'checked': report['checked'],
'failed': report['failed'][:20],
'missing': report['missing'][:20],
'errors': report['errors'],
})
@backup_bp.route('/upload', methods=['POST'])
@login_required
@admin_required
def upload():
"""
Receive a backup archive as the raw request body and stream it to disk.
Deliberately NOT a multipart form. Flask enforces MAX_CONTENT_LENGTH (50MB)
whenever the form parser runs, and its value cannot be overridden per
request — the property reads app config every time. Reading the WSGI input
stream directly sidesteps the parser, so a multi-gigabyte archive uploads
without raising the global limit for every other upload in the app. It also
avoids Werkzeug spooling the whole file into a temp copy first.
The browser sends: POST /admin/backup/upload?filename=<name>.zip
with the file itself as the body.
"""
raw_name = request.args.get('filename', '')
safe = secure_filename(os.path.basename(raw_name))
if not safe.lower().endswith('.zip'):
return jsonify({'error': 'Filename must end in .zip'}), 400
folder = bsvc.backup_folder()
# Don't clobber an existing archive.
target = os.path.join(folder, safe)
if os.path.exists(target):
stem, ext = os.path.splitext(safe)
safe = f"{stem}-uploaded-{datetime.now().strftime('%Y%m%d-%H%M%S')}{ext}"
target = os.path.join(folder, safe)
limit = current_app.config.get('BACKUP_MAX_UPLOAD') or 0
declared = request.content_length or 0
if limit and declared > limit:
return jsonify({'error': f'Archive exceeds the {limit} byte upload limit'}), 413
stream = request.environ.get('wsgi.input')
if stream is None:
return jsonify({'error': 'No request body'}), 400
partial = target + '.part'
written = 0
try:
with open(partial, 'wb') as handle:
while True:
if declared and written >= declared:
break
chunk = stream.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if limit and written > limit:
raise ValueError(f'Archive exceeds the {limit} byte upload limit')
handle.write(chunk)
if written == 0:
raise ValueError('Uploaded file was empty')
# Only keep it if it actually is one of ours.
manifest = bsvc.read_manifest(partial)
if manifest.get('format') != bsvc.BACKUP_FORMAT:
raise ValueError('Not a LIM backup archive')
os.replace(partial, target)
except Exception as exc: # noqa: BLE001 - reported to the admin
if os.path.exists(partial):
try:
os.remove(partial)
except OSError:
pass
current_app.logger.warning('Backup upload rejected: %s', exc)
return jsonify({'error': str(exc)}), 400
current_app.logger.info('Backup uploaded: %s (%d bytes)', safe, written)
return jsonify({
'filename': safe,
'size_bytes': written,
'size_human': bsvc.human_size(written),
'counts': manifest.get('counts', {}),
'uploads': manifest.get('uploads', {}),
'created_at': manifest.get('created_at'),
'source': manifest.get('source', {}).get('hostname'),
})
@backup_bp.route('/delete/<path:filename>', methods=['POST'])
@login_required
@admin_required
def delete(filename):
try:
removed = bsvc.delete_backup(filename)
flash(f'Deleted backup {removed}', 'success')
except FileNotFoundError:
flash('That backup no longer exists.', 'warning')
except ValueError as exc:
flash(str(exc), 'danger')
return redirect(url_for('backup.index'))
@backup_bp.route('/restore/<path:filename>', methods=['POST'])
@login_required
@admin_required
def restore(filename):
"""Restore an archive that already sits in the backup folder."""
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
flash('That backup no longer exists.', 'warning')
return redirect(url_for('backup.index'))
if request.form.get('confirm', '').strip().upper() != 'RESTORE':
flash('Restore cancelled: confirmation text did not match.', 'warning')
return redirect(url_for('backup.index'))
mode = request.form.get('mode', 'merge')
force = request.form.get('force') == 'on'
overwrite = request.form.get('overwrite_files') == 'on'
try:
summary = bsvc.restore_backup(path, mode=mode, force=force,
overwrite_files=overwrite)
except Exception as exc: # noqa: BLE001 - reported to the admin
current_app.logger.exception('Restore failed')
flash(f'Restore failed: {exc}', 'danger')
return redirect(url_for('backup.index'))
inserted = ', '.join(f"{v} {k}" for k, v in summary['inserted'].items() if v)
flash(
f"Restore complete ({mode} mode). Rows added: {inserted or 'none'}. "
f"Files written: {summary['files_written']} "
f"({summary['files_bytes_human']}), skipped: {summary['files_skipped']}.",
'success'
)
for warning in summary['warnings']:
flash(warning, 'info')
return redirect(url_for('backup.index'))