upload zip archive from strava

This commit is contained in:
Davide Scaini
2026-04-10 22:26:11 +02:00
parent fc6c00c6eb
commit da622131fd
4 changed files with 77 additions and 1 deletions
+44 -1
View File
@@ -11,7 +11,7 @@ from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware 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 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 token["last_sync_at"] = last_ts
save_token(dd, token) save_token(dd, token)
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) 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"},
)
+21
View File
@@ -25,6 +25,27 @@ export default defineConfig({
// In production nginx handles this — same pattern, no code change needed. // In production nginx handles this — same pattern, no code change needed.
server: { server: {
proxy: { 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': { '/api': {
target: serveTarget, target: serveTarget,
changeOrigin: true, changeOrigin: true,
+11
View File
@@ -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 = () => { xhr.onerror = () => {
zipStatus.textContent = 'Upload failed — check your connection.'; zipStatus.textContent = 'Upload failed — check your connection.';
zipStatus.style.color = '#f87171'; zipStatus.style.color = '#f87171';
+1
View File
@@ -27,5 +27,6 @@ def test_edit_app_has_routes():
from bincio.edit.server import app from bincio.edit.server import app
paths = {r.path for r in app.routes} paths = {r.path for r in app.routes}
assert "/api/upload" in paths assert "/api/upload" in paths
assert "/api/upload/strava-zip" in paths
assert "/api/activity/{activity_id}" in paths assert "/api/activity/{activity_id}" in paths
assert "/api/strava/sync" in paths assert "/api/strava/sync" in paths