From da622131fd0843f41fb6e439da7f88e383ce0a9d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 22:26:11 +0200 Subject: [PATCH] upload zip archive from strava --- bincio/edit/server.py | 45 +++++++++++++++++++++++++++++++++++- site/astro.config.mjs | 21 +++++++++++++++++ site/src/layouts/Base.astro | 11 +++++++++ tests/test_server_imports.py | 1 + 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 940eed5..207e6f2 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -11,7 +11,7 @@ from typing import Any from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID @@ -783,3 +783,46 @@ async def strava_reset(request: Request) -> JSONResponse: token["last_sync_at"] = last_ts save_token(dd, token) return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) + + +@app.post("/api/upload/strava-zip") +async def upload_strava_zip(file: UploadFile = File(...)) -> StreamingResponse: + """Accept a Strava bulk export ZIP and stream SSE progress while processing. + + The ZIP is written to a temp file, processed activity-by-activity, then deleted. + Originals are never kept — the UI informs the user of this upfront. + """ + if not file.filename or not file.filename.lower().endswith(".zip"): + raise HTTPException(400, "Please upload a .zip file") + + dd = _get_data_dir() + import tempfile + tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd) + zip_path = Path(tmp.name) + try: + while chunk := await file.read(1024 * 1024): # 1 MB chunks + tmp.write(chunk) + finally: + tmp.close() + + from bincio.extract.strava_zip import strava_zip_iter + from bincio.render.merge import merge_all + + def event_stream(): + any_imported = False + try: + for event in strava_zip_iter(zip_path, dd): + yield f"data: {json.dumps(event)}\n\n" + if event.get("type") == "progress" and event.get("status") == "imported": + any_imported = True + if event.get("type") == "done" and any_imported: + merge_all(dd) + except Exception as exc: + zip_path.unlink(missing_ok=True) + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/site/astro.config.mjs b/site/astro.config.mjs index d2c0249..5622396 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -25,6 +25,27 @@ export default defineConfig({ // In production nginx handles this — same pattern, no code change needed. server: { proxy: { + // SSE response to a POST — Vite's default proxy buffers the full body before + // forwarding, which breaks streaming and can cause EPIPE on long uploads. + // selfHandleResponse + manual pipe sends chunks as they arrive. + '/api/upload/strava-zip': { + target: serveTarget, + changeOrigin: true, + selfHandleResponse: true, + configure: (proxy) => { + proxy.on('proxyRes', (proxyRes, req, res) => { + res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + proxy.on('error', (err, _req, res) => { + if (err.code === 'EPIPE' || err.code === 'ECONNRESET') return; + if (!res.headersSent) { + res.writeHead(502); + res.end('proxy error'); + } + }); + }, + }, '/api': { target: serveTarget, changeOrigin: true, diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 9c1f76a..9a36962 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -732,6 +732,17 @@ try { } }; + xhr.onload = () => { + // Fires when the request completes. If we already got a 'done' or 'error' + // SSE event via onprogress the status is already set. If not (e.g. a non-SSE + // error response), surface the failure. + if (xhr.status !== 200) { + zipStatus.textContent = `Upload failed (${xhr.status}).`; + zipStatus.style.color = '#f87171'; + zipInput.value = ''; + } + }; + xhr.onerror = () => { zipStatus.textContent = 'Upload failed — check your connection.'; zipStatus.style.color = '#f87171'; diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py index 63f11c4..8124e16 100644 --- a/tests/test_server_imports.py +++ b/tests/test_server_imports.py @@ -27,5 +27,6 @@ def test_edit_app_has_routes(): from bincio.edit.server import app paths = {r.path for r in app.routes} assert "/api/upload" in paths + assert "/api/upload/strava-zip" in paths assert "/api/activity/{activity_id}" in paths assert "/api/strava/sync" in paths