"keep data on the server" opt-in/out

This commit is contained in:
Davide Scaini
2026-04-10 13:01:21 +02:00
parent 5170afa9e8
commit 469a5954cc
6 changed files with 77 additions and 15 deletions
+10 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Optional
# ── Shared constants (imported by edit/server.py and serve/server.py) ───────── # ── Shared constants (imported by edit/server.py and serve/server.py) ─────────
@@ -58,13 +58,20 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
merge_one(data_dir, activity_id) merge_one(data_dir, activity_id)
def run_strava_sync(data_dir: Path, client_id: str, client_secret: str) -> dict[str, Any]: def run_strava_sync(
data_dir: Path,
client_id: str,
client_secret: str,
originals_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Fetch new Strava activities and write them into data_dir. """Fetch new Strava activities and write them into data_dir.
Args: Args:
data_dir: Per-user data directory. data_dir: Per-user data directory.
client_id: Strava OAuth client ID. client_id: Strava OAuth client ID.
client_secret: Strava OAuth client secret. client_secret: Strava OAuth client secret.
originals_dir: If set, raw Strava API data (meta + streams) is saved here
as JSON files for potential future reprocessing.
Returns: Returns:
Dict with keys: ok, imported, skipped, error_count, errors. Dict with keys: ok, imported, skipped, error_count, errors.
@@ -75,7 +82,7 @@ def run_strava_sync(data_dir: Path, client_id: str, client_secret: str) -> dict[
from bincio.extract.ingest import strava_sync as _strava_sync from bincio.extract.ingest import strava_sync as _strava_sync
from bincio.render.merge import merge_all from bincio.render.merge import merge_all
result = _strava_sync(data_dir, client_id, client_secret) result = _strava_sync(data_dir, client_id, client_secret, originals_dir=originals_dir)
if result["imported"]: if result["imported"]:
merge_all(data_dir) merge_all(data_dir)
+14 -3
View File
@@ -7,7 +7,7 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, File, 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
@@ -517,7 +517,10 @@ def _file_suffix(name: str) -> str:
@app.post("/api/upload") @app.post("/api/upload")
async def upload_activity(file: UploadFile = File(...)) -> JSONResponse: async def upload_activity(
file: UploadFile = File(...),
store_original: bool = Form(False),
) -> JSONResponse:
"""Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge.""" """Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge."""
dd = _get_data_dir() dd = _get_data_dir()
@@ -536,6 +539,7 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
staged = staging / name staged = staging / name
staged.write_bytes(contents) staged.write_bytes(contents)
kept = False
try: try:
from bincio.extract.metrics import compute from bincio.extract.metrics import compute
from bincio.extract.parsers.factory import parse_file from bincio.extract.parsers.factory import parse_file
@@ -563,6 +567,12 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
existing[activity_id] = summary existing[activity_id] = summary
write_index(list(existing.values()), dd, owner) write_index(list(existing.values()), dd, owner)
if store_original:
originals_dir = dd / "originals"
originals_dir.mkdir(exist_ok=True)
staged.rename(originals_dir / name)
kept = True
from bincio.render.merge import merge_all from bincio.render.merge import merge_all
merge_all(dd) merge_all(dd)
@@ -571,7 +581,8 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
except Exception as exc: except Exception as exc:
raise HTTPException(422, f"Failed to process activity file: {type(exc).__name__}") raise HTTPException(422, f"Failed to process activity file: {type(exc).__name__}")
finally: finally:
staged.unlink(missing_ok=True) if not kept:
staged.unlink(missing_ok=True)
return JSONResponse({"ok": True, "id": activity_id}) return JSONResponse({"ok": True, "id": activity_id})
+7 -1
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from bincio.extract.models import ParsedActivity from bincio.extract.models import ParsedActivity
@@ -67,6 +66,7 @@ def strava_sync(
data_dir: Path, data_dir: Path,
client_id: str, client_id: str,
client_secret: str, client_secret: str,
originals_dir: Optional[Path] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Fetch new Strava activities and ingest them into data_dir. """Fetch new Strava activities and ingest them into data_dir.
@@ -119,6 +119,12 @@ def strava_sync(
skipped += 1 skipped += 1
continue continue
streams = fetch_streams(token["access_token"], meta["id"]) streams = fetch_streams(token["access_token"], meta["id"])
if originals_dir is not None:
orig_path = originals_dir / f"{activity_id}.json"
orig_path.write_text(
json.dumps({"meta": meta, "streams": streams}, indent=2),
encoding="utf-8",
)
parsed = strava_to_parsed(meta, streams) parsed = strava_to_parsed(meta, streams)
ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001) ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001)
imported += 1 imported += 1
+6 -1
View File
@@ -24,7 +24,7 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
Creates the SQLite database, the admin user, the per-user data directory, Creates the SQLite database, the admin user, the per-user data directory,
and prints a first invite code. Safe to re-run — skips steps already done. and prints a first invite code. Safe to re-run — skips steps already done.
""" """
from bincio.serve.db import create_invite, create_user, get_user, open_db, set_setting from bincio.serve.db import create_invite, create_user, get_user, open_db, set_setting, get_setting
dd = Path(data_dir).expanduser().resolve() dd = Path(data_dir).expanduser().resolve()
dd.mkdir(parents=True, exist_ok=True) dd.mkdir(parents=True, exist_ok=True)
@@ -75,6 +75,11 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
else: else:
console.print(" [dim]·[/dim] no user limit (unlimited)") console.print(" [dim]·[/dim] no user limit (unlimited)")
# ── Original file storage default ─────────────────────────────────────────
if get_setting(db, "store_originals") is None:
set_setting(db, "store_originals", "true")
console.print(" [green]✓[/green] store_originals = true (users can override per upload)")
# ── First invite code ───────────────────────────────────────────────────── # ── First invite code ─────────────────────────────────────────────────────
code = create_invite(db, handle) code = create_invite(db, handle)
+18 -3
View File
@@ -15,7 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from fastapi import Cookie, FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, 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 JSONResponse from fastapi.responses import JSONResponse
@@ -164,10 +164,12 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
user = _current_user(bincio_session) user = _current_user(bincio_session)
if not user: if not user:
raise HTTPException(404, "Not authenticated") raise HTTPException(404, "Not authenticated")
store_orig = get_setting(_get_db(), "store_originals")
return JSONResponse({ return JSONResponse({
"handle": user.handle, "handle": user.handle,
"display_name": user.display_name, "display_name": user.display_name,
"is_admin": user.is_admin, "is_admin": user.is_admin,
"store_originals_default": store_orig != "false",
}) })
@@ -466,6 +468,7 @@ _SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
@app.post("/api/upload") @app.post("/api/upload")
async def upload_activity( async def upload_activity(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
store_original: bool = Form(False),
bincio_session: Optional[str] = Cookie(default=None), bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
from bincio.extract.ingest import ingest_parsed from bincio.extract.ingest import ingest_parsed
@@ -496,6 +499,7 @@ async def upload_activity(
staged = staging / name staged = staging / name
staged.write_bytes(contents) staged.write_bytes(contents)
kept = False
try: try:
activity = parse_file(staged) activity = parse_file(staged)
activity_id = make_activity_id(activity) activity_id = make_activity_id(activity)
@@ -503,12 +507,18 @@ async def upload_activity(
results.append({"name": name, "ok": False, "error": "duplicate"}) results.append({"name": name, "ok": False, "error": "duplicate"})
continue continue
ingest_parsed(activity, dd, privacy="public") ingest_parsed(activity, dd, privacy="public")
if store_original:
originals_dir = dd / "originals"
originals_dir.mkdir(exist_ok=True)
staged.rename(originals_dir / name)
kept = True
results.append({"name": name, "ok": True, "id": activity_id}) results.append({"name": name, "ok": True, "id": activity_id})
any_added = True any_added = True
except Exception as exc: except Exception as exc:
results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"})
finally: finally:
staged.unlink(missing_ok=True) if not kept:
staged.unlink(missing_ok=True)
if any_added: if any_added:
merge_all(dd) merge_all(dd)
@@ -524,9 +534,14 @@ async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)
if not strava_client_id or not strava_client_secret: if not strava_client_id or not strava_client_secret:
raise HTTPException(400, "Strava not configured on this server") raise HTTPException(400, "Strava not configured on this server")
dd = _get_data_dir() / user.handle dd = _get_data_dir() / user.handle
store_orig_setting = get_setting(_get_db(), "store_originals")
store_orig = store_orig_setting == "true"
originals_dir = (dd / "originals" / "strava") if store_orig else None
if originals_dir:
originals_dir.mkdir(parents=True, exist_ok=True)
from bincio.edit.ops import run_strava_sync from bincio.edit.ops import run_strava_sync
try: try:
result = run_strava_sync(dd, strava_client_id, strava_client_secret) result = run_strava_sync(dd, strava_client_id, strava_client_secret, originals_dir=originals_dir)
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(502, str(e)) raise HTTPException(502, str(e))
_trigger_rebuild(user.handle) _trigger_rebuild(user.handle)
+22 -4
View File
@@ -175,6 +175,7 @@ try {
{mobileApp && ( {mobileApp && (
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a> <a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
)} )}
<a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors">About</a>
</> </>
)} )}
@@ -259,6 +260,17 @@ try {
<div id="upload-label">Drop FIT, GPX, or TCX files<br/>or click to browse</div> <div id="upload-label">Drop FIT, GPX, or TCX files<br/>or click to browse</div>
<input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" multiple /> <input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" multiple />
</div> </div>
<label class="flex items-start gap-2 mt-3 cursor-pointer group">
<input
id="upload-keep-original"
type="checkbox"
class="mt-0.5 accent-blue-500 shrink-0"
/>
<span class="text-xs text-zinc-500 group-hover:text-zinc-300 transition-colors leading-snug">
Keep original file on server
<span class="text-zinc-600 block mt-0.5">Lets you reprocess if the format changes. See the <a href={`${baseUrl}about/`} class="underline hover:text-zinc-400">About page</a> for details.</span>
</span>
</label>
<p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p> <p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p>
</div> </div>
@@ -366,6 +378,10 @@ try {
// Show logout button // Show logout button
const logoutEl = document.getElementById('nav-logout'); const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = ''; if (logoutEl) logoutEl.style.display = '';
// Pre-populate the "keep original" checkbox from the instance default
const chk = document.getElementById('upload-keep-original');
if (chk && user.store_originals_default) chk.checked = true;
} catch (_) {} } catch (_) {}
})(); })();
@@ -388,10 +404,11 @@ try {
const chooseStrava = document.getElementById('upload-choose-strava'); const chooseStrava = document.getElementById('upload-choose-strava');
const backFile = document.getElementById('upload-back-file'); const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava'); const backStrava = document.getElementById('upload-back-strava');
const drop = document.getElementById('upload-drop'); const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input'); const input = document.getElementById('upload-input');
const label = document.getElementById('upload-label'); const label = document.getElementById('upload-label');
const fileStatus = document.getElementById('upload-status'); const keepOriginalChk = document.getElementById('upload-keep-original');
const fileStatus = document.getElementById('upload-status');
const stravaStatus = document.getElementById('strava-status'); const stravaStatus = document.getElementById('strava-status');
const stravaConnect = document.getElementById('strava-connect-area'); const stravaConnect = document.getElementById('strava-connect-area');
const stravaSync = document.getElementById('strava-sync-area'); const stravaSync = document.getElementById('strava-sync-area');
@@ -449,6 +466,7 @@ try {
drop.style.pointerEvents = 'none'; drop.style.pointerEvents = 'none';
const fd = new FormData(); const fd = new FormData();
for (const f of files) fd.append('files', f); for (const f of files) fd.append('files', f);
fd.append('store_original', keepOriginalChk?.checked ? 'true' : 'false');
try { try {
const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd }); const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());