""" 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/') @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/') @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/') @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=.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/', 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/', 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'))