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
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||||
request, jsonify, send_file, current_app, abort)
|
request, jsonify, send_file, current_app, abort)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app.routes.admin import admin_required
|
from app.routes.admin import admin_required
|
||||||
from app.services import backup_service as bsvc
|
from app.services import backup_service as bsvc
|
||||||
@@ -50,6 +52,7 @@ def index():
|
|||||||
dialect=bsvc.database_dialect(),
|
dialect=bsvc.database_dialect(),
|
||||||
sqlite_path=bsvc.sqlite_db_path(),
|
sqlite_path=bsvc.sqlite_db_path(),
|
||||||
keep=current_app.config.get('BACKUP_KEEP'),
|
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'])
|
@backup_bp.route('/delete/<path:filename>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
Reference in new issue
Block a user