|
|
@@ -41,8 +41,22 @@ TOKEN_FILE = Path(os.environ.get("WEBRAKE_TOKENS", str(APP_DIR / "token.json")))
|
|
|
for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_DIR):
|
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
+# Spool any temporary files onto the data volume, never /tmp — on Alpine /tmp
|
|
|
+# can be a small RAM-backed tmpfs, which silently caps upload sizes.
|
|
|
+import tempfile
|
|
|
+SCRATCH = DATA_DIR / "tmp"
|
|
|
+SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
|
+os.environ["TMPDIR"] = str(SCRATCH)
|
|
|
+tempfile.tempdir = str(SCRATCH)
|
|
|
+
|
|
|
+def storage_info():
|
|
|
+ u = shutil.disk_usage(DATA_DIR)
|
|
|
+ return {"total": u.total, "used": u.used, "free": u.free}
|
|
|
+
|
|
|
+FREE_SPACE_MARGIN = 256 * 1024 * 1024 # keep 256 MB headroom for outputs/logs
|
|
|
+
|
|
|
HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
|
|
|
-VERSION = "1.0.3"
|
|
|
+VERSION = "1.0.4"
|
|
|
|
|
|
def load_tokens():
|
|
|
"""token.json is created by install.sh and is never part of the repo."""
|
|
|
@@ -518,15 +532,53 @@ def api_presets():
|
|
|
except Exception as e:
|
|
|
return jsonify({"presets": [], "error": str(e)})
|
|
|
|
|
|
-@app.route("/api/upload", methods=["POST"])
|
|
|
+@app.route("/api/upload", methods=["POST", "PUT"])
|
|
|
@auth_required
|
|
|
def api_upload():
|
|
|
- f = request.files.get("file")
|
|
|
- if not f or not f.filename:
|
|
|
- abort(400, description="No file supplied")
|
|
|
- safe = re.sub(r"[^\w.\- ()\[\]]", "_", os.path.basename(f.filename))
|
|
|
+ # Preferred path: raw body streaming (?filename=… or X-Filename header) —
|
|
|
+ # constant memory, no multipart spooling. Multipart 'file' still accepted
|
|
|
+ # for curl/bot compatibility.
|
|
|
+ raw_name = request.args.get("filename") or request.headers.get("X-Filename")
|
|
|
+ mp = None if raw_name else request.files.get("file")
|
|
|
+ if not raw_name and (not mp or not mp.filename):
|
|
|
+ abort(400, description="No file supplied — send raw body with "
|
|
|
+ "?filename=… or multipart field 'file'")
|
|
|
+ name = os.path.basename(raw_name or mp.filename)
|
|
|
+ safe = re.sub(r"[^\w.\- ()\[\]]", "_", name) or "upload.bin"
|
|
|
+
|
|
|
+ # Preflight: refuse uploads that cannot fit, with a number instead of a 500.
|
|
|
+ length = request.content_length or 0
|
|
|
+ free = storage_info()["free"]
|
|
|
+ if length and length + FREE_SPACE_MARGIN > free:
|
|
|
+ return jsonify({"error": "Not enough space on the container volume: "
|
|
|
+ f"upload is {length/1e9:.2f} GB but only "
|
|
|
+ f"{max(free - FREE_SPACE_MARGIN, 0)/1e9:.2f} GB is usable. "
|
|
|
+ "Delete old sources/outputs or grow the LXC disk."}), 507
|
|
|
+
|
|
|
dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
|
|
|
- f.save(dest)
|
|
|
+ try:
|
|
|
+ if raw_name:
|
|
|
+ with open(dest, "wb") as out:
|
|
|
+ while True:
|
|
|
+ chunk = request.stream.read(4 * 1024 * 1024)
|
|
|
+ if not chunk:
|
|
|
+ break
|
|
|
+ out.write(chunk)
|
|
|
+ else:
|
|
|
+ mp.save(dest)
|
|
|
+ except OSError as e:
|
|
|
+ dest.unlink(missing_ok=True) # never leave truncated sources behind
|
|
|
+ import errno
|
|
|
+ if e.errno == errno.ENOSPC:
|
|
|
+ return jsonify({"error": "Disk filled up mid-upload — the partial "
|
|
|
+ "file was removed. Free space and retry."}), 507
|
|
|
+ return jsonify({"error": f"Could not store upload: {e}"}), 500
|
|
|
+ if raw_name and length and dest.stat().st_size != length:
|
|
|
+ # Only meaningful for raw uploads: multipart Content-Length includes
|
|
|
+ # boundary/header overhead and will never equal the file size.
|
|
|
+ dest.unlink(missing_ok=True)
|
|
|
+ return jsonify({"error": "Upload was truncated in transit — removed. "
|
|
|
+ "Please retry."}), 500
|
|
|
return jsonify({"upload_id": dest.name, "filename": safe,
|
|
|
"size": dest.stat().st_size})
|
|
|
|
|
|
@@ -540,7 +592,7 @@ def api_uploads():
|
|
|
"filename": p.name.split("_", 1)[-1],
|
|
|
"size": p.stat().st_size,
|
|
|
"mtime": p.stat().st_mtime})
|
|
|
- return jsonify({"uploads": items})
|
|
|
+ return jsonify({"uploads": items, "storage": storage_info()})
|
|
|
|
|
|
@app.route("/api/uploads/<upload_id>", methods=["DELETE"])
|
|
|
@auth_required
|
|
|
@@ -676,6 +728,11 @@ def api_download(job_id):
|
|
|
def json_error(err):
|
|
|
return jsonify({"error": getattr(err, "description", str(err))}), err.code
|
|
|
|
|
|
+@app.errorhandler(500)
|
|
|
+def json_500(err):
|
|
|
+ orig = getattr(err, "original_exception", None)
|
|
|
+ return jsonify({"error": f"Server error: {orig or err}"}), 500
|
|
|
+
|
|
|
if __name__ == "__main__":
|
|
|
host = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
|
|
|
port = int(os.environ.get("WEBRAKE_PORT", "8090"))
|