From 8ceb71476574c90968e890540e87f9e9f58f10f8 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 12:50:38 +0200 Subject: [PATCH 001/124] bulk upload --- bincio/serve/server.py | 72 ++++++++++++++++++++++--------------- site/src/layouts/Base.astro | 31 +++++++++------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 3e6e202..983a07d 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -465,43 +465,57 @@ _SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} @app.post("/api/upload") async def upload_activity( - file: UploadFile = File(...), + files: list[UploadFile] = File(...), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: + from bincio.extract.ingest import ingest_parsed + from bincio.extract.parsers.factory import parse_file + from bincio.extract.writer import make_activity_id + from bincio.render.merge import merge_all + user = _require_user(bincio_session) dd = _get_data_dir() / user.handle - name = Path(file.filename or "upload.fit").name - p = Path(name.lower()) - suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix - if suffix not in _SUPPORTED_SUFFIXES: - raise HTTPException(400, f"Unsupported file type '{suffix}'") - contents = await file.read() - if len(contents) > 50 * 1024 * 1024: - raise HTTPException(413, "File too large (max 50 MB)") staging = dd / "_uploads" staging.mkdir(exist_ok=True) - staged = staging / name - staged.write_bytes(contents) - try: - from bincio.extract.ingest import ingest_parsed - from bincio.extract.parsers.factory import parse_file - activity = parse_file(staged) - activity_id_check = dd / "activities" / f"{activity.source_file}.json" - from bincio.extract.writer import make_activity_id - activity_id = make_activity_id(activity) - if (dd / "activities" / f"{activity_id}.json").exists(): - raise HTTPException(409, f"Activity already exists: {activity_id}") - ingest_parsed(activity, dd, privacy="public") - from bincio.render.merge import merge_all + + results = [] + any_added = False + + for file in files: + name = Path(file.filename or "upload.fit").name + p = Path(name.lower()) + suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix + if suffix not in _SUPPORTED_SUFFIXES: + results.append({"name": name, "ok": False, "error": f"Unsupported file type '{suffix}'"}) + continue + + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: + results.append({"name": name, "ok": False, "error": "File too large (max 50 MB)"}) + continue + + staged = staging / name + staged.write_bytes(contents) + try: + activity = parse_file(staged) + activity_id = make_activity_id(activity) + if (dd / "activities" / f"{activity_id}.json").exists(): + results.append({"name": name, "ok": False, "error": "duplicate"}) + continue + ingest_parsed(activity, dd, privacy="public") + results.append({"name": name, "ok": True, "id": activity_id}) + any_added = True + except Exception as exc: + results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) + finally: + staged.unlink(missing_ok=True) + + if any_added: merge_all(dd) _trigger_rebuild(user.handle) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(422, f"Failed to process activity: {type(exc).__name__}: {exc}") - finally: - staged.unlink(missing_ok=True) - return JSONResponse({"ok": True, "id": activity_id}) + + added = [r for r in results if r["ok"]] + return JSONResponse({"ok": True, "added": len(added), "results": results}) @app.post("/api/strava/sync") diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 5411cff..679daf8 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -256,8 +256,8 @@ try { id="upload-drop" class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors" > -
Drop a FIT, GPX, or TCX file
or click to browse
- +
Drop FIT, GPX, or TCX files
or click to browse
+

@@ -437,24 +437,31 @@ try { e.preventDefault(); drop.style.borderColor = ''; drop.style.color = ''; - if (e.dataTransfer?.files[0]) doUpload(e.dataTransfer.files[0]); + if (e.dataTransfer?.files.length) doUpload(e.dataTransfer.files); }); - input.addEventListener('change', () => { if (input.files?.[0]) doUpload(input.files[0]); }); + input.addEventListener('change', () => { if (input.files?.length) doUpload(input.files); }); - async function doUpload(file) { - label.textContent = file.name; - fileStatus.textContent = 'Uploading…'; + async function doUpload(files) { + const n = files.length; + label.textContent = n === 1 ? files[0].name : `${n} files selected`; + fileStatus.textContent = `Uploading ${n} file${n > 1 ? 's' : ''}…`; fileStatus.style.color = 'var(--text-4)'; drop.style.pointerEvents = 'none'; const fd = new FormData(); - fd.append('file', file); + for (const f of files) fd.append('files', f); try { - const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd }); + const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); - fileStatus.textContent = 'Done! Opening activity…'; - fileStatus.style.color = '#4ade80'; - setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600); + const dupes = d.results.filter(r => r.error === 'duplicate').length; + const errors = d.results.filter(r => !r.ok && r.error !== 'duplicate').length; + let msg = `${d.added} added`; + if (dupes) msg += `, ${dupes} duplicate${dupes > 1 ? 's' : ''}`; + if (errors) msg += `, ${errors} failed`; + fileStatus.textContent = msg; + fileStatus.style.color = d.added > 0 ? '#4ade80' : '#a1a1aa'; + if (d.added > 0) setTimeout(() => { window.location.reload(); }, 1200); + else drop.style.pointerEvents = ''; } catch (e) { fileStatus.textContent = 'Error: ' + e.message; fileStatus.style.color = '#f87171'; From 5170afa9e8874c30b8aff4939ab3cbaf031690ed Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 12:53:35 +0200 Subject: [PATCH 002/124] vps instructions --- docs/deployment/vps.md | 298 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 docs/deployment/vps.md diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md new file mode 100644 index 0000000..71719e2 --- /dev/null +++ b/docs/deployment/vps.md @@ -0,0 +1,298 @@ +# VPS deployment guide + +Concrete setup for a Debian VPS running a private multi-user bincio instance. +Code is deployed directly from your laptop via `git push` — no GitHub required. + +## Assumptions + +- Bare Debian 12 VPS with root SSH access +- You own a domain pointed at the VPS +- You have Strava API credentials +- Up to ~30 users + +--- + +## 1. Install system dependencies + +```bash +apt update && apt upgrade -y +apt install -y git curl nginx certbot python3-certbot-nginx sqlite3 +``` + +**Node.js 20 LTS** (the Debian package is too old): +```bash +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +apt install -y nodejs +``` + +**uv** (manages Python and all Python deps): +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +# add to PATH: +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +## 2. Set up the code directory + +```bash +mkdir -p /opt/bincio +git init --bare /opt/bincio-repo.git +``` + +Create the post-receive hook at `/opt/bincio-repo.git/hooks/post-receive`: + +```bash +#!/bin/bash +set -e + +REPO=/opt/bincio-repo.git +DEPLOY=/opt/bincio +DATA=/var/bincio/data + +echo "--- Checking out code ---" +git --work-tree=$DEPLOY --git-dir=$REPO checkout -f + +echo "--- Syncing Python deps ---" +cd $DEPLOY +~/.local/bin/uv sync + +echo "--- Syncing JS deps ---" +cd $DEPLOY/site +npm install --silent + +echo "--- Building site ---" +cd $DEPLOY +~/.local/bin/uv run bincio render --data-dir $DATA --site-dir $DEPLOY/site + +echo "--- Copying dist to webroot ---" +rsync -a --delete $DEPLOY/site/dist/ /var/www/bincio/ + +echo "--- Restarting API ---" +systemctl restart bincio + +echo "--- Done ---" +``` + +```bash +chmod +x /opt/bincio-repo.git/hooks/post-receive +mkdir -p /var/www/bincio +``` + +--- + +## 3. First deploy from your laptop + +Add the VPS as a git remote (run this locally, once): + +```bash +git remote add vps root@:/opt/bincio-repo.git +``` + +Push your code: + +```bash +git push vps main +``` + +The hook checks out the code, installs deps, and builds the site. +Subsequent pushes (including unpublished branches) work the same way: + +```bash +git push vps mobile_app # deploy any branch directly +``` + +--- + +## 4. Initialise the instance + +```bash +cd /opt/bincio + +uv run bincio init \ + --data-dir /var/bincio/data \ + --handle dave \ + --display-name "Dave" \ + --name "My Bincio" +# prompted for password; prints a first invite code +``` + +Set the user cap: + +```bash +sqlite3 /var/bincio/data/instance.db \ + "INSERT INTO settings VALUES ('max_users', '30');" +``` + +--- + +## 5. Prepare your own activities + +Source files (raw GPX/FIT) live separately from the BAS output: + +``` +/var/bincio/sources/dave/ ← raw activity files, rsync'd from laptop +/var/bincio/data/dave/ ← BAS JSON output (bincio extract writes here) +``` + +Configure `/opt/bincio/extract_config.yaml` on the server to point to your +source dir: + +```yaml +sources: + - path: /var/bincio/sources/dave/activities + type: strava_export + - path: /var/bincio/sources/dave/activities.csv + type: strava_csv + +output: + dir: /var/bincio/data +``` + +Sync and extract (run from your laptop or SSH in): + +```bash +# push raw files from laptop +rsync -avz ~/your-activity-data/ root@:/var/bincio/sources/dave/ + +# extract on server +ssh root@ "cd /opt/bincio && uv run bincio extract" + +# rebuild site +ssh root@ "cd /opt/bincio && \ + uv run bincio render --data-dir /var/bincio/data --site-dir site && \ + rsync -a --delete site/dist/ /var/www/bincio/" +``` + +--- + +## 6. systemd service + +Create `/etc/systemd/system/bincio.service`: + +```ini +[Unit] +Description=BincioActivity API +After=network.target + +[Service] +WorkingDirectory=/opt/bincio +ExecStart=/root/.local/bin/uv run bincio serve \ + --data-dir /var/bincio/data \ + --site-dir /opt/bincio/site \ + --host 127.0.0.1 \ + --port 4041 +EnvironmentFile=/etc/bincio/secrets.env +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Create `/etc/bincio/secrets.env`: + +```bash +mkdir -p /etc/bincio +chmod 700 /etc/bincio +cat > /etc/bincio/secrets.env <:/var/bincio/sources/dave/` | +| Re-extract after sync | `ssh root@ "cd /opt/bincio && uv run bincio extract"` then push again to rebuild | +| View API logs | `journalctl -u bincio -f` | +| Restart API | `systemctl restart bincio` | +| Check nginx logs | `tail -f /var/log/nginx/error.log` | +| Renew SSL (auto) | `certbot renew --dry-run` | + +--- + +## See also + +- [Multi-user architecture](multi-user.md) +- [CLI reference](../reference/cli.md) +- [API reference](../reference/api.md) From 469a5954ccc0293df836e5d0d58228ea0cd2be66 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:01:21 +0200 Subject: [PATCH 003/124] "keep data on the server" opt-in/out --- bincio/edit/ops.py | 13 ++++++++++--- bincio/edit/server.py | 17 ++++++++++++++--- bincio/extract/ingest.py | 8 +++++++- bincio/serve/init_cmd.py | 7 ++++++- bincio/serve/server.py | 21 ++++++++++++++++++--- site/src/layouts/Base.astro | 26 ++++++++++++++++++++++---- 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/bincio/edit/ops.py b/bincio/edit/ops.py index fddddb5..50a1f16 100644 --- a/bincio/edit/ops.py +++ b/bincio/edit/ops.py @@ -9,7 +9,7 @@ from __future__ import annotations import json import re from pathlib import Path -from typing import Any +from typing import Any, Optional # ── 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) -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. Args: data_dir: Per-user data directory. client_id: Strava OAuth client ID. 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: 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.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"]: merge_all(data_dir) diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 7e7b516..7cb99ed 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -7,7 +7,7 @@ import shutil from pathlib import Path 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.gzip import GZipMiddleware from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -517,7 +517,10 @@ def _file_suffix(name: str) -> str: @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.""" dd = _get_data_dir() @@ -536,6 +539,7 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse: staged = staging / name staged.write_bytes(contents) + kept = False try: from bincio.extract.metrics import compute from bincio.extract.parsers.factory import parse_file @@ -563,6 +567,12 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse: existing[activity_id] = summary 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 merge_all(dd) @@ -571,7 +581,8 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse: except Exception as exc: raise HTTPException(422, f"Failed to process activity file: {type(exc).__name__}") finally: - staged.unlink(missing_ok=True) + if not kept: + staged.unlink(missing_ok=True) return JSONResponse({"ok": True, "id": activity_id}) diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py index 84406eb..7515251 100644 --- a/bincio/extract/ingest.py +++ b/bincio/extract/ingest.py @@ -10,7 +10,6 @@ from __future__ import annotations import json from pathlib import Path from typing import Any, Optional - from bincio.extract.models import ParsedActivity @@ -67,6 +66,7 @@ def strava_sync( data_dir: Path, client_id: str, client_secret: str, + originals_dir: Optional[Path] = None, ) -> dict[str, Any]: """Fetch new Strava activities and ingest them into data_dir. @@ -119,6 +119,12 @@ def strava_sync( skipped += 1 continue 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) ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001) imported += 1 diff --git a/bincio/serve/init_cmd.py b/bincio/serve/init_cmd.py index 5122857..5dfbb84 100644 --- a/bincio/serve/init_cmd.py +++ b/bincio/serve/init_cmd.py @@ -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, 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.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: 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 ───────────────────────────────────────────────────── code = create_invite(db, handle) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 983a07d..d6f3f95 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -15,7 +15,7 @@ import time from pathlib import Path 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.gzip import GZipMiddleware 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) if not user: raise HTTPException(404, "Not authenticated") + store_orig = get_setting(_get_db(), "store_originals") return JSONResponse({ "handle": user.handle, "display_name": user.display_name, "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") async def upload_activity( files: list[UploadFile] = File(...), + store_original: bool = Form(False), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: from bincio.extract.ingest import ingest_parsed @@ -496,6 +499,7 @@ async def upload_activity( staged = staging / name staged.write_bytes(contents) + kept = False try: activity = parse_file(staged) activity_id = make_activity_id(activity) @@ -503,12 +507,18 @@ async def upload_activity( results.append({"name": name, "ok": False, "error": "duplicate"}) continue 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}) any_added = True except Exception as exc: results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) finally: - staged.unlink(missing_ok=True) + if not kept: + staged.unlink(missing_ok=True) if any_added: 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: raise HTTPException(400, "Strava not configured on this server") 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 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: raise HTTPException(502, str(e)) _trigger_rebuild(user.handle) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 679daf8..7cc36f7 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -175,6 +175,7 @@ try { {mobileApp && ( Convert )} + About )} @@ -259,6 +260,17 @@ try {
Drop FIT, GPX, or TCX files
or click to browse
+

@@ -366,6 +378,10 @@ try { // Show logout button const logoutEl = document.getElementById('nav-logout'); 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 (_) {} })(); @@ -388,10 +404,11 @@ try { const chooseStrava = document.getElementById('upload-choose-strava'); const backFile = document.getElementById('upload-back-file'); const backStrava = document.getElementById('upload-back-strava'); - const drop = document.getElementById('upload-drop'); - const input = document.getElementById('upload-input'); - const label = document.getElementById('upload-label'); - const fileStatus = document.getElementById('upload-status'); + const drop = document.getElementById('upload-drop'); + const input = document.getElementById('upload-input'); + const label = document.getElementById('upload-label'); + const keepOriginalChk = document.getElementById('upload-keep-original'); + const fileStatus = document.getElementById('upload-status'); const stravaStatus = document.getElementById('strava-status'); const stravaConnect = document.getElementById('strava-connect-area'); const stravaSync = document.getElementById('strava-sync-area'); @@ -449,6 +466,7 @@ try { drop.style.pointerEvents = 'none'; const fd = new FormData(); for (const f of files) fd.append('files', f); + fd.append('store_original', keepOriginalChk?.checked ? 'true' : 'false'); try { const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd }); if (!r.ok) throw new Error(await r.text()); From f37e898eb59803a2c383885aaf13fd10dccc953d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:05:51 +0200 Subject: [PATCH 004/124] about page --- site/src/pages/about/ca/index.astro | 114 ++++++++++++++++++++++++++++ site/src/pages/about/es/index.astro | 113 +++++++++++++++++++++++++++ site/src/pages/about/index.astro | 109 ++++++++++++++++++++++++++ site/src/pages/about/it/index.astro | 113 +++++++++++++++++++++++++++ 4 files changed, 449 insertions(+) create mode 100644 site/src/pages/about/ca/index.astro create mode 100644 site/src/pages/about/es/index.astro create mode 100644 site/src/pages/about/index.astro create mode 100644 site/src/pages/about/it/index.astro diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro new file mode 100644 index 0000000..5452a76 --- /dev/null +++ b/site/src/pages/about/ca/index.astro @@ -0,0 +1,114 @@ +--- +import Base from '../../../layouts/Base.astro'; +const baseUrl = import.meta.env.BASE_URL ?? '/'; +--- + +
+
+

Sobre BincioActivity

+
+ EN + IT + ES + CA +
+
+

Seguiment d'activitats de codi obert i allotjament propi

+ + ☕ Dona suport a Ko-fi + + +
+ +
+

Què és això?

+

+ BincioActivity és una plataforma gratuïta i de codi obert per registrar les teves + activitats a l'aire lliure: ciclisme, córrer, senderisme i més. Està dissenyada per + ser allotjada pel propi usuari: tu (o algú de confiança) gestioneu el servidor, i + les teves dades resten sota el teu control. +

+

+ Les activitats s'emmagatzemen en un format JSON obert anomenat BAS (BincioActivity Schema), + dissenyat per ser llegible i portable. La plataforma no té analítiques ocultes, + no inclou publicitat i no comparteix dades amb tercers. +

+
+ +
+

Les teves dades en aquest servidor

+

+ Quan puges un fitxer FIT, GPX o TCX, el servidor el converteix al format BAS. + Per defecte, el fitxer font original també es desa a la carpeta + originals/ del teu compte. + Pots desactivar aquesta opció en el moment de la pujada desmarcant + "Conserva el fitxer original al servidor". +

+

+ Es recomana conservar els originals durant aquestes primeres etapes del projecte: + si la cadena de processament millora (millor suavitzat d'elevació, càlcul de velocitat, + detecció de voltes, etc.) podràs tornar a importar els fitxers per aprofitar els canvis. + Si has triat no conservar els originals, hauràs de tornar a pujar els fitxers manualment. +

+

+ En sincronitzar amb Strava, les dades brutes obtingudes de l'API de Strava també + es poden emmagatzemar localment. Això ho controla una configuració global del servidor + establerta per l'operador. +

+
+ +
+

Programari en fase inicial

+

+ BincioActivity està en desenvolupament actiu. El format de dades, la cadena de + processament i l'API del servidor poden canviar entre versions. Els canvis + incompatibles són possibles, especialment en aquesta etapa. Quan es produeixin, + tornar a importar els fitxers originals és la manera més segura d'actualitzar + les teves dades. +

+

+ No hi ha cap garantia de disponibilitat, integritat de les dades ni compatibilitat + futura per a cap versió en particular. Fes servir aquest programari sota la teva + pròpia responsabilitat i conserva les teves pròpies còpies de seguretat de les + dades importants. +

+
+ +
+

Limitació de responsabilitat

+

+ BincioActivity es proporciona "tal com és", sense + cap garantia de cap mena. Els autors i operadors del servidor no accepten cap + responsabilitat per: +

+
    +
  • Pèrdua, corrupció o accés no autoritzat a les teves dades d'activitat
  • +
  • Dades exposades per una configuració incorrecta del servidor o la infraestructura
  • +
  • Inexactituds en les estadístiques calculades (distància, desnivell, freqüència cardíaca, etc.)
  • +
  • Qualsevol conseqüència derivada d'actuar sobre la informació mostrada per aquesta aplicació
  • +
+

+ Ets responsable de protegir el teu compte amb una contrasenya segura, de revisar + quines dades comparteixes i de fer les teves pròpies còpies de seguretat. Les dades + de GPS i salut poden ser sensibles — reflexiona sobre el que puges i qui ho pot veure. +

+
+ +
+

Codi obert

+

+ BincioActivity és programari de codi obert. Ets lliure d'inspeccionar el codi, + allotjar la teva pròpia instància i contribuir amb millores. +

+
+ + +
+
+ diff --git a/site/src/pages/about/es/index.astro b/site/src/pages/about/es/index.astro new file mode 100644 index 0000000..8633e72 --- /dev/null +++ b/site/src/pages/about/es/index.astro @@ -0,0 +1,113 @@ +--- +import Base from '../../../layouts/Base.astro'; +const baseUrl = import.meta.env.BASE_URL ?? '/'; +--- + +
+
+

Acerca de BincioActivity

+
+ EN + IT + ES + CA +
+
+

Seguimiento de actividades open-source y autoalojado

+ + ☕ Apoya en Ko-fi + + +
+ +
+

¿Qué es esto?

+

+ BincioActivity es una plataforma gratuita y de código abierto para registrar tus + actividades al aire libre: ciclismo, running, senderismo y más. Está diseñada para + ser autoalojada: tú (o alguien de confianza) gestionas el servidor, y tus datos + permanecen bajo tu control. +

+

+ Las actividades se almacenan en un formato JSON abierto llamado BAS (BincioActivity Schema), + diseñado para ser legible y portable. La plataforma no tiene analíticas ocultas, + no incluye publicidad y no comparte datos con terceros. +

+
+ +
+

Tus datos en este servidor

+

+ Cuando subes un archivo FIT, GPX o TCX, el servidor lo convierte al formato BAS. + Por defecto, el archivo fuente original también se guarda en la carpeta + originals/ de tu cuenta. + Puedes desactivar esta opción en el momento de la subida desmarcando + "Conservar el archivo original en el servidor". +

+

+ Se recomienda conservar los originales durante estas primeras etapas del proyecto: + si la cadena de procesamiento mejora (mejor suavizado de elevación, cálculo de velocidad, + detección de vueltas, etc.) podrás volver a importar tus archivos para aprovechar los + cambios. Si elegiste no conservar los originales, tendrías que subir los archivos + de nuevo manualmente. +

+

+ Al sincronizar con Strava, los datos brutos obtenidos de la API de Strava también + pueden almacenarse localmente. Esto lo controla una configuración global del servidor + establecida por el operador. +

+
+ +
+

Software en fase temprana

+

+ BincioActivity está en desarrollo activo. El formato de datos, la cadena de procesamiento + y la API del servidor pueden cambiar entre versiones. Los cambios incompatibles son + posibles, especialmente en esta etapa. Cuando ocurran, volver a importar los archivos + originales es la forma más segura de actualizar tus datos. +

+

+ No existe ninguna garantía de disponibilidad, integridad de datos ni compatibilidad + futura para ninguna versión en particular. Usa este software bajo tu propia + responsabilidad y mantén tus propias copias de seguridad de los datos importantes. +

+
+ +
+

Descargo de responsabilidad

+

+ BincioActivity se proporciona "tal cual", sin + garantía de ningún tipo. Los autores y operadores del servidor no aceptan ninguna + responsabilidad por: +

+
    +
  • Pérdida, corrupción o acceso no autorizado a tus datos de actividad
  • +
  • Datos expuestos por una mala configuración del servidor o la infraestructura
  • +
  • Inexactitudes en las estadísticas calculadas (distancia, elevación, frecuencia cardíaca, etc.)
  • +
  • Cualquier consecuencia derivada de actuar sobre la información mostrada por esta aplicación
  • +
+

+ Eres responsable de proteger tu cuenta con una contraseña segura, de revisar qué + datos compartes y de realizar tus propias copias de seguridad. Los datos de GPS y + salud pueden ser sensibles — reflexiona sobre qué subes y quién puede verlo. +

+
+ +
+

Código abierto

+

+ BincioActivity es software de código abierto. Eres libre de inspeccionar el código, + alojar tu propia instancia y contribuir con mejoras. +

+
+ + +
+
+ diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro new file mode 100644 index 0000000..883e64b --- /dev/null +++ b/site/src/pages/about/index.astro @@ -0,0 +1,109 @@ +--- +import Base from '../../layouts/Base.astro'; +const baseUrl = import.meta.env.BASE_URL ?? '/'; +--- + +
+
+

About BincioActivity

+
+ EN + IT + ES + CA +
+
+

Open-source, self-hosted activity tracking

+ + ☕ Support on Ko-fi + + +
+ +
+

What is this?

+

+ BincioActivity is a free, open-source platform for tracking your outdoor activities — + cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone + you trust) run the server, and your data stays under your control. +

+

+ Activities are stored in an open JSON format called BAS (BincioActivity Schema), + which is designed to be readable and portable. The platform has no hidden analytics, + no advertising, and no third-party data sharing. +

+
+ +
+

Your data on this server

+

+ When you upload a FIT, GPX, or TCX file, the server converts it to BAS format. + By default the original source file is also kept in your account's + originals/ folder. + You can opt out of this at upload time by unchecking "Keep original file on server". +

+

+ Keeping originals is recommended during these early stages of the project: if the + processing pipeline improves (better elevation smoothing, speed calculation, lap + detection, etc.) you can re-import your files to take advantage of the changes. + If you chose not to keep originals, you would need to upload the files again manually. +

+

+ When syncing from Strava, the raw activity data fetched from the Strava API can + similarly be stored locally. This is controlled by an instance-wide setting + configured by the server operator. +

+
+ +
+

Early-stage software

+

+ BincioActivity is under active development. The data format, processing pipeline, + and server API may change between versions. Breaking changes are possible, especially + at this stage. When they occur, re-importing your original files is the safest way + to bring your data up to date. +

+

+ There is no guarantee of uptime, data integrity, or forward compatibility for + any particular version. Use this software at your own risk, and keep your own + backups of important data. +

+
+ +
+

Disclaimer

+

+ BincioActivity is provided "as is", without + warranty of any kind. The authors and server operators accept no responsibility for: +

+
    +
  • Loss, corruption, or unauthorised access to your activity data
  • +
  • Data exposed through misconfiguration of the server or infrastructure
  • +
  • Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)
  • +
  • Any consequences of acting on information displayed by this application
  • +
+

+ You are responsible for securing your account with a strong password, reviewing + what data you share, and making your own backups. GPS and health data can be + sensitive — think carefully about what you upload and who can see it. +

+
+ +
+

Open source

+

+ BincioActivity is open-source software. You are free to inspect the code, + self-host your own instance, and contribute improvements. +

+
+ + +
+
+ diff --git a/site/src/pages/about/it/index.astro b/site/src/pages/about/it/index.astro new file mode 100644 index 0000000..3488fef --- /dev/null +++ b/site/src/pages/about/it/index.astro @@ -0,0 +1,113 @@ +--- +import Base from '../../../layouts/Base.astro'; +const baseUrl = import.meta.env.BASE_URL ?? '/'; +--- + +
+
+

Informazioni su BincioActivity

+
+ EN + IT + ES + CA +
+
+

Tracciamento attività open-source e self-hosted

+ + ☕ Supporta su Ko-fi + + +
+ +
+

Cos'è?

+

+ BincioActivity è una piattaforma gratuita e open-source per tracciare le tue attività + all'aperto — ciclismo, corsa, escursionismo e altro. È progettata per essere + self-hosted: tu (o qualcuno di cui ti fidi) gestisci il server, e i tuoi dati + rimangono sotto il tuo controllo. +

+

+ Le attività vengono salvate in un formato JSON aperto chiamato BAS (BincioActivity Schema), + progettato per essere leggibile e portabile. La piattaforma non ha analytics nascosti, + nessuna pubblicità e nessuna condivisione di dati con terze parti. +

+
+ +
+

I tuoi dati su questo server

+

+ Quando carichi un file FIT, GPX o TCX, il server lo converte nel formato BAS. + Di default, il file sorgente originale viene conservato nella cartella + originals/ del tuo account. + Puoi disattivare questa opzione al momento del caricamento deselezionando + "Mantieni il file originale sul server". +

+

+ Conservare i file originali è consigliato in questa fase iniziale del progetto: se la + pipeline di elaborazione migliorasse (migliore smoothing del dislivello, calcolo della + velocità, rilevamento dei giri, ecc.) potrai reimportare i file per beneficiare delle + modifiche. Se hai scelto di non conservare gli originali, dovrai ricaricare i file + manualmente. +

+

+ Durante la sincronizzazione con Strava, i dati grezzi dell'attività ottenuti dall'API + Strava possono essere conservati localmente. Questo è controllato da un'impostazione + a livello di istanza configurata dall'operatore del server. +

+
+ +
+

Software in fase iniziale

+

+ BincioActivity è in sviluppo attivo. Il formato dei dati, la pipeline di elaborazione + e le API del server potrebbero cambiare tra una versione e l'altra. Modifiche + incompatibili sono possibili, soprattutto in questa fase. Quando si verificano, + reimportare i file originali è il modo più sicuro per aggiornare i propri dati. +

+

+ Non vi è alcuna garanzia di uptime, integrità dei dati o compatibilità futura per + nessuna versione specifica. Usa questo software a tuo rischio e pericolo, e conserva + sempre i tuoi backup dei dati importanti. +

+
+ +
+

Limitazione di responsabilità

+

+ BincioActivity è fornito "così com'è", senza + garanzie di alcun tipo. Gli autori e gli operatori del server non si assumono alcuna + responsabilità per: +

+
    +
  • Perdita, corruzione o accesso non autorizzato ai tuoi dati di attività
  • +
  • Dati esposti a causa di una configurazione errata del server o dell'infrastruttura
  • +
  • Imprecisioni nelle statistiche calcolate (distanza, dislivello, frequenza cardiaca, ecc.)
  • +
  • Qualsiasi conseguenza derivante dall'utilizzo delle informazioni visualizzate dall'applicazione
  • +
+

+ Sei responsabile di proteggere il tuo account con una password robusta, di verificare + quali dati condividi e di eseguire i tuoi backup. I dati GPS e sanitari possono essere + sensibili — rifletti attentamente su cosa carichi e su chi può vederlo. +

+
+ +
+

Open source

+

+ BincioActivity è software open-source. Sei libero di esaminare il codice, + ospitare la tua istanza e contribuire con miglioramenti. +

+
+ + +
+
+ From 6b2d31a44af071e9784a7e1526675cb33bb6ca1f Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:06:00 +0200 Subject: [PATCH 005/124] test server imports --- tests/test_server_imports.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_server_imports.py diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py new file mode 100644 index 0000000..524e52b --- /dev/null +++ b/tests/test_server_imports.py @@ -0,0 +1,26 @@ +"""Smoke tests: import both FastAPI apps so missing names and bad syntax fail fast.""" + + +def test_serve_server_importable(): + import bincio.serve.server # noqa: F401 + + +def test_edit_server_importable(): + import bincio.edit.server # noqa: F401 + + +def test_serve_app_has_routes(): + from bincio.serve.server import app + paths = {r.path for r in app.routes} + assert "/api/me" in paths + assert "/api/upload" in paths + assert "/api/strava/sync" in paths + assert "/api/register" in paths + + +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/activity/{activity_id}" in paths + assert "/api/strava/sync" in paths From 053da10ab9a02be7ce4a475d0cd3265b0cc64077 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:21:31 +0200 Subject: [PATCH 006/124] some basic statistics and invite tree, plus watch new data --- CHANGELOG.md | 135 ++++++++++++++++++++++++++++ CLAUDE.md | 28 +++++- bincio/dev.py | 60 +++++++++++++ bincio/serve/db.py | 27 ++++++ bincio/serve/server.py | 22 +++++ site/src/pages/about/ca/index.astro | 94 ++++++++++++++++++- site/src/pages/about/es/index.astro | 94 ++++++++++++++++++- site/src/pages/about/index.astro | 106 +++++++++++++++++++++- site/src/pages/about/it/index.astro | 94 ++++++++++++++++++- 9 files changed, 655 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6ffc0..16ce436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,140 @@ # Changelog +## [Unreleased] — 2026-04-10 + +### New feature — Per-instance user limit + +Operators can now cap the maximum number of registered users on an instance. + +- **`bincio/serve/db.py`** + - New `settings` table (key/value, upsert-safe via `ON CONFLICT DO UPDATE`). + - `count_users(db)` — returns total number of rows in `users`. + - `get_setting(db, key)` / `set_setting(db, key, value)` — generic persistent settings store. + +- **`bincio/serve/server.py`** — `POST /api/register` now reads the `max_users` setting; if + set to N > 0 and the current user count is already ≥ N, registration is rejected with + HTTP 403 and a clear message. Imports `count_users` and `get_setting`. + +- **`bincio/serve/init_cmd.py`** — new `--max-users N` flag (default 0 = unlimited). Saves + the value to the `settings` table via `set_setting`. Printed in the init summary. + +- **`bincio/serve/cli.py`** — new `--max-users N` flag on `bincio serve`. Writes to the DB + on startup (lets operators change the limit without re-running `bincio init`). Startup + banner now shows `Users: max N` or `Users: unlimited`. + +--- + +### New feature — Original file storage option (upload & Strava sync) + +Users can now choose whether to keep their source files on the server after processing. +Keeping originals allows reprocessing if the pipeline improves; discarding them is the +privacy-conscious choice. Previously, uploaded files were always deleted after processing. + +- **`bincio/serve/db.py`** — `store_originals` is stored as a settings key. `bincio init` + writes `store_originals=true` on first run. + +- **`bincio/serve/server.py`** — `POST /api/upload` accepts a new `store_original: bool` + form field. On success, if true, the staged file is moved to `{user_dir}/originals/` + instead of being deleted. `GET /api/me` now includes `store_originals_default: bool` + (read from the instance setting) so the frontend can pre-populate the checkbox. + `POST /api/strava/sync` checks the `store_originals` instance setting; if true, creates + `{user_dir}/originals/strava/` and passes it as `originals_dir` to `run_strava_sync`. + +- **`bincio/edit/server.py`** — `POST /api/upload` gains the same `store_original` form + field with identical behaviour (originals stored in `{data_dir}/originals/`). + +- **`bincio/edit/ops.py`** — `run_strava_sync` gains an `originals_dir: Optional[Path]` + parameter, passed through to `ingest.strava_sync`. + +- **`bincio/extract/ingest.py`** — `strava_sync` gains `originals_dir: Optional[Path]`. + When set, saves `{"meta": …, "streams": …}` as JSON to + `originals_dir/{activity_id}.json` before processing each activity. This preserves the + raw Strava API response for future reprocessing without needing another API call. + +- **`bincio/serve/init_cmd.py`** — sets `store_originals=true` in the settings table on + first init (skipped if the key already exists, so re-running init doesn't override + an operator's choice). + +- **`site/src/layouts/Base.astro`** — upload modal file view gains a "Keep original file on + server" checkbox. Defaults to unchecked; pre-checked after login if the instance setting + is `true` (read from `store_originals_default` in the `/api/me` response). The checkbox + value is sent as the `store_original` form field. + +- **`bincio/serve/server.py`** and **`bincio/edit/server.py`** — `Form` added to the + FastAPI imports (was missing, causing a startup `NameError`). + +--- + +### New feature — About page (multilingual) + +New static `/about/` page explaining the project, with a Ko-fi donation button, data +storage disclaimer, and early-software caveats. Available in four languages. + +- **`site/src/pages/about/index.astro`** — English +- **`site/src/pages/about/it/index.astro`** — Italian +- **`site/src/pages/about/es/index.astro`** — Spanish +- **`site/src/pages/about/ca/index.astro`** — Catalan + +All four pages share the same structure: +- Language switcher (EN / IT / ES / CA) in the top-right corner. +- Ko-fi donation button (`https://ko-fi.com/brutsalvadi`) at the top. +- **Community stats section** — fetches `GET /api/stats` on load; shown only in + multi-user mode (silently hidden in single-user mode where the endpoint doesn't exist). + Displays total member count and an indented invitation tree: each row shows display name, + `@handle`, membership duration (days / months), and either "founder" or "invited by @X". + UI labels are fully translated per language. +- Sections: What is this · Your data on this server · Early-stage software · Disclaimer · + Open source. +- All pages use `public={true}` so they bypass the instance auth wall. + +"About" link added to the main nav bar (visible when not on a public page). +The upload modal's "Keep original file" checkbox links to `/about/` for context. + +--- + +### New feature — Community stats API + +- **`bincio/serve/db.py`** — `get_member_tree(db)` joins `users` with `invites` (on + `used_by`) to reconstruct the invitation graph. Returns a list ordered oldest-first with + `handle`, `display_name`, `created_at`, and `invited_by` (inviter handle or `None` for + the founder/admin). + +- **`bincio/serve/server.py`** — new public `GET /api/stats` endpoint (no auth required). + Returns `user_count` and a `members` array where each entry includes `handle`, + `display_name`, `member_since` (Unix timestamp), `member_for_days`, and `invited_by`. + +--- + +### Fix — `bincio dev` now watches data directory for live re-merge + +Previously, editing a sidecar or running `bincio extract` while `bincio dev` was running +required a manual restart to pick up changes. Now a background watcher thread re-merges +automatically. + +- **`bincio/dev.py`** — new `_watch_data(data)` function, started as a daemon thread + alongside `bincio serve`. Uses `watchfiles` (already bundled with `uvicorn[standard]`, + no new dependency) for OS-level file event watching — no polling. + - Watches every `{user_dir}/edits/` and `{user_dir}/activities/` directory. + - On any change, identifies which users were affected and calls `merge_all(user_dir)` + for each. + - Skips churn files written by merge itself (`.timeseries.json`, `.geojson`, + `index.json`) to avoid re-triggering. + - Prints `↺ {handle}: merged` on each successful re-merge; warns on failure. + - Astro dev picks up the result automatically since `public/data` is a symlink into + the live data directory. + +--- + +### Tests + +- **`tests/test_server_imports.py`** (new) — smoke tests that import `bincio.serve.server` + and `bincio.edit.server` at module level, catching `NameError`, missing imports, and + syntax errors before they reach the runtime. Also asserts that key routes (`/api/me`, + `/api/upload`, `/api/strava/sync`, `/api/register`, `/api/activity/{activity_id}`) are + registered on each app. + +--- + ## [Unreleased] — 2026-04-06 ### New feature — Strava sync from UI diff --git a/CLAUDE.md b/CLAUDE.md index feb1c2b..f8249e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,9 +233,31 @@ Key facts: - Write API in `bincio serve` delegates to `bincio.edit.server._apply_sidecar_edit`; the Strava sync delegates to `bincio.edit.server.strava_sync` with a temporary data_dir swap +## Instance settings (stored in `instance.db` `settings` table) + +| Key | Default | Set by | Description | +|-----|---------|--------|-------------| +| `max_users` | — (unlimited) | `bincio init --max-users N` or `bincio serve --max-users N` | Cap on registered users; 0 or absent = unlimited | +| `store_originals` | `true` | `bincio init` (first run only) | Whether uploaded source files and raw Strava API data are kept in `{user_dir}/originals/` | + +`get_setting` / `set_setting` in `db.py` are the read/write accessors. Any new instance-wide flag should use this table rather than a new column. + +## Original file storage + +When a user uploads a FIT/GPX/TCX file the server may keep the source in `{user_dir}/originals/{filename}` rather than always deleting it after extraction. The per-upload `store_original` form field controls the behaviour for a single upload (sent by the UI checkbox). The instance-level `store_originals` setting provides the default that pre-populates the checkbox (read from `GET /api/me` → `store_originals_default`). + +For Strava sync, `store_originals=true` causes `POST /api/strava/sync` to save `{"meta":…,"streams":…}` JSON per activity to `{user_dir}/originals/strava/{activity_id}.json`. + +## About pages + +Static public pages at `/about/` (EN), `/about/it/` (IT), `/about/es/` (ES), `/about/ca/` (CA). All use `public={true}` to bypass the auth wall. Each page: +- Shows a Ko-fi donation button at the top. +- Fetches `GET /api/stats` on load and renders a **community/invitation tree** (member count, each user's display name, membership duration, and who invited them). Hidden silently in single-user mode. +- Contains project description, data storage explanation, early-software caveat, and liability disclaimer. + ## Known issues / next steps -- `bincio render --watch` mode not yet implemented +- `bincio render --watch` mode not yet implemented as a standalone command, but `bincio dev` now watches the data directory via `watchfiles` (bundled with uvicorn) and re-runs `merge_all` automatically when sidecars or activity files change - Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format - Some activities appear with both untitled and titled IDs (near-dedup timing race) - Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI @@ -250,6 +272,10 @@ Key facts: - [ ] Karoo/Garmin Connect importers beyond Strava - [ ] `bincio render --watch` incremental rebuild on sidecar/data changes - [ ] Highlight badge in activity feed cards +- [x] Per-instance user limit (`max_users` setting, enforced at registration) +- [x] Original file storage option (per-upload checkbox + `store_originals` instance setting) +- [x] About page — multilingual (EN/IT/ES/CA), Ko-fi button, community invitation tree +- [x] `GET /api/stats` — public endpoint with member count and invitation tree - [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort - [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge) - [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site diff --git a/bincio/dev.py b/bincio/dev.py index 24b5292..b40d441 100644 --- a/bincio/dev.py +++ b/bincio/dev.py @@ -86,6 +86,62 @@ def _start_serve(data: Path, api_port: int, site: Path) -> None: server.run() +def _watch_data(data: Path) -> None: + """Watch the data directory for sidecar/activity changes and re-merge. + + Monitors every user's edits/ and activities/ subdirectories. When any file + changes (new activity extracted, sidecar saved), re-runs merge_all for that + user so the _merged/ symlink tree stays current. Astro dev picks up the + result automatically because public/data is a symlink into the live data dir. + + Uses watchfiles (bundled with uvicorn[standard]) for efficient OS-level + file watching — no polling. + """ + from watchfiles import watch, Change + + watch_paths = [] + for user_dir in _user_dirs(data): + for sub in ("edits", "activities"): + p = user_dir / sub + p.mkdir(exist_ok=True) + watch_paths.append(p) + + if not watch_paths: + return + + console.print(f" [dim]Watching {len(watch_paths)} director{'y' if len(watch_paths) == 1 else 'ies'} for changes…[/dim]") + + # Build a map from path prefix → user dir for targeted merge + prefix_to_user: dict[str, Path] = {} + for user_dir in _user_dirs(data): + for sub in ("edits", "activities"): + prefix_to_user[str(user_dir / sub)] = user_dir + + for changes in watch(*watch_paths, yield_on_timeout=False): + # Find which users were affected + affected: set[Path] = set() + for change_type, path in changes: + # Skip timeseries / geojson / index churn written by merge itself + if any(path.endswith(s) for s in (".timeseries.json", ".geojson", "index.json")): + continue + for prefix, user_dir in prefix_to_user.items(): + if path.startswith(prefix): + affected.add(user_dir) + break + + if not affected: + continue + + for user_dir in affected: + handle = user_dir.name + try: + from bincio.render.merge import merge_all + merge_all(user_dir) + console.print(f" [dim]↺ {handle}: merged[/dim]") + except Exception as exc: + console.print(f" [yellow]⚠ {handle}: merge failed — {exc}[/yellow]") + + @click.command("dev") @click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)") @click.option("--site-dir", default=None, help="Astro project directory (default: ./site)") @@ -144,6 +200,10 @@ def dev( t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True) t.start() + # Watch data dir for sidecar/activity changes → auto-merge + watcher = threading.Thread(target=_watch_data, args=(data,), daemon=True) + watcher.start() + # Build env for astro dev env = { **os.environ, diff --git a/bincio/serve/db.py b/bincio/serve/db.py index d939ad4..4485a01 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -154,6 +154,33 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None: db.commit() +def get_member_tree(db: sqlite3.Connection) -> list[dict]: + """Return users with their inviter handle and join timestamp. + + Each entry: {handle, display_name, created_at, invited_by (handle or None)}. + Ordered oldest-first so callers can build the tree top-down. + """ + users = {r["handle"]: r for r in db.execute( + "SELECT handle, display_name, created_at FROM users ORDER BY created_at" + ).fetchall()} + # Map invitee → inviter from the used invites + invited_by: dict[str, str] = {} + for row in db.execute( + "SELECT created_by, used_by FROM invites WHERE used_by IS NOT NULL" + ).fetchall(): + invited_by[row["used_by"]] = row["created_by"] + + return [ + { + "handle": r["handle"], + "display_name": r["display_name"], + "created_at": r["created_at"], + "invited_by": invited_by.get(r["handle"]), + } + for r in users.values() + ] + + def count_users(db: sqlite3.Connection) -> int: """Return the total number of registered users.""" row = db.execute("SELECT COUNT(*) FROM users").fetchone() diff --git a/bincio/serve/server.py b/bincio/serve/server.py index d6f3f95..0b07c71 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -29,6 +29,7 @@ from bincio.serve.db import ( create_user, delete_session, get_invite, + get_member_tree, get_session, get_setting, get_user, @@ -173,6 +174,27 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon }) +@app.get("/api/stats") +async def stats() -> JSONResponse: + """Public endpoint: member count, join dates, and invitation tree.""" + import time as _time + now = int(_time.time()) + members = get_member_tree(_get_db()) + return JSONResponse({ + "user_count": len(members), + "members": [ + { + "handle": m["handle"], + "display_name": m["display_name"], + "member_since": m["created_at"], + "member_for_days": (now - m["created_at"]) // 86400, + "invited_by": m["invited_by"], + } + for m in members + ], + }) + + @app.post("/api/auth/login") async def login(request: Request) -> JSONResponse: ip = request.client.host if request.client else "unknown" diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro index 5452a76..3d29722 100644 --- a/site/src/pages/about/ca/index.astro +++ b/site/src/pages/about/ca/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunitat', + members: 'membre', + members_pl: 'membres', + day: 'dia', + days: 'dies', + invited_by: 'convidat per', + founder: 'fundador', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

Què és això?

@@ -108,7 +123,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/es/index.astro b/site/src/pages/about/es/index.astro index 8633e72..ea950b6 100644 --- a/site/src/pages/about/es/index.astro +++ b/site/src/pages/about/es/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunidad', + members: 'miembro', + members_pl: 'miembros', + day: 'día', + days: 'días', + invited_by: 'invitado por', + founder: 'fundador', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

¿Qué es esto?

@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro index 883e64b..c3aa740 100644 --- a/site/src/pages/about/index.astro +++ b/site/src/pages/about/index.astro @@ -1,6 +1,16 @@ --- import Base from '../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Community', + members: 'member', + members_pl: 'members', + day: 'day', + days: 'days', + invited_by: 'invited by', + founder: 'founder', + loading: 'Loading…', +}; ---
@@ -26,6 +36,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ + +

