diff --git a/app/routes/backup.py b/app/routes/backup.py index d1b171a..d697f40 100644 --- a/app/routes/backup.py +++ b/app/routes/backup.py @@ -9,10 +9,12 @@ 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 @@ -50,6 +52,7 @@ def index(): 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'), ) @@ -110,6 +113,92 @@ def verify(filename): }) +@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