Add GUI archive upload: streams raw body to disk, bypassing the 50MB form cap

This commit is contained in:
jessikitty committed 2026-09-24 20:29:40 +10:00
1 parent 842ef065e1
commit db1d577996
1 file changed
+89
+89
View File
@@ -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=<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