Add chunked, resumable dashcam upload to work around Cloudflare's 100MB body limit
Confirmed against the real deployed server: Cloudflare Tunnel's free tier caps request bodies at ~100MB and returns a plain 413 - a direct LAN upload of the same 250MB test file succeeds, the identical request through yaris.ceaf.dev does not. Real dashcam clips average ~320MB, so no dashcam video could ever complete an upload through the tunnel as it stood, regardless of the earlier memory-streaming fix. Server: new /api/v1/dashcam/upload/chunk (accepts one chunk at a time, keyed by relative_path+sha256+chunk_index/total_chunks, reassembles and verifies the sha256 once every chunk has arrived) and /api/v1/dashcam/upload/status (reports which chunk indices a given upload already has, or "already_uploaded" if it's done) endpoints. Chunks are staged under STORAGE_DIR/.upload_tmp/<device>/<sha256>/ and cleaned up after reassembly; a stale partial set left by a differently -chunked earlier attempt is detected via a small marker file and wiped before restarting. The original single-shot /upload endpoint is left in place unchanged. Client (modules/app_upload.py): _upload_dashcam_files() now calls _upload_video_chunked(), which checks /status first (so a prior partial upload resumes instead of restarting from byte 0) and then POSTs any missing 40MB chunks (DASHCAM_CHUNK_SIZE, comfortably under Cloudflare's cap) via the same streaming MultipartEncoder approach as before. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+142
@@ -3,6 +3,7 @@ uploaded by the Pi, storing them in Postgres + on-disk file storage.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from flask import Flask, request, jsonify, g
|
||||
|
||||
@@ -112,6 +113,147 @@ def upload_dashcam_file():
|
||||
return jsonify(status="ok", size_bytes=size_bytes), 201
|
||||
|
||||
|
||||
# --- Chunked dashcam upload ---------------------------------------------
|
||||
# Real dashcam clips run 300+MB, well past Cloudflare Tunnel's ~100MB max
|
||||
# request body size (confirmed with a real 250MB file: a direct LAN upload
|
||||
# succeeds, the same request through the tunnel comes back 413). The Pi
|
||||
# client splits files into small chunks (see CHUNK_SIZE in
|
||||
# modules/app_upload.py) and PUTs them one at a time through this endpoint
|
||||
# instead, staying comfortably under that limit. Chunks for a given upload
|
||||
# are staged on disk under _CHUNK_TMP_DIR and reassembled once complete.
|
||||
|
||||
_CHUNK_TMP_DIRNAME = ".upload_tmp"
|
||||
|
||||
|
||||
def _chunk_tmp_dir(device_name, sha256):
|
||||
return os.path.join(STORAGE_DIR, _CHUNK_TMP_DIRNAME, device_name, sha256)
|
||||
|
||||
|
||||
def _received_chunk_indices(tmp_dir):
|
||||
if not os.path.isdir(tmp_dir):
|
||||
return set()
|
||||
indices = set()
|
||||
for name in os.listdir(tmp_dir):
|
||||
if name.endswith(".chunk"):
|
||||
try:
|
||||
indices.add(int(name[: -len(".chunk")]))
|
||||
except ValueError:
|
||||
continue
|
||||
return indices
|
||||
|
||||
|
||||
@app.get("/api/v1/dashcam/upload/status")
|
||||
@require_device_auth
|
||||
def dashcam_upload_status():
|
||||
"""Report upload progress for (relative_path, sha256) so the Pi client
|
||||
can resume a chunked upload without re-sending chunks the server
|
||||
already has (e.g. after a network drop or app restart mid-transfer)."""
|
||||
relative_path = request.args.get("relative_path", "").strip()
|
||||
sha256 = request.args.get("sha256", "").strip().lower()
|
||||
if not relative_path or not sha256:
|
||||
return jsonify(error="missing 'relative_path' or 'sha256' query param"), 400
|
||||
|
||||
if db.dashcam_file_exists(g.device_id, relative_path, sha256):
|
||||
return jsonify(status="already_uploaded"), 200
|
||||
|
||||
tmp_dir = _chunk_tmp_dir(g.device_name, sha256)
|
||||
received = sorted(_received_chunk_indices(tmp_dir))
|
||||
return jsonify(status="in_progress" if received else "not_started", received_chunk_indices=received), 200
|
||||
|
||||
|
||||
@app.post("/api/v1/dashcam/upload/chunk")
|
||||
@require_device_auth
|
||||
def dashcam_upload_chunk():
|
||||
"""Receive one chunk of a dashcam video file.
|
||||
|
||||
Multipart form fields:
|
||||
file: the chunk's bytes
|
||||
relative_path: original sub-folder path on the dashcam SD card
|
||||
sha256: hex digest of the *whole* (reassembled) file
|
||||
chunk_index: 0-based index of this chunk
|
||||
total_chunks: total number of chunks for this file
|
||||
|
||||
Once every chunk 0..total_chunks-1 has arrived, the chunks are
|
||||
concatenated in order, the combined sha256 is verified, and the result
|
||||
is recorded exactly like the single-shot /upload endpoint.
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify(error="missing 'file' in multipart form"), 400
|
||||
|
||||
relative_path = request.form.get("relative_path", "").strip()
|
||||
expected_sha256 = request.form.get("sha256", "").strip().lower()
|
||||
try:
|
||||
chunk_index = int(request.form.get("chunk_index", ""))
|
||||
total_chunks = int(request.form.get("total_chunks", ""))
|
||||
except ValueError:
|
||||
return jsonify(error="'chunk_index'/'total_chunks' must be integers"), 400
|
||||
|
||||
if not relative_path or not expected_sha256 or total_chunks < 1 or not (0 <= chunk_index < total_chunks):
|
||||
return jsonify(error="missing/invalid form fields"), 400
|
||||
|
||||
device_id = g.device_id
|
||||
if db.dashcam_file_exists(device_id, relative_path, expected_sha256):
|
||||
return jsonify(status="already_uploaded"), 200
|
||||
|
||||
device_dir = os.path.join(STORAGE_DIR, g.device_name)
|
||||
dest_path = os.path.normpath(os.path.join(device_dir, relative_path))
|
||||
if os.path.commonpath([device_dir, dest_path]) != os.path.normpath(device_dir):
|
||||
return jsonify(error="invalid relative_path"), 400
|
||||
|
||||
tmp_dir = _chunk_tmp_dir(g.device_name, expected_sha256)
|
||||
# A stale tmp_dir left over from a previous attempt with a *different*
|
||||
# total_chunks (e.g. the client's chunk size changed between app
|
||||
# versions) would reassemble incorrectly - start clean if that happens.
|
||||
marker_path = os.path.join(tmp_dir, ".total_chunks")
|
||||
if os.path.isdir(tmp_dir) and os.path.exists(marker_path):
|
||||
with open(marker_path) as marker_file:
|
||||
if marker_file.read().strip() != str(total_chunks):
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
with open(marker_path, "w") as marker_file:
|
||||
marker_file.write(str(total_chunks))
|
||||
|
||||
chunk_path = os.path.join(tmp_dir, f"{chunk_index:06d}.chunk")
|
||||
uploaded = request.files["file"]
|
||||
with open(chunk_path, "wb") as f:
|
||||
while True:
|
||||
data = uploaded.stream.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
f.write(data)
|
||||
|
||||
received = _received_chunk_indices(tmp_dir)
|
||||
if len(received) < total_chunks:
|
||||
return jsonify(status="chunk_received", received_chunks=len(received), total_chunks=total_chunks), 202
|
||||
|
||||
# All chunks in - reassemble, verify, and store.
|
||||
hasher = hashlib.sha256()
|
||||
tmp_path = dest_path + ".part"
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
size_bytes = 0
|
||||
with open(tmp_path, "wb") as out:
|
||||
for idx in range(total_chunks):
|
||||
with open(os.path.join(tmp_dir, f"{idx:06d}.chunk"), "rb") as chunk_file:
|
||||
while True:
|
||||
data = chunk_file.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
hasher.update(data)
|
||||
size_bytes += len(data)
|
||||
out.write(data)
|
||||
|
||||
actual_sha256 = hasher.hexdigest()
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
if actual_sha256 != expected_sha256:
|
||||
os.remove(tmp_path)
|
||||
return jsonify(error="sha256 mismatch, upload corrupted in transit"), 422
|
||||
|
||||
os.replace(tmp_path, dest_path)
|
||||
db.insert_dashcam_file(device_id, relative_path, size_bytes, actual_sha256, dest_path)
|
||||
return jsonify(status="complete", size_bytes=size_bytes), 201
|
||||
|
||||
|
||||
def main():
|
||||
app.run(host="0.0.0.0", port=8098)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user