What is this?

@@ -103,7 +120,94 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + diff --git a/site/src/pages/about/it/index.astro b/site/src/pages/about/it/index.astro index 3488fef..783318b 100644 --- a/site/src/pages/about/it/index.astro +++ b/site/src/pages/about/it/index.astro @@ -1,6 +1,15 @@ --- import Base from '../../../layouts/Base.astro'; const baseUrl = import.meta.env.BASE_URL ?? '/'; +const labels = { + community: 'Comunità', + members: 'membro', + members_pl: 'membri', + day: 'giorno', + days: 'giorni', + invited_by: 'invitato da', + founder: 'fondatore', +}; ---
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+ +

Cos'è?

@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';

-
+ + From 8b7cdd9ed18153f0663466c8c03f25db666f88cb Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:42:00 +0200 Subject: [PATCH 007/124] explain invitation system --- site/src/pages/about/ca/index.astro | 17 +++++++++++++++++ site/src/pages/about/es/index.astro | 17 +++++++++++++++++ site/src/pages/about/index.astro | 17 +++++++++++++++++ site/src/pages/about/it/index.astro | 17 +++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro index 3d29722..57d8e83 100644 --- a/site/src/pages/about/ca/index.astro +++ b/site/src/pages/about/ca/index.astro @@ -56,6 +56,20 @@ const labels = {

+
+

Registre i invitacions

+

+ Aquesta instància és només per invitació. Per registrar-te necessites un enllaç + d'invitació d'un membre existent — cada enllaç és d'un sol ús i està vinculat a + un codi únic. +

+

+ Un cop tinguis un compte, pots generar fins a 3 enllaços d'invitació per + compartir amb persones de confiança. Gestiona les teves invitacions des de la pàgina d'invitacions + (cal iniciar sessió). +

+
+

Les teves dades en aquest servidor

@@ -136,6 +150,9 @@ const labels = { data = await r.json(); } catch { return; } + const invLink = document.getElementById('invites-link'); + if (invLink) invLink.href = '/invites/'; + if (!data.user_count) return; const section = document.getElementById('stats-section'); diff --git a/site/src/pages/about/es/index.astro b/site/src/pages/about/es/index.astro index ea950b6..0bf6edb 100644 --- a/site/src/pages/about/es/index.astro +++ b/site/src/pages/about/es/index.astro @@ -56,6 +56,20 @@ const labels = {

+
+

Registro e invitaciones

+

+ Esta instancia es solo por invitación. Para registrarte necesitas un enlace de + invitación de un miembro existente — cada enlace es de un solo uso y está vinculado + a un código único. +

+

+ Una vez que tengas una cuenta, puedes generar hasta 3 enlaces de invitación para + compartir con personas de confianza. Gestiona tus invitaciones desde la página de invitaciones + (requiere inicio de sesión). +

+
+

Tus datos en este servidor

@@ -135,6 +149,9 @@ const labels = { data = await r.json(); } catch { return; } + const invLink = document.getElementById('invites-link'); + if (invLink) invLink.href = '/invites/'; + if (!data.user_count) return; const section = document.getElementById('stats-section'); diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro index c3aa740..da7520b 100644 --- a/site/src/pages/about/index.astro +++ b/site/src/pages/about/index.astro @@ -57,6 +57,19 @@ const labels = {

+
+

Joining & invitations

+

+ This instance is invite-only. To join, you need an invite link from an existing + member — each link is single-use and tied to a unique code. +

+

+ Once you have an account, you can generate up to 3 invite links to + share with people you trust. You can manage your invites from the invites page + (requires login). +

+
+

Your data on this server

@@ -133,6 +146,10 @@ const labels = { data = await r.json(); } catch { return; } + // Fix invites link to use absolute base URL + const invLink = document.getElementById('invites-link'); + if (invLink) invLink.href = '/invites/'; + if (!data.user_count) return; const section = document.getElementById('stats-section'); diff --git a/site/src/pages/about/it/index.astro b/site/src/pages/about/it/index.astro index 783318b..e2a649d 100644 --- a/site/src/pages/about/it/index.astro +++ b/site/src/pages/about/it/index.astro @@ -56,6 +56,20 @@ const labels = {

+
+

Iscrizione e inviti

+

+ Questa istanza è accessibile solo su invito. Per registrarti hai bisogno di un link + di invito da parte di un membro già registrato — ogni link è monouso e associato a + un codice univoco. +

+

+ Una volta registrato, puoi generare fino a 3 link di invito da + condividere con persone di fiducia. Gestisci i tuoi inviti dalla pagina inviti + (richiede il login). +

+
+

I tuoi dati su questo server

@@ -135,6 +149,9 @@ const labels = { data = await r.json(); } catch { return; } + const invLink = document.getElementById('invites-link'); + if (invLink) invLink.href = '/invites/'; + if (!data.user_count) return; const section = document.getElementById('stats-section'); From 6d3673b2f7f09e0f39a0c1b870710c5960286246 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:54:50 +0200 Subject: [PATCH 008/124] =?UTF-8?q?=201.=20Image=20upload=20size=20limit?= =?UTF-8?q?=20=E2=80=94=20=5FMAX=5FIMAGE=5FBYTES=20=3D=2010=20MB=20in=20bo?= =?UTF-8?q?th=20serve/server.py=20and=20edit/server.py=20=20=202.=20Image?= =?UTF-8?q?=20MIME=20type=20whitelist=20=E2=80=94=20=5FALLOWED=5FIMAGE=5FT?= =?UTF-8?q?YPES=20blocks=20SVG=20XSS=20in=20both=20servers=20=20=203.=20Fi?= =?UTF-8?q?lename=20collision=20safety=20=E2=80=94=20=5Funique=5Fimage=5Fn?= =?UTF-8?q?ame()=20helper=20in=20both=20servers=20=20=204.=20OAuth=20CSRF?= =?UTF-8?q?=20=E2=80=94=20state=20token=20generated=20in=20edit/server.py?= =?UTF-8?q?=20auth-url,=20stored=20in=20=5Foauth=5Fstates,=20validated=20a?= =?UTF-8?q?nd=20discarded=20in=20callback;=20strava=5Fapi.auth=5Furl()=20a?= =?UTF-8?q?ccepts=20optional=20state=20param=20=20=205.=20Error=20message?= =?UTF-8?q?=20leak=20=E2=80=94=20upload=20processing=20errors=20now=20retu?= =?UTF-8?q?rn=20generic=20"Processing=20failed"=20instead=20of=20exception?= =?UTF-8?q?=20type/message=20=20=206.=20Handle=20injection=20in=20subproce?= =?UTF-8?q?ss=20=E2=80=94=20=5Ftrigger=5Frebuild=20now=20asserts=20handle?= =?UTF-8?q?=20matches=20=5FVALID=5FHANDLE=20before=20passing=20to=20subpro?= =?UTF-8?q?cess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bincio/edit/server.py | 43 ++++++++++++---- bincio/extract/strava_api.py | 10 ++-- bincio/serve/server.py | 98 ++++++++++++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 18 deletions(-) diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 7cb99ed..940eed5 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import secrets import shutil from pathlib import Path from typing import Any @@ -20,6 +21,9 @@ site_url: str = "http://localhost:4321" strava_client_id: str = "" strava_client_secret: str = "" +# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use +_oauth_states: set[str] = set() + app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None) app.add_middleware(GZipMiddleware, minimum_size=1024) @@ -38,6 +42,21 @@ def _check_id(activity_id: str) -> str: return activity_id +_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} +_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB + + +def _unique_image_name(directory: Path, filename: str) -> str: + """Return a filename that does not collide with existing files in directory.""" + stem, suffix = Path(filename).stem, Path(filename).suffix + candidate = filename + counter = 1 + while (directory / candidate).exists(): + candidate = f"{stem}_{counter}{suffix}" + counter += 1 + return candidate + + # ── HTML UI ─────────────────────────────────────────────────────────────────── _HTML = """\ @@ -419,14 +438,15 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe images_dir = dd / "edits" / "images" / activity_id images_dir.mkdir(parents=True, exist_ok=True) - safe_name = Path(file.filename).name - # Only allow image content types ct = file.content_type or "" - if not ct.startswith("image/"): - raise HTTPException(400, f"Only image files are accepted (got {ct})") - dest = images_dir / safe_name - dest.write_bytes(await file.read()) - return JSONResponse({"ok": True, "filename": dest.name}) + if ct not in _ALLOWED_IMAGE_TYPES: + raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") + contents = await file.read() + if len(contents) > _MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024 * 1024)} MB)") + safe_name = _unique_image_name(images_dir, Path(file.filename).name) + (images_dir / safe_name).write_bytes(contents) + return JSONResponse({"ok": True, "filename": safe_name}) @app.get("/api/athlete") @@ -678,16 +698,21 @@ async def strava_auth_url(request: Request) -> JSONResponse: """Return the Strava OAuth URL the browser should open.""" if not strava_client_id: raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.") + state = secrets.token_urlsafe(16) + _oauth_states.add(state) redirect_uri = str(request.url_for("strava_callback")) from bincio.extract.strava_api import auth_url - return JSONResponse({"url": auth_url(strava_client_id, redirect_uri)}) + return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)}) @app.get("/api/strava/callback", name="strava_callback") -async def strava_callback(code: str = "", error: str = "") -> RedirectResponse: +async def strava_callback(code: str = "", error: str = "", state: str = "") -> RedirectResponse: """Strava OAuth callback — exchange code for token then redirect to the site.""" if error or not code: return RedirectResponse(f"{site_url}?strava=error") + if state not in _oauth_states: + return RedirectResponse(f"{site_url}?strava=error") + _oauth_states.discard(state) if not strava_client_id or not strava_client_secret: return RedirectResponse(f"{site_url}?strava=error") dd = _get_data_dir() diff --git a/bincio/extract/strava_api.py b/bincio/extract/strava_api.py index 1367c2d..348fbd7 100644 --- a/bincio/extract/strava_api.py +++ b/bincio/extract/strava_api.py @@ -37,16 +37,18 @@ class StravaError(Exception): # ── OAuth helpers ────────────────────────────────────────────────────────────── -def auth_url(client_id: str, redirect_uri: str) -> str: +def auth_url(client_id: str, redirect_uri: str, state: str = "") -> str: """Return the Strava OAuth authorization URL.""" - params = urllib.parse.urlencode({ + params: dict[str, str] = { "client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": "activity:read_all", "approval_prompt": "auto", - }) - return f"{_AUTH_URL}?{params}" + } + if state: + params["state"] = state + return f"{_AUTH_URL}?{urllib.parse.urlencode(params)}" def exchange_code(client_id: str, client_secret: str, code: str) -> dict: diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 0b07c71..4857ad3 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import re +import secrets import subprocess import time from pathlib import Path @@ -142,12 +143,31 @@ def _set_session_cookie(response: Response, token: str) -> None: ) +# ── Image upload constants ──────────────────────────────────────────────────── + +_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} +_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB + + +def _unique_image_name(directory: Path, filename: str) -> str: + """Return a filename that does not collide with existing files in directory.""" + stem, suffix = Path(filename).stem, Path(filename).suffix + candidate = filename + counter = 1 + while (directory / candidate).exists(): + candidate = f"{stem}_{counter}{suffix}" + counter += 1 + return candidate + + # ── Post-write rebuild ──────────────────────────────────────────────────────── def _trigger_rebuild(handle: str) -> None: """Asynchronously re-merge one user's shard and rewrite the root manifest.""" if site_dir is None: return + if not _VALID_HANDLE.match(handle): + return # safety: never pass untrusted strings to subprocess subprocess.Popen( ["uv", "run", "bincio", "render", "--data-dir", str(data_dir), @@ -386,12 +406,15 @@ async def upload_image( if not file.filename: raise HTTPException(400, "No filename") ct = file.content_type or "" - if not ct.startswith("image/"): - raise HTTPException(400, f"Only image files accepted (got {ct})") + if ct not in _ALLOWED_IMAGE_TYPES: + raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") + contents = await file.read() + if len(contents) > _MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)") images_dir = dd / "edits" / "images" / activity_id images_dir.mkdir(parents=True, exist_ok=True) - safe_name = Path(file.filename).name - (images_dir / safe_name).write_bytes(await file.read()) + safe_name = _unique_image_name(images_dir, Path(file.filename).name) + (images_dir / safe_name).write_bytes(contents) _trigger_rebuild(user.handle) return JSONResponse({"ok": True, "filename": safe_name}) @@ -537,7 +560,7 @@ async def upload_activity( results.append({"name": name, "ok": True, "id": activity_id}) any_added = True except Exception as exc: - results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"}) + results.append({"name": name, "ok": False, "error": "Processing failed"}) finally: if not kept: staged.unlink(missing_ok=True) @@ -550,6 +573,71 @@ async def upload_activity( return JSONResponse({"ok": True, "added": len(added), "results": results}) +# ── Feedback ────────────────────────────────────────────────────────────────── + +_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} +_FEEDBACK_MAX_IMAGES = 3 +_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB + + +@app.post("/api/feedback") +async def submit_feedback( + text: str = Form(""), + images: list[UploadFile] = File(default=[]), + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + + text = text.strip() + if not text and not any(f.filename for f in images): + raise HTTPException(400, "Feedback must include text or at least one image") + if len(images) > _FEEDBACK_MAX_IMAGES: + raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission") + + feedback_dir = _get_data_dir() / "_feedback" + feedback_dir.mkdir(exist_ok=True) + images_dir = feedback_dir / user.handle + images_dir.mkdir(exist_ok=True) + + now = int(time.time()) + submission_id = f"{now}_{secrets.token_hex(4)}" + saved_images: list[str] = [] + + for img in images: + if not img.filename: + continue + suffix = Path(img.filename).suffix.lower() + if suffix not in _FEEDBACK_IMAGE_SUFFIXES: + raise HTTPException(400, f"Unsupported image type '{suffix}'") + contents = await img.read() + if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit") + safe_name = f"{submission_id}_{Path(img.filename).name}" + (images_dir / safe_name).write_bytes(contents) + saved_images.append(safe_name) + + from datetime import datetime, timezone + entry = { + "id": submission_id, + "handle": user.handle, + "submitted_at": datetime.now(timezone.utc).isoformat(), + "text": text, + "images": saved_images, + } + + log_file = feedback_dir / f"{user.handle}.json" + existing: list[dict] = [] + if log_file.exists(): + try: + existing = json.loads(log_file.read_text()) + except Exception: + existing = [] + existing.append(entry) + log_file.write_text(json.dumps(existing, indent=2)) + + return JSONResponse({"ok": True, "id": submission_id}) + + @app.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) From 4593478863f8cf4d5eb8541df26196d5814e2d75 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 13:58:12 +0200 Subject: [PATCH 009/124] feedback page --- docs/deployment/vps.md | 25 +++++ site/src/layouts/Base.astro | 5 +- site/src/pages/feedback/index.astro | 162 ++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/feedback/index.astro diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 71719e2..570e214 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -277,6 +277,31 @@ incremental sync from the same modal. --- +## Reading user feedback + +Users can submit feedback from the **Feedback** link in the nav (visible when logged in). +Submissions are stored as JSON on the server: + +``` +/var/bincio/data/_feedback/ + {handle}.json ← one file per user, array of submissions + {handle}/ ← attached images +``` + +To read all feedback: + +```bash +cat /var/bincio/data/_feedback/*.json | python3 -m json.tool +``` + +Per-user only: + +```bash +cat /var/bincio/data/_feedback/pres.json | python3 -m json.tool +``` + +--- + ## Day-to-day operations | Task | Command | diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 7cc36f7..83c6dd3 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -176,6 +176,7 @@ try { Convert )} About + )} @@ -375,9 +376,11 @@ try { el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path'); }); - // Show logout button + // Show logout button and feedback link const logoutEl = document.getElementById('nav-logout'); if (logoutEl) logoutEl.style.display = ''; + const feedbackEl = document.getElementById('nav-feedback'); + if (feedbackEl) feedbackEl.style.display = ''; // Pre-populate the "keep original" checkbox from the instance default const chk = document.getElementById('upload-keep-original'); diff --git a/site/src/pages/feedback/index.astro b/site/src/pages/feedback/index.astro new file mode 100644 index 0000000..df401b0 --- /dev/null +++ b/site/src/pages/feedback/index.astro @@ -0,0 +1,162 @@ +--- +import Base from '../../layouts/Base.astro'; +--- + +

+

Send feedback

+

Report a bug, suggest a feature, or share anything useful. Plain text only — no account details needed.

+ +
+ +
+ +
+ + +
+

Attach up to 3 screenshots (max 2 MB each)

+
+ Drop images or click to browse + +
+
+
+ + + + + +
+ + +
+ + + From ceb8e28b7419e88f5b05867a9eb9488278717315 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:06:31 +0200 Subject: [PATCH 010/124] update deployment instructions --- docs/deployment/vps.md | 162 ++++++++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 570e214..326f39a 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -16,7 +16,7 @@ Code is deployed directly from your laptop via `git push` — no GitHub required ```bash apt update && apt upgrade -y -apt install -y git curl nginx certbot python3-certbot-nginx sqlite3 +apt install -y git curl nginx certbot python3-certbot-nginx sqlite3 rsync ``` **Node.js 20 LTS** (the Debian package is too old): @@ -52,38 +52,88 @@ REPO=/opt/bincio-repo.git DEPLOY=/opt/bincio DATA=/var/bincio/data -echo "--- Checking out code ---" -git --work-tree=$DEPLOY --git-dir=$REPO checkout -f +while read oldrev newrev refname; do + echo "--- Checking out $refname ---" + git --work-tree=$DEPLOY --git-dir=$REPO checkout -f $newrev -echo "--- Syncing Python deps ---" -cd $DEPLOY -~/.local/bin/uv sync + echo "--- Syncing Python deps ---" + cd $DEPLOY + ~/.local/bin/uv sync --extra serve --extra strava -echo "--- Syncing JS deps ---" -cd $DEPLOY/site -npm install --silent + echo "--- Syncing JS deps ---" + cd $DEPLOY/site + npm install --silent -echo "--- Building site ---" -cd $DEPLOY -~/.local/bin/uv run bincio render --data-dir $DATA --site-dir $DEPLOY/site + echo "--- Building site ---" + cd $DEPLOY + ~/.local/bin/uv run bincio render --data-dir $DATA --site-dir $DEPLOY/site -echo "--- Copying dist to webroot ---" -rsync -a --delete $DEPLOY/site/dist/ /var/www/bincio/ + echo "--- Copying dist to webroot ---" + rsync -a --delete $DEPLOY/site/dist/ /var/www/bincio/ -echo "--- Restarting API ---" -systemctl restart bincio + echo "--- Restarting API ---" + systemctl restart bincio -echo "--- Done ---" + echo "--- Done ---" +done ``` ```bash chmod +x /opt/bincio-repo.git/hooks/post-receive -mkdir -p /var/www/bincio +mkdir -p /var/www/bincio /var/bincio/data /var/bincio/sources ``` --- -## 3. First deploy from your laptop +## 3. systemd service + +The hook restarts the `bincio` service on every deploy, so it must exist before the first push. + +Create `/etc/bincio/secrets.env`: + +```bash +mkdir -p /etc/bincio +chmod 700 /etc/bincio +cat > /etc/bincio/secrets.env < "cd /opt/bincio && \ --- -## 6. systemd service - -Create `/etc/systemd/system/bincio.service`: - -```ini -[Unit] -Description=BincioActivity API -After=network.target - -[Service] -WorkingDirectory=/opt/bincio -ExecStart=/root/.local/bin/uv run bincio serve \ - --data-dir /var/bincio/data \ - --site-dir /opt/bincio/site \ - --host 127.0.0.1 \ - --port 4041 -EnvironmentFile=/etc/bincio/secrets.env -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -Create `/etc/bincio/secrets.env`: - -```bash -mkdir -p /etc/bincio -chmod 700 /etc/bincio -cat > /etc/bincio/secrets.env </` — you should see the bincio activity feed, not the nginx welcome page. + --- ## 8. SSL +SSL requires the domain to be pointing at the VPS first. In your DNS provider, add: + +``` +Type: A +Name: @ +Value: +TTL: 300 +``` + +Verify propagation before running certbot: + +```bash +dig yourdomain.com A +short # must return your VPS IP +``` + +Then: + ```bash certbot --nginx -d yourdomain.com # certbot edits the nginx config and sets up automatic renewal @@ -320,4 +346,4 @@ cat /var/bincio/data/_feedback/pres.json | python3 -m json.tool - [Multi-user architecture](multi-user.md) - [CLI reference](../reference/cli.md) -- [API reference](../reference/api.md) +- [API reference](../reference/api.md) \ No newline at end of file From e006175285bc3cfefdb4d2aef64a2a8ed98cb0c9 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:06:45 +0200 Subject: [PATCH 011/124] fix: don't redirect to /u/{handle}/ on private (multi-user) instances --- site/src/lib/manifest.ts | 11 +++++++++++ site/src/pages/index.astro | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/site/src/lib/manifest.ts b/site/src/lib/manifest.ts index 3071bc3..2dc52f2 100644 --- a/site/src/lib/manifest.ts +++ b/site/src/lib/manifest.ts @@ -22,6 +22,17 @@ export interface ShardHandle { url: string; } +export function isInstancePrivate(): boolean { + try { + const dataDir = findDataDir(); + if (!dataDir) return false; + const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8')); + return root?.instance?.private === true; + } catch { + return false; + } +} + export function readShardHandles(): ShardHandle[] { try { const dataDir = findDataDir(); diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index eac5563..2adc560 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,11 +1,11 @@ --- import Base from '../layouts/Base.astro'; import ActivityFeed from '../components/ActivityFeed.svelte'; -import { readShardHandles } from '../lib/manifest'; +import { readShardHandles, isInstancePrivate } from '../lib/manifest'; const base = import.meta.env.BASE_URL; const shards = readShardHandles(); -const isSingleUser = shards.length === 1; +const isSingleUser = shards.length === 1 && !isInstancePrivate(); const singleHandle = isSingleUser ? shards[0].handle : null; --- {isSingleUser ? ( From 5371c77c8fab3c64502500cce16afd64507c0094 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:14:44 +0200 Subject: [PATCH 012/124] update vps instructions --- docs/deployment/vps.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 326f39a..31d308e 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -72,7 +72,7 @@ while read oldrev newrev refname; do rsync -a --delete $DEPLOY/site/dist/ /var/www/bincio/ echo "--- Restarting API ---" - systemctl restart bincio + systemctl restart bincio || echo "WARNING: bincio service restart failed — check journalctl -u bincio" echo "--- Done ---" done @@ -169,6 +169,12 @@ uv run bincio init \ # prompted for password; prints a first invite code ``` +Enable the edit/upload UI (this env var is read at build time and is gitignored, so it must be set on the server): + +```bash +echo "PUBLIC_EDIT_ENABLED=true" > /opt/bincio/site/.env +``` + Set the user cap: ```bash From 27f94cf5812956d32046beac2b7565e59fa41718 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:19:41 +0200 Subject: [PATCH 014/124] fix: respect instancePrivate when determining single-user mode in Base.astro --- site/src/layouts/Base.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 83c6dd3..5f33ad2 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -31,7 +31,7 @@ try { instancePrivate = root?.instance?.private === true; const shards: Array<{ handle?: string }> = root?.shards ?? []; const handles = shards.map(s => s.handle).filter(Boolean); - if (handles.length === 1) singleHandle = handles[0] as string; + if (handles.length === 1 && !instancePrivate) singleHandle = handles[0] as string; } } catch { /* non-fatal */ } --- From 7088b94a87a07a317d872aaf7d7703206a5c4e57 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:26:11 +0200 Subject: [PATCH 015/124] fix: bincio init always sets private:true even if index.json already exists --- bincio/serve/init_cmd.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/bincio/serve/init_cmd.py b/bincio/serve/init_cmd.py index 5dfbb84..7c6fc8e 100644 --- a/bincio/serve/init_cmd.py +++ b/bincio/serve/init_cmd.py @@ -55,7 +55,19 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str from datetime import datetime, timezone root_index = dd / "index.json" - if not root_index.exists(): + if root_index.exists(): + # Preserve existing manifest but always enforce private: True for a multi-user instance. + manifest = json.loads(root_index.read_text()) + instance = manifest.setdefault("instance", {}) + if not instance.get("private"): + instance["private"] = True + if name: + instance["name"] = name + root_index.write_text(json.dumps(manifest, indent=2)) + console.print(" [green]✓[/green] root index.json updated (private: true)") + else: + console.print(" [yellow]·[/yellow] root index.json already private — skipping") + else: manifest = { "bas_version": "1.0", "instance": {"name": name or "BincioActivity", "private": True}, @@ -65,8 +77,6 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str } root_index.write_text(json.dumps(manifest, indent=2)) console.print(" [green]✓[/green] root index.json manifest written") - else: - console.print(" [yellow]·[/yellow] root index.json already exists — skipping") # ── User limit ──────────────────────────────────────────────────────────── if max_users > 0: From 8d8b009a78b0d1e105a753a87ae6950048c07c35 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:33:06 +0200 Subject: [PATCH 016/124] fix: use shutil.which to find uv in _trigger_rebuild; never 500 on rebuild failure --- bincio/serve/server.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 4857ad3..3b544c7 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import re import secrets +import shutil import subprocess import time from pathlib import Path @@ -168,14 +169,18 @@ def _trigger_rebuild(handle: str) -> None: return if not _VALID_HANDLE.match(handle): return # safety: never pass untrusted strings to subprocess - subprocess.Popen( - ["uv", "run", "bincio", "render", - "--data-dir", str(data_dir), - "--site-dir", str(site_dir), - "--handle", handle], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") + try: + subprocess.Popen( + [uv, "run", "bincio", "render", + "--data-dir", str(data_dir), + "--site-dir", str(site_dir), + "--handle", handle], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass # rebuild failure must never 500 the calling endpoint # ── Auth endpoints ──────────────────────────────────────────────────────────── From f790deb54fd434ff4d09e514d574078197d5940d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:35:41 +0200 Subject: [PATCH 017/124] fix: athlete page shows empty state instead of error when athlete.json missing --- site/src/components/AthleteView.svelte | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index b0be67a..95b95e1 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -42,8 +42,7 @@ loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined), loadIndex(import.meta.env.BASE_URL, indexUrl || undefined), ]); - if (!athleteData) throw new Error('athlete.json not found — run bincio extract first'); - athlete = athleteData; + athlete = athleteData as AthleteJson | null; activities = index.activities.filter(a => a.mmp && a.privacy !== 'private'); } catch (e: any) { error = e.message; @@ -78,7 +77,17 @@

Loading…

{:else if error}

{error}

-{:else if athlete} +{:else if !athlete} +
+

No athlete profile yet.

+ {#if editEnabled} + + {/if} +
+{:else}
From 6a8ef984cb492b8e09076a25b3cdde5bca67c41e Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:37:05 +0200 Subject: [PATCH 018/124] fix: GET/POST /api/athlete work without a pre-existing athlete.json --- bincio/serve/server.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 3b544c7..30bf920 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -449,9 +449,9 @@ async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> J user = _require_user(bincio_session) dd = _get_data_dir() / user.handle athlete_path = dd / "athlete.json" - if not athlete_path.exists(): - raise HTTPException(404, "athlete.json not found — run bincio extract first") - data = json.loads(athlete_path.read_text(encoding="utf-8")) + data: dict = {} + if athlete_path.exists(): + data = json.loads(athlete_path.read_text(encoding="utf-8")) # Layer edits/athlete.yaml on top edits_path = dd / "edits" / "athlete.yaml" if edits_path.exists(): @@ -480,8 +480,14 @@ async def save_athlete( ) -> JSONResponse: user = _require_user(bincio_session) dd = _get_data_dir() / user.handle - if not (dd / "athlete.json").exists(): - raise HTTPException(404, "athlete.json not found — run bincio extract first") + athlete_path = dd / "athlete.json" + if not athlete_path.exists(): + from datetime import datetime, timezone + athlete_path.write_text(json.dumps({ + "bas_version": "1.0", + "generated_at": datetime.now(timezone.utc).isoformat(), + "power_curve": {}, + }), encoding="utf-8") payload = await request.json() edits_dir = dd / "edits" edits_dir.mkdir(exist_ok=True) From f8e02f3da2fedbbc8253d2f944e3080563134add Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:43:01 +0200 Subject: [PATCH 019/124] fix: athlete page falls back to /api/athlete when static file not yet rebuilt --- site/src/components/AthleteView.svelte | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 95b95e1..3de95cd 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -42,7 +42,15 @@ loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined), loadIndex(import.meta.env.BASE_URL, indexUrl || undefined), ]); - athlete = athleteData as AthleteJson | null; + // Static file may not exist yet if the background rebuild hasn't finished — fall back to API + let resolvedAthlete = athleteData as AthleteJson | null; + if (!resolvedAthlete && editEnabled) { + try { + const r = await fetch('/api/athlete', { credentials: 'include' }); + if (r.ok) resolvedAthlete = await r.json() as AthleteJson; + } catch { /* ignore */ } + } + athlete = resolvedAthlete; activities = index.activities.filter(a => a.mmp && a.privacy !== 'private'); } catch (e: any) { error = e.message; @@ -52,8 +60,11 @@ }); async function onSaved() { - const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`); - if (res.ok) athlete = await res.json(); + // Try static file first; fall back to API (works before the background rebuild finishes) + const staticUrl = athleteUrl || `${import.meta.env.BASE_URL}data/athlete.json`; + let res = await fetch(`${staticUrl}?t=${Date.now()}`); + if (!res.ok) res = await fetch('/api/athlete', { credentials: 'include' }); + if (res.ok) athlete = await res.json() as AthleteJson; drawerOpen = false; } From ae883a7dbaaef1968f33253b12189a42e4599bba Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:47:50 +0200 Subject: [PATCH 020/124] fix: rebuild athlete.json on every ingest; remove bincio-extract references from UI --- bincio/extract/ingest.py | 14 ++++++++++++++ site/src/components/AthleteView.svelte | 2 +- site/src/pages/athlete/index.astro | 2 +- site/src/pages/stats/index.astro | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py index 7515251..dedb4fc 100644 --- a/bincio/extract/ingest.py +++ b/bincio/extract/ingest.py @@ -59,6 +59,20 @@ def ingest_parsed( summaries[activity_id] = summary write_index(list(summaries.values()), data_dir, owner) + # Rebuild athlete.json with updated MMP curves and records. + # Preserve any manually-set fields (max_hr, ftp_w, zones, etc.) from the existing file. + from bincio.extract.writer import write_athlete_json + _COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"} + athlete_config: dict[str, Any] = {} + athlete_path = data_dir / "athlete.json" + if athlete_path.exists(): + try: + existing = json.loads(athlete_path.read_text(encoding="utf-8")) + athlete_config = {k: v for k, v in existing.items() if k not in _COMPUTED} + except Exception: + pass + write_athlete_json(list(summaries.values()), data_dir, athlete_config) + return activity_id diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 3de95cd..ed990f1 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -130,7 +130,7 @@
{:else} -

No power data found. Make sure your activities include power meter data and re-run bincio extract.

+

No power data found. Make sure your activities include power meter data.

{/if} diff --git a/site/src/pages/athlete/index.astro b/site/src/pages/athlete/index.astro index 58865d5..3f56408 100644 --- a/site/src/pages/athlete/index.astro +++ b/site/src/pages/athlete/index.astro @@ -13,5 +13,5 @@ const handle = shards[0]?.handle ?? null; window.location.replace(base + 'u/' + handle + '/athlete/'); ) : ( -

No data found. Run bincio extract first.

+

No data found. Upload activities to get started.

)} diff --git a/site/src/pages/stats/index.astro b/site/src/pages/stats/index.astro index b2ceda2..65bcf96 100644 --- a/site/src/pages/stats/index.astro +++ b/site/src/pages/stats/index.astro @@ -14,5 +14,5 @@ const handle = shards[0]?.handle ?? null; window.location.replace(base + 'u/' + handle + '/stats/'); ) : ( -

No data found. Run bincio extract first.

+

No data found. Upload activities to get started.

)} From 96a3deee5decfc8f37f50f7bd3a42de611fe3c5d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 15:57:23 +0200 Subject: [PATCH 021/124] fix: serve data/ from disk via nginx alias; return full athlete data from API --- bincio/serve/server.py | 9 +-------- docs/deployment/vps.md | 9 +++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 30bf920..ba620fb 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -463,14 +463,7 @@ async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> J data[k] = edits[k] except Exception: pass - return JSONResponse({ - "max_hr": data.get("max_hr"), - "ftp_w": data.get("ftp_w"), - "hr_zones": data.get("hr_zones"), - "power_zones": data.get("power_zones"), - "seasons": data.get("seasons", []), - "gear": data.get("gear", {}), - }) + return JSONResponse(data) @app.post("/api/athlete") diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 31d308e..921be5e 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -236,6 +236,8 @@ server { root /var/www/bincio; index index.html; + client_max_body_size 512M; # bulk activity uploads + # API → bincio serve location /api/ { proxy_pass http://127.0.0.1:4041; @@ -244,6 +246,13 @@ server { proxy_read_timeout 120s; # Strava sync can be slow } + # Data files served live from disk — bypasses the build/rsync cycle + # so uploads and merges are visible immediately without a site rebuild. + location /data/ { + alias /var/bincio/data/; + add_header Cache-Control "no-cache, must-revalidate"; + } + # Static files location / { try_files $uri $uri/ $uri.html =404; From a20df6bd574f54582c2b18b61a1c887d1d5ba0af Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 17:40:16 +0200 Subject: [PATCH 022/124] fix: hide community stats/tree on about page for non-logged-in users --- site/src/pages/about/ca/index.astro | 4 ++++ site/src/pages/about/es/index.astro | 4 ++++ site/src/pages/about/index.astro | 4 ++++ site/src/pages/about/it/index.astro | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro index 57d8e83..a4f928e 100644 --- a/site/src/pages/about/ca/index.astro +++ b/site/src/pages/about/ca/index.astro @@ -143,6 +143,10 @@ const labels = { + +{#if loading} +

Loading activity…

+{:else if notFound} +
+

Activity not found.

+

It may still be processing — try refreshing in a moment.

+ ← Back to feed +
+{:else if activity} + +{/if} diff --git a/site/src/pages/activity/index.astro b/site/src/pages/activity/index.astro new file mode 100644 index 0000000..453a95d --- /dev/null +++ b/site/src/pages/activity/index.astro @@ -0,0 +1,7 @@ +--- +import Base from '../../layouts/Base.astro'; +import ActivityDetailLoader from '../../components/ActivityDetailLoader.svelte'; +--- + + + From f67e7552fd544c6b779dcaf122227f0788284db5 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 17:55:24 +0200 Subject: [PATCH 025/124] fix to strava sync --- bincio/serve/server.py | 65 ++++++++++++++++++++++++++++++++++++ tests/test_server_imports.py | 3 ++ 2 files changed, 68 insertions(+) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index c861b1a..0b23d83 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any, Optional from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile +from fastapi.responses import RedirectResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse @@ -643,6 +644,70 @@ async def submit_feedback( return JSONResponse({"ok": True, "id": submission_id}) +# ── Strava ──────────────────────────────────────────────────────────────────── + +_strava_oauth_states: set[str] = set() + + +@app.get("/api/strava/status") +async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _require_user(bincio_session) + if not strava_client_id: + return JSONResponse({"configured": False, "connected": False, "last_sync": None}) + dd = _get_data_dir() / user.handle + token_path = dd / "strava_token.json" + connected = token_path.exists() + last_sync = None + if connected: + try: + token = json.loads(token_path.read_text()) + last_sync = token.get("last_sync_at") + except Exception: + pass + return JSONResponse({"configured": True, "connected": connected, "last_sync": last_sync}) + + +@app.get("/api/strava/auth-url") +async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + _require_user(bincio_session) + if not strava_client_id: + raise HTTPException(400, "Strava client ID not configured on this server") + state = secrets.token_urlsafe(16) + _strava_oauth_states.add(state) + redirect_uri = str(request.url_for("strava_callback")) + from bincio.extract.strava_api import auth_url + return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)}) + + +@app.get("/api/strava/callback", name="strava_callback") +async def strava_callback( + request: Request, + code: str = "", + error: str = "", + state: str = "", + bincio_session: Optional[str] = Cookie(default=None), +) -> RedirectResponse: + site_origin = str(request.base_url).rstrip("/") + if error or not code: + return RedirectResponse(f"{site_origin}/?strava=error") + if state not in _strava_oauth_states: + return RedirectResponse(f"{site_origin}/?strava=error") + _strava_oauth_states.discard(state) + user = _current_user(bincio_session) + if not user: + return RedirectResponse(f"{site_origin}/?strava=error") + if not strava_client_id or not strava_client_secret: + return RedirectResponse(f"{site_origin}/?strava=error") + dd = _get_data_dir() / user.handle + from bincio.extract.strava_api import StravaError, exchange_code, save_token + try: + token = exchange_code(strava_client_id, strava_client_secret, code) + except StravaError: + return RedirectResponse(f"{site_origin}/?strava=error") + save_token(dd, token) + return RedirectResponse(f"{site_origin}/?strava=connected") + + @app.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py index 524e52b..76c55ac 100644 --- a/tests/test_server_imports.py +++ b/tests/test_server_imports.py @@ -14,6 +14,9 @@ def test_serve_app_has_routes(): paths = {r.path for r in app.routes} assert "/api/me" in paths assert "/api/upload" in paths + assert "/api/strava/status" in paths + assert "/api/strava/auth-url" in paths + assert "/api/strava/callback" in paths assert "/api/strava/sync" in paths assert "/api/register" in paths From e2765ea0123110c845ca7c473b77f587055db2dc Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 18:01:32 +0200 Subject: [PATCH 026/124] fix for strava auth --- bincio/serve/cli.py | 5 ++++- bincio/serve/server.py | 8 ++++++-- docs/deployment/vps.md | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 085ce0b..ae07a44 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -19,9 +19,10 @@ console = Console() @click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID", help="Strava OAuth client ID (enables per-user Strava sync)") @click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret") @click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)") +@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.") def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, strava_client_id: Optional[str], strava_client_secret: Optional[str], - max_users: Optional[int]) -> None: + max_users: Optional[int], public_url: Optional[str]) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -51,6 +52,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, srv.strava_client_id = strava_client_id if strava_client_secret: srv.strava_client_secret = strava_client_secret + if public_url: + srv.public_url = public_url db = open_db(dd) current_limit = get_setting(db, "max_users") diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 0b23d83..9b8ab9f 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -48,6 +48,7 @@ data_dir: Path | None = None site_dir: Path | None = None # for post-write rebuild trigger strava_client_id: str = "" strava_client_secret: str = "" +public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs _db = None # sqlite3.Connection, opened lazily @@ -674,7 +675,10 @@ async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cook raise HTTPException(400, "Strava client ID not configured on this server") state = secrets.token_urlsafe(16) _strava_oauth_states.add(state) - redirect_uri = str(request.url_for("strava_callback")) + if public_url: + redirect_uri = public_url.rstrip("/") + "/api/strava/callback" + else: + redirect_uri = str(request.url_for("strava_callback")) from bincio.extract.strava_api import auth_url return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)}) @@ -687,7 +691,7 @@ async def strava_callback( state: str = "", bincio_session: Optional[str] = Cookie(default=None), ) -> RedirectResponse: - site_origin = str(request.base_url).rstrip("/") + site_origin = public_url.rstrip("/") if public_url else str(request.base_url).rstrip("/") if error or not code: return RedirectResponse(f"{site_origin}/?strava=error") if state not in _strava_oauth_states: diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 5b79f92..4c1e905 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -114,7 +114,8 @@ ExecStart=/root/.local/bin/uv run bincio serve \ --data-dir /var/bincio/data \ --site-dir /opt/bincio/site \ --host 127.0.0.1 \ - --port 4041 + --port 4041 \ + --public-url https://yourdomain.com EnvironmentFile=/etc/bincio/secrets.env Restart=always RestartSec=5 From cf414a08ad144755282aa4c22c6241763a4ffed1 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 18:13:32 +0200 Subject: [PATCH 027/124] fix strava import? --- bincio/extract/ingest.py | 66 +++++++++++++++++++++++++----------- bincio/serve/server.py | 33 +++++++++++++++++- site/src/layouts/Base.astro | 59 ++++++++++++++++++++++---------- tests/test_server_imports.py | 1 + 4 files changed, 122 insertions(+), 37 deletions(-) diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py index dedb4fc..24daf4e 100644 --- a/bincio/extract/ingest.py +++ b/bincio/extract/ingest.py @@ -76,24 +76,19 @@ def ingest_parsed( return activity_id -def strava_sync( +def strava_sync_iter( data_dir: Path, client_id: str, client_secret: str, originals_dir: Optional[Path] = None, -) -> dict[str, Any]: - """Fetch new Strava activities and ingest them into data_dir. +): + """Generator version of strava_sync — yields progress dicts, then a final summary. - Args: - data_dir: Per-user data directory. - client_id: Strava OAuth client ID. - client_secret: Strava OAuth client secret. - - Returns: - Dict with keys: ok, imported, skipped, error_count, errors. - - Raises: - RuntimeError: If Strava credentials are missing or API calls fail. + Each yielded dict has a ``type`` key: + - ``"fetching"`` — about to fetch the activity list from Strava + - ``"progress"`` — one activity processed; keys: n, total, name, status ("imported"|"skipped"|"error") + - ``"done"`` — final summary; keys: imported, skipped, error_count, errors + - ``"error"`` — fatal error before processing started; key: message """ import time @@ -109,28 +104,36 @@ def strava_sync( from bincio.extract.writer import make_activity_id if not client_id or not client_secret: - raise RuntimeError("Strava not configured (missing client_id or client_secret)") + yield {"type": "error", "message": "Strava not configured"} + return try: token = ensure_fresh(data_dir, client_id, client_secret) except StravaError as e: - raise RuntimeError(str(e)) from e + yield {"type": "error", "message": str(e)} + return + + yield {"type": "fetching"} after: Optional[int] = token.get("last_sync_at") try: activities = fetch_activities(token["access_token"], after=after) except StravaError as e: - raise RuntimeError(str(e)) from e + yield {"type": "error", "message": str(e)} + return + total = len(activities) imported = 0 skipped = 0 errors: list[str] = [] - for meta in activities: + for n, meta in enumerate(activities, 1): + name = meta.get("name", "Untitled") try: activity_id = make_activity_id(strava_meta_to_partial(meta)) if (data_dir / "activities" / f"{activity_id}.json").exists(): skipped += 1 + yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"} continue streams = fetch_streams(token["access_token"], meta["id"]) if originals_dir is not None: @@ -142,16 +145,41 @@ def strava_sync( parsed = strava_to_parsed(meta, streams) ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001) imported += 1 + yield {"type": "progress", "n": n, "total": total, "name": name, "status": "imported"} except Exception as exc: errors.append(f"{meta.get('id')}: {type(exc).__name__}") + yield {"type": "progress", "n": n, "total": total, "name": name, "status": "error"} token["last_sync_at"] = int(time.time()) save_token(data_dir, token) - return { - "ok": True, + yield { + "type": "done", "imported": imported, "skipped": skipped, "error_count": len(errors), "errors": errors[:5], } + + +def strava_sync( + data_dir: Path, + client_id: str, + client_secret: str, + originals_dir: Optional[Path] = None, +) -> dict[str, Any]: + """Fetch new Strava activities and ingest them into data_dir. + + Returns: + Dict with keys: ok, imported, skipped, error_count, errors. + + Raises: + RuntimeError: If Strava credentials are missing or API calls fail. + """ + result: dict[str, Any] = {} + for event in strava_sync_iter(data_dir, client_id, client_secret, originals_dir): + if event["type"] == "error": + raise RuntimeError(event["message"]) + if event["type"] == "done": + result = event + return {"ok": True, **{k: v for k, v in result.items() if k != "type"}} diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 9b8ab9f..3646648 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Any, Optional from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse @@ -712,6 +712,37 @@ async def strava_callback( return RedirectResponse(f"{site_origin}/?strava=connected") +@app.get("/api/strava/sync/stream") +async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: + """SSE endpoint — streams per-activity progress then a final summary event.""" + user = _require_user(bincio_session) + if not strava_client_id or not strava_client_secret: + raise HTTPException(400, "Strava not configured on this server") + 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.extract.ingest import strava_sync_iter + + def event_stream(): + try: + for event in strava_sync_iter(dd, strava_client_id, strava_client_secret, originals_dir): + yield f"data: {json.dumps(event)}\n\n" + if event["type"] == "done": + _trigger_rebuild(user.handle) + except Exception as exc: + 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"}, + ) + + @app.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 5f33ad2..da29b81 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -561,26 +561,51 @@ try { } }); - stravaSyncBtn.addEventListener('click', async () => { + stravaSyncBtn.addEventListener('click', () => { stravaSyncBtn.disabled = true; stravaSyncBtn.textContent = 'Syncing…'; stravaStatus.textContent = ''; - try { - const r = await fetch(`${editUrl}/api/strava/sync`, { method: 'POST' }); - if (!r.ok) throw new Error(await r.text()); - const d = await r.json(); - stravaLastSync.textContent = new Date().toLocaleString(); - const errNote = d.error_count ? `, ${d.error_count} errors` : ''; - stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`; - stravaStatus.style.color = '#4ade80'; - if (d.imported > 0) setTimeout(() => window.location.reload(), 1500); - } catch (e) { - stravaStatus.textContent = 'Error: ' + e.message; - stravaStatus.style.color = '#f87171'; - } finally { - stravaSyncBtn.disabled = false; - stravaSyncBtn.textContent = 'Sync now'; - } + stravaStatus.style.color = ''; + + const es = new EventSource(`${editUrl}/api/strava/sync/stream`, { withCredentials: true }); + let imported = 0; + + es.onmessage = (e) => { + const d = JSON.parse(e.data); + if (d.type === 'fetching') { + stravaStatus.textContent = 'Fetching activity list from Strava…'; + } else if (d.type === 'progress') { + const pct = Math.round((d.n / d.total) * 100); + const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·'; + stravaStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`; + if (d.status === 'imported') imported++; + } else if (d.type === 'done') { + es.close(); + stravaLastSync.textContent = new Date().toLocaleString(); + const errNote = d.error_count ? `, ${d.error_count} errors` : ''; + stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`; + stravaStatus.style.color = '#4ade80'; + stravaSyncBtn.disabled = false; + stravaSyncBtn.textContent = 'Sync now'; + if (d.imported > 0) setTimeout(() => window.location.reload(), 1500); + } else if (d.type === 'error') { + es.close(); + stravaStatus.textContent = 'Error: ' + d.message; + stravaStatus.style.color = '#f87171'; + stravaSyncBtn.disabled = false; + stravaSyncBtn.textContent = 'Sync now'; + } + }; + + es.onerror = () => { + es.close(); + if (stravaSyncBtn.disabled) { + stravaStatus.textContent = 'Connection lost. Check logs.'; + stravaStatus.style.color = '#f87171'; + stravaSyncBtn.disabled = false; + stravaSyncBtn.textContent = 'Sync now'; + } + }; }); async function stravaReset(mode) { diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py index 76c55ac..8ac4c7d 100644 --- a/tests/test_server_imports.py +++ b/tests/test_server_imports.py @@ -18,6 +18,7 @@ def test_serve_app_has_routes(): assert "/api/strava/auth-url" in paths assert "/api/strava/callback" in paths assert "/api/strava/sync" in paths + assert "/api/strava/sync/stream" in paths assert "/api/register" in paths From 3e4ff4019bd864ec4bd305db34c76f83f63d2588 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 18:13:49 +0200 Subject: [PATCH 028/124] limit number of workers --- bincio/extract/cli.py | 2 +- bincio/extract/config.py | 2 ++ docs/deployment/vps.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bincio/extract/cli.py b/bincio/extract/cli.py index b95ec37..6ddf179 100644 --- a/bincio/extract/cli.py +++ b/bincio/extract/cli.py @@ -171,7 +171,7 @@ def extract( dedup = DedupIndex(output_dir=cfg.output_dir) known_hashes: frozenset = frozenset(dedup._by_hash.keys()) - n_workers = workers or os.cpu_count() or 4 + n_workers = workers or cfg.workers or os.cpu_count() or 4 console.print(f"Using [bold]{n_workers}[/bold] worker processes.") owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name} diff --git a/bincio/extract/config.py b/bincio/extract/config.py index 1c8c6a1..baa72d6 100644 --- a/bincio/extract/config.py +++ b/bincio/extract/config.py @@ -51,6 +51,7 @@ class ExtractConfig: track: TrackConfig = field(default_factory=TrackConfig) classifier: ClassifierConfig = field(default_factory=ClassifierConfig) incremental: bool = True + workers: Optional[int] = None # None → use CPU count owner_handle: str = "me" owner_display_name: str = "Me" athlete: AthleteConfig | None = None @@ -109,6 +110,7 @@ def load_config(path: Path) -> ExtractConfig: track=track, classifier=classifier, incremental=raw.get("incremental", True), + workers=raw.get("workers"), owner_handle=owner.get("handle", "me"), owner_display_name=owner.get("display_name", "Me"), athlete=athlete, diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 4c1e905..1a5f9b5 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -206,6 +206,8 @@ sources: output: dir: /var/bincio/data + +workers: 2 # cap extract parallelism on the VPS (default: all CPUs) ``` Sync and extract (run from your laptop or SSH in): From 816f103b4cc8681a075de30a7d1040170b1e0a41 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 18:20:35 +0200 Subject: [PATCH 029/124] fix: write empty index.json for new users at registration so shard resolves immediately --- bincio/serve/server.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 3646648..1b07965 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -286,11 +286,18 @@ async def register(request: Request) -> JSONResponse: # Create per-user directories dd = _get_data_dir() - (dd / handle / "activities").mkdir(parents=True, exist_ok=True) - (dd / handle / "edits").mkdir(parents=True, exist_ok=True) + user_dir = dd / handle + (user_dir / "activities").mkdir(parents=True, exist_ok=True) + (user_dir / "edits").mkdir(parents=True, exist_ok=True) + + # Write an empty index.json so the shard URL resolves immediately, + # even before the user uploads any activities. + from bincio.extract.writer import write_index + index_path = user_dir / "index.json" + if not index_path.exists(): + write_index([], user_dir, {"handle": handle, "display_name": display or handle}) # Update root manifest so the new user's shard is discoverable immediately - # (Astro dev re-evaluates getStaticPaths() on each request from this file) from bincio.render.cli import _write_root_manifest _write_root_manifest(dd) From 9fd088c693fcea67a7f110bffecca264c564678f Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 18:34:53 +0200 Subject: [PATCH 030/124] =?UTF-8?q?=20=20-=20"Last=20sync:=20never":=20The?= =?UTF-8?q?=20old=20blocking=20sync=20was=20killed=20by=20nginx=20at=20120?= =?UTF-8?q?s=20before=20save=5Ftoken=20was=20reached.=20The=20activities?= =?UTF-8?q?=20made=20it=20to=20disk=20(ingestion=20happens=20per-activity?= =?UTF-8?q?=20as=20it=20goes),=20but=20the=20token's=20=20=20last=5Fsync?= =?UTF-8?q?=5Fat=20timestamp=20was=20never=20written.=20After=20deploying,?= =?UTF-8?q?=20do=20a=20soft=20reset=20=E2=80=94=20it'll=20set=20last=5Fsyn?= =?UTF-8?q?c=5Fat=20to=20your=20most=20recent=20activity's=20timestamp=20s?= =?UTF-8?q?o=20the=20next=20sync=20only=20fetches=20newer=20ones.=20=20=20?= =?UTF-8?q?-=20Reset=20404:=20Added=20POST=20/api/strava/reset=20to=20serv?= =?UTF-8?q?e/server.py.=20The=20soft=20reset=20now=20looks=20in=20=5Fmerge?= =?UTF-8?q?d/index.json=20first=20(multi-user=20path),=20falling=20back=20?= =?UTF-8?q?to=20index.json.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bincio/serve/server.py | 65 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 1b07965..83051fa 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -663,16 +663,65 @@ async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> if not strava_client_id: return JSONResponse({"configured": False, "connected": False, "last_sync": None}) dd = _get_data_dir() / user.handle - token_path = dd / "strava_token.json" - connected = token_path.exists() - last_sync = None - if connected: + from bincio.extract.strava_api import load_token + token = load_token(dd) + return JSONResponse({ + "configured": True, + "connected": token is not None, + "last_sync": token.get("last_sync_at") if token else None, + }) + + +@app.post("/api/strava/reset") +async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Reset last_sync_at so the next sync re-fetches from a chosen point. + + mode=soft — set to the started_at of the most recent activity on disk + (next sync only fetches activities newer than the last known one) + mode=hard — clear last_sync_at entirely + (next sync re-downloads full Strava history, skipping existing files) + """ + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + from bincio.extract.strava_api import load_token, save_token + token = load_token(dd) + if token is None: + raise HTTPException(400, "Not connected to Strava") + + body = await request.json() + mode = body.get("mode", "soft") + + if mode == "hard": + token.pop("last_sync_at", None) + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None}) + + # soft: find the most recent started_at across the user's merged index + from datetime import datetime, timezone + last_ts: int | None = None + for index_path in [dd / "_merged" / "index.json", dd / "index.json"]: + if not index_path.exists(): + continue try: - token = json.loads(token_path.read_text()) - last_sync = token.get("last_sync_at") + index_data = json.loads(index_path.read_text(encoding="utf-8")) + started_ats = [ + a.get("started_at") for a in index_data.get("activities", []) + if a.get("started_at") + ] + if started_ats: + latest = max(started_ats) + dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) + last_ts = int(dt.astimezone(timezone.utc).timestamp()) + break except Exception: - pass - return JSONResponse({"configured": True, "connected": connected, "last_sync": last_sync}) + continue + + if last_ts is None: + token.pop("last_sync_at", None) + else: + token["last_sync_at"] = last_ts + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) @app.get("/api/strava/auth-url") From e5eadc69f2dbd5491328755644705379af90a478 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 19:20:00 +0200 Subject: [PATCH 031/124] fix: remove double px-4 on user profile page headers --- site/src/pages/u/[handle]/athlete/index.astro | 2 +- site/src/pages/u/[handle]/index.astro | 2 +- site/src/pages/u/[handle]/stats/index.astro | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/u/[handle]/athlete/index.astro b/site/src/pages/u/[handle]/athlete/index.astro index 137186d..705f7bf 100644 --- a/site/src/pages/u/[handle]/athlete/index.astro +++ b/site/src/pages/u/[handle]/athlete/index.astro @@ -20,7 +20,7 @@ const indexUrl = `${mergedBase}index.json`; const athleteUrl = `${mergedBase}athlete.json`; --- -
+

@{handle}

diff --git a/site/src/pages/u/[handle]/index.astro b/site/src/pages/u/[handle]/index.astro index 2193f97..a5ea76e 100644 --- a/site/src/pages/u/[handle]/index.astro +++ b/site/src/pages/u/[handle]/index.astro @@ -20,7 +20,7 @@ const { handle, shardUrl } = Astro.props as { handle: string; shardUrl: string } const base = import.meta.env.BASE_URL; --- -
+

@{handle}

@@ -307,6 +317,22 @@ try {

+ + +
)} @@ -403,10 +429,17 @@ try { const viewChoose = document.getElementById('upload-view-choose'); const viewFile = document.getElementById('upload-view-file'); const viewStrava = document.getElementById('upload-view-strava'); + const viewZip = document.getElementById('upload-view-zip'); const chooseFile = document.getElementById('upload-choose-file'); const chooseStrava = document.getElementById('upload-choose-strava'); + const chooseZip = document.getElementById('upload-choose-zip'); const backFile = document.getElementById('upload-back-file'); const backStrava = document.getElementById('upload-back-strava'); + const backZip = document.getElementById('upload-back-zip'); + const zipDrop = document.getElementById('zip-drop'); + const zipInput = document.getElementById('zip-input'); + const zipLabel = document.getElementById('zip-label'); + const zipStatus = document.getElementById('zip-status'); const drop = document.getElementById('upload-drop'); const input = document.getElementById('upload-input'); const label = document.getElementById('upload-label'); @@ -427,6 +460,7 @@ try { viewChoose.style.display = name === 'choose' ? '' : 'none'; viewFile.style.display = name === 'file' ? '' : 'none'; viewStrava.style.display = name === 'strava' ? '' : 'none'; + viewZip.style.display = name === 'zip' ? '' : 'none'; } function openModal() { @@ -446,8 +480,10 @@ try { document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); }); chooseFile.addEventListener('click', () => showView('file')); + chooseZip.addEventListener('click', () => showView('zip')); backFile.addEventListener('click', () => showView('choose')); backStrava.addEventListener('click', () => showView('choose')); + backZip.addEventListener('click', () => showView('choose')); // ── file upload ─────────────────────────────────────────────────────── drop.addEventListener('click', () => input.click()); @@ -638,6 +674,75 @@ try { stravaResetSoftBtn.addEventListener('click', () => stravaReset('soft')); stravaResetHardBtn.addEventListener('click', () => stravaReset('hard')); + // ── Strava ZIP upload ───────────────────────────────────────────────── + function doZipUpload(file) { + if (!file) return; + zipLabel.textContent = file.name; + zipStatus.textContent = 'Uploading…'; + zipStatus.style.color = ''; + + const fd = new FormData(); + fd.append('file', file); + + // POST the file; server responds with SSE stream immediately after receiving body + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${editUrl}/api/upload/strava-zip`); + xhr.withCredentials = true; + xhr.setRequestHeader('Accept', 'text/event-stream'); + + let buf = ''; + let imported = 0; + + xhr.onprogress = () => { + // Parse SSE lines from the incrementally received response text + const newText = xhr.responseText.slice(buf.length); + buf = xhr.responseText; + for (const line of newText.split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const ev = JSON.parse(line.slice(6)); + if (ev.type === 'validating') { + zipStatus.textContent = 'Validating ZIP structure…'; + } else if (ev.type === 'extracting_csv') { + zipStatus.textContent = 'Reading activities.csv…'; + } else if (ev.type === 'progress') { + const pct = Math.round((ev.n / ev.total) * 100); + const icon = ev.status === 'imported' ? '↓' : ev.status === 'error' ? '✗' : '·'; + zipStatus.textContent = `${icon} ${ev.n}/${ev.total} (${pct}%) — ${ev.name}`; + if (ev.status === 'imported') imported++; + } else if (ev.type === 'done') { + const errNote = ev.error_count ? `, ${ev.error_count} errors` : ''; + zipStatus.textContent = `Done — ${ev.imported} imported, ${ev.skipped} already up to date${errNote}.`; + zipStatus.style.color = '#4ade80'; + zipInput.value = ''; + if (ev.imported > 0) setTimeout(() => window.location.reload(), 1500); + } else if (ev.type === 'error') { + zipStatus.textContent = 'Error: ' + ev.message; + zipStatus.style.color = '#f87171'; + zipInput.value = ''; + } + } catch (_) {} + } + }; + + xhr.onerror = () => { + zipStatus.textContent = 'Upload failed — check your connection.'; + zipStatus.style.color = '#f87171'; + }; + + xhr.send(fd); + } + + zipDrop.addEventListener('click', () => zipInput.click()); + zipInput.addEventListener('change', () => doZipUpload(zipInput.files?.[0])); + zipDrop.addEventListener('dragover', e => { e.preventDefault(); zipDrop.classList.add('border-zinc-400'); }); + zipDrop.addEventListener('dragleave', () => zipDrop.classList.remove('border-zinc-400')); + zipDrop.addEventListener('drop', e => { + e.preventDefault(); + zipDrop.classList.remove('border-zinc-400'); + doZipUpload(e.dataTransfer?.files?.[0]); + }); + // Handle ?strava= param set by the callback redirect (popup scenario) const sp = new URLSearchParams(window.location.search); if (sp.has('strava')) { diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py index 8ac4c7d..63f11c4 100644 --- a/tests/test_server_imports.py +++ b/tests/test_server_imports.py @@ -14,6 +14,7 @@ def test_serve_app_has_routes(): paths = {r.path for r in app.routes} assert "/api/me" in paths assert "/api/upload" in paths + assert "/api/upload/strava-zip" in paths assert "/api/strava/status" in paths assert "/api/strava/auth-url" in paths assert "/api/strava/callback" in paths From fc6c00c6eb4453d6dcf756accdc3febb7ede8c3f Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 22:05:42 +0200 Subject: [PATCH 033/124] fix: mobile nav scrolls horizontally without spilling to page width Logo and action buttons are shrink-0 anchors; nav links occupy the remaining space with overflow-x:auto and a hidden scrollbar so they scroll independently. body gets overflow-x:hidden to prevent the whole page from drifting sideways on narrow screens. --- site/src/layouts/Base.astro | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 85494f7..9c1f76a 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -138,8 +138,12 @@ try { /* ── Base reset ─────────────────────────────────────────────────────── */ * { box-sizing: border-box; } html { scroll-behavior: smooth; } - body { margin: 0; } + body { margin: 0; overflow-x: hidden; } .maplibregl-canvas { outline: none; } + + /* Nav links scroll horizontally on narrow screens without a scrollbar */ + .nav-links { scrollbar-width: none; -ms-overflow-style: none; } + .nav-links::-webkit-scrollbar { display: none; } -
- +
+ + BincioActivity {!isPublicPage && ( - <> + + )} -
+ +
{!isPublicPage && ( <> From da622131fd0843f41fb6e439da7f88e383ce0a9d Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 22:26:11 +0200 Subject: [PATCH 034/124] 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 From bc30e0a2fc364a05fcc55c95e891308700c57865 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 22:51:29 +0200 Subject: [PATCH 035/124] option to keep all activities private from strava zip, fix copy of register link --- bincio/edit/server.py | 9 +++++++-- bincio/extract/strava_zip.py | 3 ++- bincio/serve/server.py | 5 ++++- site/src/layouts/Base.astro | 7 +++++++ site/src/pages/invites/index.astro | 24 +++++++++++++++++++++--- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 207e6f2..eda65cc 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -786,7 +786,10 @@ async def strava_reset(request: Request) -> JSONResponse: @app.post("/api/upload/strava-zip") -async def upload_strava_zip(file: UploadFile = File(...)) -> StreamingResponse: +async def upload_strava_zip( + file: UploadFile = File(...), + private: str = Form(default="false"), +) -> 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. @@ -795,6 +798,8 @@ async def upload_strava_zip(file: UploadFile = File(...)) -> StreamingResponse: if not file.filename or not file.filename.lower().endswith(".zip"): raise HTTPException(400, "Please upload a .zip file") + privacy = "private" if private.lower() in ("true", "1", "yes") else "public" + dd = _get_data_dir() import tempfile tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd) @@ -811,7 +816,7 @@ async def upload_strava_zip(file: UploadFile = File(...)) -> StreamingResponse: def event_stream(): any_imported = False try: - for event in strava_zip_iter(zip_path, dd): + for event in strava_zip_iter(zip_path, dd, privacy=privacy): yield f"data: {json.dumps(event)}\n\n" if event.get("type") == "progress" and event.get("status") == "imported": any_imported = True diff --git a/bincio/extract/strava_zip.py b/bincio/extract/strava_zip.py index 65777e7..8eda4d2 100644 --- a/bincio/extract/strava_zip.py +++ b/bincio/extract/strava_zip.py @@ -34,6 +34,7 @@ def strava_zip_iter( zip_path: Path, data_dir: Path, originals_dir: Optional[Path] = None, + privacy: str = "public", ) -> Generator[dict, None, None]: """Process a Strava export ZIP, yielding SSE-style progress dicts. @@ -120,7 +121,7 @@ def strava_zip_iter( orig_dest = originals_dir / entry_name shutil.copy2(tmp_path, orig_dest) - ingest_parsed(parsed, data_dir, privacy="public") + ingest_parsed(parsed, data_dir, privacy=privacy) imported += 1 yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "imported"} diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 7c1f297..c26292d 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -590,6 +590,7 @@ async def upload_activity( @app.post("/api/upload/strava-zip") async def upload_strava_zip( file: UploadFile = File(...), + private: str = Form(default="false"), bincio_session: Optional[str] = Cookie(default=None), ) -> StreamingResponse: """Accept a Strava bulk export ZIP and stream SSE progress while processing. @@ -601,6 +602,8 @@ async def upload_strava_zip( if not file.filename or not file.filename.lower().endswith(".zip"): raise HTTPException(400, "Please upload a .zip file") + privacy = "private" if private.lower() in ("true", "1", "yes") else "public" + dd = _get_data_dir() / user.handle import tempfile tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd) @@ -617,7 +620,7 @@ async def upload_strava_zip( def event_stream(): any_imported = False try: - for event in strava_zip_iter(zip_path, dd): + for event in strava_zip_iter(zip_path, dd, privacy=privacy): yield f"data: {json.dumps(event)}\n\n" if event.get("type") == "progress" and event.get("status") == "imported": any_imported = True diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 9a36962..b531a45 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -338,6 +338,11 @@ try {
Drop your Strava export .zip
or click to browse
+

@@ -447,6 +452,7 @@ try { const zipInput = document.getElementById('zip-input'); const zipLabel = document.getElementById('zip-label'); const zipStatus = document.getElementById('zip-status'); + const zipPrivate = document.getElementById('zip-private'); const drop = document.getElementById('upload-drop'); const input = document.getElementById('upload-input'); const label = document.getElementById('upload-label'); @@ -690,6 +696,7 @@ try { const fd = new FormData(); fd.append('file', file); + fd.append('private', zipPrivate?.checked ? 'true' : 'false'); // POST the file; server responds with SSE stream immediately after receiving body const xhr = new XMLHttpRequest(); diff --git a/site/src/pages/invites/index.astro b/site/src/pages/invites/index.astro index 0262565..f8e76c2 100644 --- a/site/src/pages/invites/index.astro +++ b/site/src/pages/invites/index.astro @@ -47,6 +47,17 @@ import Base from '../../layouts/Base.astro'; return li; } + function fallbackCopy(text: string, done: () => void) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand('copy'); done(); } catch (_) {} + document.body.removeChild(ta); + } + async function loadInvites() { try { const r = await fetch('/api/invites', { credentials: 'include' }); @@ -66,9 +77,16 @@ import Base from '../../layouts/Base.astro'; // Copy link buttons listEl.querySelectorAll('.copy-btn').forEach(btn => { btn.addEventListener('click', () => { - navigator.clipboard.writeText((btn as HTMLElement).dataset.link ?? ''); - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); + const text = (btn as HTMLElement).dataset.link ?? ''; + const done = () => { + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); + }; + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); + } else { + fallbackCopy(text, done); + } }); }); } catch (e: any) { From c99b755382df2fb97913729494a4aa753e719873 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 22:58:34 +0200 Subject: [PATCH 036/124] The culprit is in renderChart(): it calls chart?.remove() which empties the container div, causing the layout to collapse to zero height for a moment. The browser then scrolls to keep the viewport anchored, but since the page got shorter it jumps to the top. When the new SVG is appended, the page is taller again but the scroll position was already reset. Fix: give the chart container a min-height matching the chart height (220px) so it never collapses. --- site/src/components/ActivityCharts.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index 6f07c5d..b80deec 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -347,7 +347,7 @@
-
+
{#if chartType === 'histogram'} From cbd5a98cd3d1eea438739758316a48817a6a8ed5 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 23:16:38 +0200 Subject: [PATCH 037/124] - merge.py: keep private activities in _merged/index.json instead of stripping them; privacy filtering is now done client-side - ActivityFeed: detect logged-in user via bincio:me event; show private activities only when viewing your own profile; private cards get a lock badge --- bincio/render/merge.py | 5 ++-- site/src/components/ActivityFeed.svelte | 33 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/bincio/render/merge.py b/bincio/render/merge.py index 8064776..c90cb35 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -152,7 +152,6 @@ def merge_one(data_dir: Path, activity_id: str) -> None: s = _apply_sidecar_summary(s, fm) activities.append(s) - activities = [a for a in activities if a.get("privacy") != "private"] activities.sort(key=lambda a: a.get("started_at", ""), reverse=True) activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1) @@ -262,9 +261,9 @@ def merge_all(data_dir: Path) -> int: activities.append(s) # Drop private activities from the published feed - activities = [a for a in activities if a.get("privacy") != "private"] - # Sort: newest first, then bring highlighted activities to the top + # Private activities are kept in the index so the owner can see them; + # the feed UI filters them out for non-owners client-side. activities.sort(key=lambda a: a.get("started_at", ""), reverse=True) activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1) diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte index ffc330e..4d6291d 100644 --- a/site/src/components/ActivityFeed.svelte +++ b/site/src/components/ActivityFeed.svelte @@ -43,8 +43,20 @@ let loading = true; let error = ''; let mounted = false; + /** Logged-in handle — resolved async via bincio:me event. */ + let me: string = ''; - $: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport); + // Show private activities only to their owner. + // On a profile page (filterHandle set): show private if me === filterHandle. + // On the global feed: show private only for the logged-in user's own activities. + $: isOwner = filterHandle !== '' && me === filterHandle; + $: withPrivacy = all.filter(a => { + if (a.privacy === 'private') { + return filterHandle ? isOwner : (me !== '' && (a as any).handle === me); + } + return true; + }); + $: filtered = sport === 'all' ? withPrivacy : withPrivacy.filter(a => a.sport === sport); $: visible = filtered.slice(0, shown); $: hasMore = shown < filtered.length; @@ -60,18 +72,26 @@ onMount(async () => { sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all'; mounted = true; + + // Resolve the logged-in handle so we can show the owner their private activities. + if ((window as any).__bincioMe !== undefined) { + me = (window as any).__bincioMe; + } else { + window.addEventListener('bincio:me', (e: Event) => { me = (e as CustomEvent).detail; }, { once: true }); + } + try { const indexUrl = profileIndexUrl ? `${base}data/${profileIndexUrl}` : `${base}data/index.json`; const index = await loadIndex(base, indexUrl); - let activities = index.activities.filter(a => a.privacy !== 'private'); + let activities = index.activities; // filterHandle only applies when loading the root manifest (multi-user feed). // When profileIndexUrl is set we already loaded the right user's shard directly — // activities from a direct shard fetch have no handle tag, so the filter would // remove everything. if (filterHandle && !profileIndexUrl) { - activities = activities.filter(a => a.handle === filterHandle); + activities = activities.filter(a => (a as any).handle === filterHandle); } all = activities; } catch (e: any) { @@ -140,10 +160,13 @@ >@{a.handle}{/if}

-

+

+ {#if a.privacy === 'private'} + 🔒 + {/if} {a.title}

From 01db4eb9ae4f5323d4dd9eaefd83ca77730845fc Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 11 Apr 2026 08:13:27 +0200 Subject: [PATCH 038/124] ingest activities.csv --- bincio/edit/server.py | 145 ++++++++++++++++++++++------------- bincio/extract/strava_csv.py | 118 +++++++++++++++++++++++++--- bincio/serve/server.py | 55 +++++++++++-- scripts/bulk_private.py | 108 ++++++++++++++++++++++++++ site/src/layouts/Base.astro | 20 +++-- 5 files changed, 367 insertions(+), 79 deletions(-) create mode 100644 scripts/bulk_private.py diff --git a/bincio/edit/server.py b/bincio/edit/server.py index eda65cc..f70faee 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -538,73 +538,108 @@ def _file_suffix(name: str) -> str: @app.post("/api/upload") async def upload_activity( - file: UploadFile = File(...), + files: list[UploadFile] = File(...), store_original: bool = Form(False), ) -> JSONResponse: - """Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge.""" + """Accept FIT/GPX/TCX files and/or activities.csv, extract, update index, re-merge. + + activities.csv (Strava export format) can be included in the batch to: + - Enrich activity files being uploaded in the same batch (matched by filename) + - Retroactively update sidecars for existing activities (matched by strava_id) + """ + from bincio.extract.ingest import ingest_parsed + from bincio.extract.parsers.factory import parse_file + from bincio.extract.writer import make_activity_id + from bincio.render.merge import merge_all + dd = _get_data_dir() - - name = Path(file.filename or "upload.fit").name # strip any path components - suffix = _file_suffix(name) - if suffix not in _SUPPORTED_SUFFIXES: - raise HTTPException(400, f"Unsupported file type '{Path(name).suffix}'. Expected FIT, GPX, or TCX.") - - _MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB - contents = await file.read() - if len(contents) > _MAX_UPLOAD_BYTES: - raise HTTPException(413, f"File too large ({len(contents)} bytes). Maximum is 50 MB.") - staging = dd / "_uploads" staging.mkdir(exist_ok=True) - staged = staging / name - staged.write_bytes(contents) - kept = False - try: - from bincio.extract.metrics import compute - from bincio.extract.parsers.factory import parse_file - from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index + _MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB - activity = parse_file(staged) - metrics = compute(activity) - activity_id = make_activity_id(activity) - - existing_json = dd / "activities" / f"{activity_id}.json" - if existing_json.exists(): - raise HTTPException(409, f"Activity already exists: {activity_id}") - - write_activity(activity, metrics, dd, privacy="public", rdp_epsilon=0.0001) - summary = build_summary(activity, metrics, activity_id, "public") - - # Read current index to preserve owner + existing summaries - index_path = dd / "index.json" - if index_path.exists(): - index_data = json.loads(index_path.read_text(encoding="utf-8")) + # Separate CSV files from activity files + csv_files: list[UploadFile] = [] + activity_files: list[UploadFile] = [] + for f in files: + name = Path(f.filename or "").name.lower() + if name.endswith(".csv"): + csv_files.append(f) else: - index_data = {"owner": {"handle": "unknown"}, "activities": []} - owner = index_data.get("owner", {}) - existing = {s["id"]: s for s in index_data.get("activities", [])} - existing[activity_id] = summary - write_index(list(existing.values()), dd, owner) + activity_files.append(f) - if store_original: - originals_dir = dd / "originals" - originals_dir.mkdir(exist_ok=True) - staged.rename(originals_dir / name) - kept = True + # Build metadata from the first CSV found (activities.csv from Strava export) + metadata = None + if csv_files: + from bincio.extract.strava_csv import StravaMetadata + import tempfile + csv_upload = csv_files[0] + csv_bytes = await csv_upload.read() + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp: + tmp.write(csv_bytes) + tmp_path = Path(tmp.name) + try: + metadata = StravaMetadata(tmp_path) + finally: + tmp_path.unlink(missing_ok=True) - from bincio.render.merge import merge_all + results = [] + any_added = False + + for file in activity_files: + name = Path(file.filename or "upload.fit").name + suffix = _file_suffix(name) + if suffix not in _SUPPORTED_SUFFIXES: + results.append({"name": name, "ok": False, "error": f"Unsupported file type '{Path(name).suffix}'"}) + continue + + contents = await file.read() + if len(contents) > _MAX_UPLOAD_BYTES: + results.append({"name": name, "ok": False, "error": "File too large (max 50 MB)"}) + continue + + staged = staging / name + staged.write_bytes(contents) + kept = False + try: + activity = parse_file(staged) + + # Enrich with CSV metadata when available (matched by filename) + if metadata is not None: + metadata.enrich(name, activity) + + activity_id = make_activity_id(activity) + if (dd / "activities" / f"{activity_id}.json").exists(): + results.append({"name": name, "ok": False, "error": "duplicate"}) + continue + + 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}) + any_added = True + except Exception: + results.append({"name": name, "ok": False, "error": "Processing failed"}) + finally: + if not kept: + staged.unlink(missing_ok=True) + + # Retroactively update sidecars for existing activities matched by strava_id + csv_updates = 0 + if metadata is not None: + from bincio.extract.strava_csv import apply_csv_to_data_dir + csv_updates = apply_csv_to_data_dir(dd, metadata) + + if any_added or csv_updates: merge_all(dd) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(422, f"Failed to process activity file: {type(exc).__name__}") - finally: - if not kept: - staged.unlink(missing_ok=True) - - return JSONResponse({"ok": True, "id": activity_id}) + added = [r for r in results if r["ok"]] + return JSONResponse({"ok": True, "added": len(added), "csv_updates": csv_updates, "results": results}) @app.post("/api/import-bas") diff --git a/bincio/extract/strava_csv.py b/bincio/extract/strava_csv.py index 472d847..a77f0b0 100644 --- a/bincio/extract/strava_csv.py +++ b/bincio/extract/strava_csv.py @@ -1,14 +1,14 @@ """Import metadata from Strava's activities.csv bulk export. Strava export columns we care about: - Activity ID, Activity Date, Activity Name, Activity Type, - Activity Description, Filename + Activity ID, Activity Date, Activity Name, Activity Description, Filename """ import csv +import json import re from pathlib import Path -from typing import Optional +from typing import Iterator, Optional _STRAVA_DATE_FMTS = ( @@ -18,10 +18,11 @@ _STRAVA_DATE_FMTS = ( class StravaMetadata: - """Maps original filename → Strava metadata.""" + """Maps original filename → Strava metadata, with secondary strava_id index.""" def __init__(self, csv_path: Path) -> None: self._by_filename: dict[str, dict] = {} + self._by_strava_id: dict[str, dict] = {} self._load(csv_path) def _load(self, path: Path) -> None: @@ -29,16 +30,21 @@ class StravaMetadata: reader = csv.DictReader(f) for row in reader: filename = row.get("Filename", "").strip() - if not filename: - continue - # Strava stores paths like "activities/12345.fit.gz" - basename = Path(filename).name - self._by_filename[basename] = row + if filename: + basename = Path(filename).name + self._by_filename[basename] = row + strava_id = row.get("Activity ID", "").strip() + if strava_id: + self._by_strava_id[strava_id] = row def lookup(self, source_file: str) -> Optional[dict]: """Return the Strava CSV row for a given source filename, or None.""" return self._by_filename.get(source_file) + def lookup_by_strava_id(self, strava_id: str) -> Optional[dict]: + """Return the Strava CSV row for a given Strava activity ID, or None.""" + return self._by_strava_id.get(str(strava_id)) + def enrich(self, source_file: str, activity: object) -> None: """Mutate a ParsedActivity with Strava metadata if found.""" row = self.lookup(source_file) @@ -53,3 +59,97 @@ class StravaMetadata: if not activity.strava_id and row.get("Activity ID"): # type: ignore[attr-defined] activity.strava_id = row["Activity ID"].strip() # type: ignore[attr-defined] + + +# ── Retroactive sidecar update ──────────────────────────────────────────────── + +def _parse_sidecar(path: Path) -> tuple[dict, str]: + """Return (frontmatter_dict, body) from a sidecar .md file.""" + import re as _re + import yaml + text = path.read_text(encoding="utf-8") + if text.startswith("---"): + parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE) + if len(parts) >= 3: + fm = yaml.safe_load(parts[1]) or {} + return fm, parts[2].strip() + return {}, text.strip() + + +def _write_sidecar(path: Path, fm: dict, body: str) -> None: + import yaml + path.parent.mkdir(parents=True, exist_ok=True) + fm_text = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip() + content = f"---\n{fm_text}\n---\n" + if body: + content += f"\n{body}\n" + path.write_text(content, encoding="utf-8") + + +def _update_sidecar_from_row(sidecar_path: Path, row: dict) -> bool: + """Create or update a sidecar with CSV title/description. + + Only fills fields that are not already set in the sidecar. + Returns True if anything changed. + """ + title = row.get("Activity Name", "").strip() + description = row.get("Activity Description", "").strip() + if not title and not description: + return False + + fm, body = _parse_sidecar(sidecar_path) if sidecar_path.exists() else ({}, "") + + changed = False + if title and "title" not in fm: + fm["title"] = title + changed = True + if description and not body: + body = description + changed = True + + if not changed: + return False + + _write_sidecar(sidecar_path, fm, body) + return True + + +def apply_csv_to_data_dir(data_dir: Path, metadata: StravaMetadata) -> int: + """Retroactively apply CSV metadata to existing activities via sidecars. + + Scans all activity JSONs in data_dir/activities/. For each activity that + has a strava_id, looks up the corresponding CSV row and creates/updates + the sidecar in data_dir/edits/ with any missing title or description. + + Only writes fields not already present in the sidecar — manual edits are + never overwritten. + + Returns the count of activities whose sidecars were created or updated. + """ + activities_dir = data_dir / "activities" + edits_dir = data_dir / "edits" + + if not activities_dir.exists(): + return 0 + + updated = 0 + for json_path in sorted(activities_dir.glob("*.json")): + try: + detail = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: + continue + + strava_id = detail.get("strava_id") + if not strava_id: + continue + + row = metadata.lookup_by_strava_id(str(strava_id)) + if row is None: + continue + + activity_id = json_path.stem + sidecar_path = edits_dir / f"{activity_id}.md" + if _update_sidecar_from_row(sidecar_path, row): + updated += 1 + + return updated diff --git a/bincio/serve/server.py b/bincio/serve/server.py index c26292d..ef69c02 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -530,6 +530,12 @@ async def upload_activity( store_original: bool = Form(False), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: + """Accept FIT/GPX/TCX files and/or activities.csv, extract, update index, re-merge. + + activities.csv (Strava export format) can be included in the batch to: + - Enrich activity files being uploaded in the same batch (matched by filename) + - Retroactively update sidecars for existing activities (matched by strava_id) + """ from bincio.extract.ingest import ingest_parsed from bincio.extract.parsers.factory import parse_file from bincio.extract.writer import make_activity_id @@ -540,13 +546,36 @@ async def upload_activity( staging = dd / "_uploads" staging.mkdir(exist_ok=True) + # Separate CSV files from activity files + csv_files: list[UploadFile] = [] + activity_files: list[UploadFile] = [] + for f in files: + fname = Path(f.filename or "").name.lower() + if fname.endswith(".csv"): + csv_files.append(f) + else: + activity_files.append(f) + + # Build metadata from the first CSV found (activities.csv from Strava export) + metadata = None + if csv_files: + from bincio.extract.strava_csv import StravaMetadata + import tempfile + csv_bytes = await csv_files[0].read() + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp: + tmp.write(csv_bytes) + tmp_path = Path(tmp.name) + try: + metadata = StravaMetadata(tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + results = [] any_added = False - for file in files: + for file in activity_files: name = Path(file.filename or "upload.fit").name - p = Path(name.lower()) - suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix + suffix = _file_suffix(name) if suffix not in _SUPPORTED_SUFFIXES: results.append({"name": name, "ok": False, "error": f"Unsupported file type '{suffix}'"}) continue @@ -561,6 +590,11 @@ async def upload_activity( kept = False try: activity = parse_file(staged) + + # Enrich with CSV metadata when available (matched by filename) + if metadata is not None: + metadata.enrich(name, activity) + activity_id = make_activity_id(activity) if (dd / "activities" / f"{activity_id}.json").exists(): results.append({"name": name, "ok": False, "error": "duplicate"}) @@ -573,18 +607,25 @@ async def upload_activity( kept = True results.append({"name": name, "ok": True, "id": activity_id}) any_added = True - except Exception as exc: + except Exception: results.append({"name": name, "ok": False, "error": "Processing failed"}) finally: if not kept: staged.unlink(missing_ok=True) - if any_added: + # Retroactively update sidecars for existing activities matched by strava_id + csv_updates = 0 + if metadata is not None: + from bincio.extract.strava_csv import apply_csv_to_data_dir + csv_updates = apply_csv_to_data_dir(dd, metadata) + + if any_added or csv_updates: merge_all(dd) - _trigger_rebuild(user.handle) + if any_added: + _trigger_rebuild(user.handle) added = [r for r in results if r["ok"]] - return JSONResponse({"ok": True, "added": len(added), "results": results}) + return JSONResponse({"ok": True, "added": len(added), "csv_updates": csv_updates, "results": results}) @app.post("/api/upload/strava-zip") diff --git a/scripts/bulk_private.py b/scripts/bulk_private.py new file mode 100644 index 0000000..7a33941 --- /dev/null +++ b/scripts/bulk_private.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Bulk-set activities matching a title pattern to private by writing sidecar files. + +Usage: + uv run python scripts/bulk_private.py --data-dir /var/bincio/data/brut --match "morning walk" "afternoon walk" + + --dry-run Print what would be changed without writing anything. + --handle Subdirectory name (if data-dir is the root, not the user dir). +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + + +def parse_sidecar(path: Path) -> tuple[dict, str]: + text = path.read_text(encoding="utf-8") + if text.startswith("---"): + parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE) + if len(parts) >= 3: + fm = yaml.safe_load(parts[1]) or {} + return fm, parts[2].strip() + return {}, text.strip() + + +def write_sidecar(path: Path, fm: dict, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = "---\n" + yaml.dump(fm, allow_unicode=True, default_flow_style=False) + "---\n" + if body: + content += "\n" + body + "\n" + path.write_text(content, encoding="utf-8") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--data-dir", required=True, help="User data directory (e.g. /var/bincio/data/brut)") + ap.add_argument("--handle", default=None, help="Handle subdir if data-dir is the instance root") + ap.add_argument("--match", nargs="+", required=True, help="Title patterns to match (case-insensitive substring)") + ap.add_argument("--dry-run", action="store_true", help="Print changes without writing") + args = ap.parse_args() + + data_dir = Path(args.data_dir) + if args.handle: + data_dir = data_dir / args.handle + + index_path = data_dir / "index.json" + if not index_path.exists(): + sys.exit(f"ERROR: index.json not found at {index_path}") + + index = json.loads(index_path.read_text(encoding="utf-8")) + activities = index.get("activities", []) + + patterns = [p.lower() for p in args.match] + + matched = [ + a for a in activities + if any(pat in (a.get("title") or "").lower() for pat in patterns) + ] + + if not matched: + print("No activities matched.") + return + + print(f"Found {len(matched)} matching activities:") + edits_dir = data_dir / "edits" + changed = 0 + + for act in matched: + aid = act["id"] + title = act.get("title", "(no title)") + date = act.get("started_at", "")[:10] + sidecar_path = edits_dir / f"{aid}.md" + + # Load existing sidecar if present + if sidecar_path.exists(): + fm, body = parse_sidecar(sidecar_path) + else: + fm, body = {}, "" + + if fm.get("private") is True: + print(f" [already private] {date} {title}") + continue + + print(f" {'[DRY RUN] ' if args.dry_run else ''}→ private {date} {title}") + if not args.dry_run: + fm["private"] = True + write_sidecar(sidecar_path, fm, body) + changed += 1 + + if args.dry_run: + print("\nDry run — nothing written. Re-run without --dry-run to apply.") + else: + print(f"\n{changed} sidecar(s) written.") + if changed: + print("Running merge_all …") + from bincio.render.merge import merge_all + n = merge_all(data_dir) + print(f"merge_all done ({n} sidecar(s) applied).") + + +if __name__ == "__main__": + main() diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index b531a45..a44c785 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -275,8 +275,8 @@ try { id="upload-drop" class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors" > -
Drop FIT, GPX, or TCX files
or click to browse
- +
Drop FIT, GPX, TCX, or activities.csv
or click to browse
+
+ +
+

Navigation

+

Hide items from the top nav bar. Affects only your view.

+
+ + + +
+ +
+ + +
+

Strava API credentials

+

Loading…

+
+
+ + +
+
+ + +
+ +
+ + +
+
+
+

Danger zone

@@ -342,8 +394,128 @@ import Base from '../../layouts/Base.astro'; document.getElementById('del-activities-btn')?.addEventListener('click', () => openConfirm('activities')); document.getElementById('del-account-btn')?.addEventListener('click', () => openConfirm('account')); + // ── Navigation prefs ───────────────────────────────────────────────────────── + + const NAV_PREF_KEYS: Record = { + 'nav-hide-feed': 'nav_hide_feed', + 'nav-hide-community': 'nav_hide_community', + 'nav-hide-about': 'nav_hide_about', + }; + + async function loadNavPrefs() { + try { + const r = await fetch('/api/me/prefs', { credentials: 'include' }); + if (!r.ok) return; + const prefs = await r.json(); + for (const [elId, key] of Object.entries(NAV_PREF_KEYS)) { + const el = document.getElementById(elId) as HTMLInputElement | null; + if (el) el.checked = prefs[key] === 'true'; + } + } catch {} + } + + async function saveNavPref(key: string, value: boolean) { + const statusEl = document.getElementById('nav-prefs-status')!; + try { + const r = await fetch('/api/me/prefs', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: String(value) }), + }); + if (r.ok) { + setStatus(statusEl, 'Saved.', true); + setTimeout(() => statusEl.classList.add('hidden'), 2000); + } else { + const d = await r.json(); + setStatus(statusEl, d.detail ?? 'Failed', false); + } + } catch { + setStatus(statusEl, 'Could not reach server', false); + } + } + + for (const [elId, key] of Object.entries(NAV_PREF_KEYS)) { + document.getElementById(elId)?.addEventListener('change', (e) => { + saveNavPref(key, (e.target as HTMLInputElement).checked); + }); + } + + // ── Strava credentials ──────────────────────────────────────────────────────── + + async function loadStravaCreds() { + const desc = document.getElementById('strava-creds-desc')!; + try { + const r = await fetch('/api/me/strava-credentials', { credentials: 'include' }); + if (!r.ok) { desc.textContent = 'Not available.'; return; } + const d = await r.json(); + if (d.has_user_creds) { + desc.textContent = `Using your own credentials (Client ID: ${d.client_id}).`; + (document.getElementById('strava-client-id') as HTMLInputElement).value = d.client_id ?? ''; + } else if (d.instance_configured) { + desc.textContent = 'Using instance-level credentials. Enter your own below to override.'; + } else { + desc.textContent = 'Strava is not configured on this instance. You can set your own API credentials below.'; + } + } catch { + desc.textContent = 'Could not load Strava settings.'; + } + } + + document.getElementById('strava-creds-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const statusEl = document.getElementById('strava-creds-status')!; + const clientId = (document.getElementById('strava-client-id') as HTMLInputElement).value.trim(); + const clientSecret = (document.getElementById('strava-client-secret') as HTMLInputElement).value.trim(); + if (!clientId) { + setStatus(statusEl, 'Client ID is required.', false); + return; + } + try { + const body: Record = { client_id: clientId }; + if (clientSecret) body.client_secret = clientSecret; + const r = await fetch('/api/me/strava-credentials', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (r.ok) { + setStatus(statusEl, 'Saved.', true); + (document.getElementById('strava-client-secret') as HTMLInputElement).value = ''; + loadStravaCreds(); + } else { + setStatus(statusEl, d.detail ?? 'Failed', false); + } + } catch { + setStatus(statusEl, 'Could not reach server', false); + } + }); + + document.getElementById('strava-creds-clear')?.addEventListener('click', async () => { + const statusEl = document.getElementById('strava-creds-status')!; + if (!confirm('Remove your custom Strava credentials and fall back to instance credentials?')) return; + try { + const r = await fetch('/api/me/strava-credentials', { method: 'DELETE', credentials: 'include' }); + if (r.ok) { + setStatus(statusEl, 'Cleared — using instance credentials.', true); + (document.getElementById('strava-client-id') as HTMLInputElement).value = ''; + (document.getElementById('strava-client-secret') as HTMLInputElement).value = ''; + loadStravaCreds(); + } else { + const d = await r.json(); + setStatus(statusEl, d.detail ?? 'Failed', false); + } + } catch { + setStatus(statusEl, 'Could not reach server', false); + } + }); + // ── Init ───────────────────────────────────────────────────────────────────── loadMe(); loadStorage(); + loadNavPrefs(); + loadStravaCreds(); From 7142ac8f2e98bc3b3d6fc42f0619ba3ab251abaf Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 15 Apr 2026 20:42:31 +0200 Subject: [PATCH 090/124] settings: split danger zone into delete originals / delete all activities Move "Delete original files" out of the Storage card and into the Danger zone as a first, less-destructive step (simple confirm, no password needed). "Delete all activity data" and "Delete account" follow below it, both still password-gated. Descriptions clarify exactly what each action does and does not remove. --- site/src/pages/settings/index.astro | 35 +++++++++++++---------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/site/src/pages/settings/index.astro b/site/src/pages/settings/index.astro index 9443846..7f039b9 100644 --- a/site/src/pages/settings/index.astro +++ b/site/src/pages/settings/index.astro @@ -31,17 +31,6 @@ import Base from '../../layouts/Base.astro'; - -
@@ -141,10 +130,21 @@ import Base from '../../layouts/Base.astro';

Danger zone

- +
-

Reset activity data

-

Wipes all extracted activities, edits, and photos. Your account is kept. Cannot be undone.

+

Delete original files

+

Removes the raw source files kept for reprocessing (originals/). Your extracted activities, edits, and photos are not affected.

+ + +
+ + +
+

Delete all activity data

+

Wipes all extracted activities, edits, and photos. Your account and original files are kept. Cannot be undone.

{/if} diff --git a/site/src/lib/dataloader.ts b/site/src/lib/dataloader.ts index a19dc87..032c8b8 100644 --- a/site/src/lib/dataloader.ts +++ b/site/src/lib/dataloader.ts @@ -55,6 +55,22 @@ function emptyIndex(): BASIndex { }; } +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function isYearShardUrl(url: string): boolean { + return /(?:^|\/)index-\d{4}\.json$/.test(url); +} + +function rewriteActivityUrls(a: ActivitySummary, shardBase: string): ActivitySummary { + return { + ...a, + detail_url: a.detail_url && !a.detail_url.startsWith('http') + ? `${shardBase}${a.detail_url}` : a.detail_url, + track_url: a.track_url && !a.track_url.startsWith('http') + ? `${shardBase}${a.track_url}` : a.track_url, + }; +} + // ── Public API ──────────────────────────────────────────────────────────────── /** @@ -87,14 +103,8 @@ async function resolveShards( // Rewrite relative detail_url / track_url to be absolute so they can be // fetched correctly regardless of where the root index lives. return activities.map(a => ({ - ...a, + ...rewriteActivityUrls(a, shardBase), ...(shard.handle ? { handle: shard.handle } : {}), - detail_url: a.detail_url && !a.detail_url.startsWith('http') - ? `${shardBase}${a.detail_url}` - : a.detail_url, - track_url: a.track_url && !a.track_url.startsWith('http') - ? `${shardBase}${a.track_url}` - : a.track_url, })); }), ); @@ -150,6 +160,97 @@ export async function loadIndex(baseUrl: string, indexUrl?: string): Promise { + indexUrl = indexUrl ?? `${baseUrl}data/index.json`; + + const [serverResult, localResult] = await Promise.allSettled([ + fetchJSON(indexUrl), + listLocalActivities(), + ]); + + const server = serverResult.status === 'fulfilled' ? serverResult.value : null; + const local = localResult.status === 'fulfilled' ? localResult.value : []; + + if (!server && local.length === 0) return { index: emptyIndex(), pendingShards: [] }; + + const base = indexUrl.substring(0, indexUrl.lastIndexOf('/') + 1); + const allShards = server?.shards ?? []; + + const yearShards = allShards.filter(s => isYearShardUrl(s.url)); + const otherShards = allShards.filter(s => !isYearShardUrl(s.url)); + + // ── Year-sharded index (single-user or profile page) ─────────────────────── + // Load only the first (most-recent) year shard; return the rest as pending. + let yearFirstActivities: ActivitySummary[] = []; + let pendingShards: string[] = []; + + if (yearShards.length > 0) { + const sorted = [...yearShards].sort((a, b) => b.url.localeCompare(a.url)); + const firstUrl = sorted[0].url.startsWith('http') ? sorted[0].url : `${base}${sorted[0].url}`; + const shardBase = firstUrl.substring(0, firstUrl.lastIndexOf('/') + 1); + try { + const first = await fetchJSON(firstUrl); + yearFirstActivities = (first.activities ?? []).map(a => rewriteActivityUrls(a, shardBase)); + } catch (e) { + console.error('[bincio] first year shard failed:', sorted[0].url, e); + } + pendingShards = sorted.slice(1).map(s => + s.url.startsWith('http') ? s.url : `${base}${s.url}`, + ); + } + + // ── Non-year shards (multi-user manifest) — loaded eagerly as before ─────── + let otherActivities: ActivitySummary[] = []; + if (otherShards.length > 0) { + const otherIndex: BASIndex = { ...(server ?? emptyIndex()), shards: otherShards }; + otherActivities = await resolveShards(otherIndex, indexUrl); + } + + // ── Own activities (legacy flat index with no shards) ────────────────────── + const ownActivities = allShards.length === 0 ? (server?.activities ?? []) : []; + + // Merge: server + local (local overrides server for same id) + const serverActivities = [...ownActivities, ...otherActivities, ...yearFirstActivities]; + const merged = new Map(); + for (const a of serverActivities) merged.set(a.id, a); + for (const a of local as ActivitySummary[]) merged.set(a.id, a); + + return { + index: { + ...(server ?? emptyIndex()), + activities: [...merged.values()].sort( + (a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''), + ), + }, + pendingShards, + }; +} + +/** + * Fetch activities from a single year shard URL (absolute). + * Used by ActivityFeed to lazily load older years when "Load more" is clicked. + */ +export async function loadShardActivities(shardUrl: string): Promise { + try { + const data = await fetchJSON(shardUrl); + const base = shardUrl.substring(0, shardUrl.lastIndexOf('/') + 1); + return (data.activities ?? []).map(a => rewriteActivityUrls(a, base)); + } catch { + return []; + } +} + /** * Load a single activity detail, checking IndexedDB first so locally-converted * activities are available offline. diff --git a/tests/test_merge.py b/tests/test_merge.py index e3dbd4c..ff286dc 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -9,6 +9,18 @@ import pytest from bincio.render.merge import apply_sidecar, merge_all, merge_one, parse_sidecar +def _load_merged_activities(merged_dir: Path) -> dict: + """Load all activities from year-sharded merged index. Returns id→dict map.""" + root = json.loads((merged_dir / "index.json").read_text()) + all_acts = list(root.get("activities", [])) + for shard in root.get("shards", []): + shard_path = merged_dir / shard["url"] + if shard_path.exists(): + sub = json.loads(shard_path.read_text()) + all_acts.extend(sub.get("activities", [])) + return {a["id"]: a for a in all_acts} + + # ── parse_sidecar ───────────────────────────────────────────────────────────── @@ -176,8 +188,7 @@ def test_merge_all_private_filtered_from_index(data_dir): (edits / "2024-01-01T080000Z-morning-ride.md").write_text("---\nprivate: true\n---\n") merge_all(data_dir) - index = json.loads((data_dir / "_merged" / "index.json").read_text()) - activities = {a["id"]: a for a in index["activities"]} + activities = _load_merged_activities(data_dir / "_merged") # unlisted activities are kept in the index; filtering is client-side assert "2024-01-01T080000Z-morning-ride" in activities assert activities["2024-01-01T080000Z-morning-ride"]["privacy"] == "unlisted" @@ -191,8 +202,11 @@ def test_merge_all_highlight_sorts_first(data_dir): (edits / "2024-01-01T080000Z-morning-ride.md").write_text("---\nhighlight: true\n---\n") merge_all(data_dir) - index = json.loads((data_dir / "_merged" / "index.json").read_text()) - ids = [a["id"] for a in index["activities"]] + # Highlighted activity must be first within its year shard + merged_dir = data_dir / "_merged" + root = json.loads((merged_dir / "index.json").read_text()) + shard_path = merged_dir / root["shards"][0]["url"] + ids = [a["id"] for a in json.loads(shard_path.read_text())["activities"]] assert ids[0] == "2024-01-01T080000Z-morning-ride" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3e0e87d..1c52036 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -83,10 +83,15 @@ class TestPipeline: merge_all(data_root / "brut") for handle in ("dave", "brut"): - merged = json.loads((data_root / handle / "_merged" / "index.json").read_text()) - assert len(merged["activities"]) >= 8, ( - f"Expected ≥8 merged activities for {handle}" - ) + merged_dir = data_root / handle / "_merged" + root = json.loads((merged_dir / "index.json").read_text()) + # Root index now has year shards; collect all activities across them + all_acts: list = list(root.get("activities", [])) + for shard in root.get("shards", []): + sp = merged_dir / shard["url"] + if sp.exists(): + all_acts.extend(json.loads(sp.read_text()).get("activities", [])) + assert len(all_acts) >= 8, f"Expected ≥8 merged activities for {handle}" def test_root_manifest(self, data_root): from bincio.render.cli import _user_dirs, _write_root_manifest From 8575a7015bc017825c9aabd16145f44ed8c14a2b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 19 Apr 2026 22:31:20 +0200 Subject: [PATCH 103/124] fix: delete activity removes it from index.json; detail page uses lazy load delete_activity now updates data_dir/index.json so merge_all no longer re-adds the summary for a deleted activity, preventing the broken "Activity not found" state after deletion. ActivityDetailLoader switches from loadIndex (all year shards) to loadIndexPaged (first year shard only) + direct file fallback, so opening an activity detail page no longer downloads the entire history. --- bincio/serve/server.py | 11 +++ .../components/ActivityDetailLoader.svelte | 95 ++++++++++++------- 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index e486139..adf5a01 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -1288,6 +1288,17 @@ async def delete_activity( if images_dir.exists(): shutil.rmtree(images_dir) + # Remove from the extract-level flat index so merge_all doesn't re-add + # the summary even though the detail file is gone. + index_path = dd / "index.json" + if index_path.exists(): + try: + idx = json.loads(index_path.read_text(encoding="utf-8")) + idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id] + index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False)) + except Exception: + pass # corrupt index — merge_all will clean up on next run + # Remove from dedup cache so the file can be re-uploaded if needed cache_path = dd / ".bincio_cache.json" if cache_path.exists(): diff --git a/site/src/components/ActivityDetailLoader.svelte b/site/src/components/ActivityDetailLoader.svelte index 1831c1f..91cd3f9 100644 --- a/site/src/components/ActivityDetailLoader.svelte +++ b/site/src/components/ActivityDetailLoader.svelte @@ -1,6 +1,6 @@