Add GUI archive upload: streams raw body to disk, bypassing the 50MB form cap
This commit is contained in:
1 parent
842ef065e1
commit
db1d577996
1 file changed
+89
@@ -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
|
||||
|
||||
Reference in new issue
Block a user