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
+
+
+
+ Keep original file on server
+ Lets you reprocess if the format changes. See the About page for details.
+
+
@@ -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
+
+
+
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
+
+
+
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
+
+
+
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
+
+
+
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 = {
+
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
+ Feedback
>
)}
@@ -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.
+
+
+
+
+
Thanks!
+
Your feedback has been received.
+
+
+
+
+
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}
+
drawerOpen = true}
+ class="px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors"
+ >Create profile
+ {/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}
diff --git a/site/src/pages/u/[handle]/stats/index.astro b/site/src/pages/u/[handle]/stats/index.astro
index b655e94..745b6e2 100644
--- a/site/src/pages/u/[handle]/stats/index.astro
+++ b/site/src/pages/u/[handle]/stats/index.astro
@@ -18,7 +18,7 @@ const base = import.meta.env.BASE_URL;
const indexUrl = `${base}data/${handle}/_merged/index.json`;
---
-
+
@{handle}
Feed
From 3b8bc159c55d58703c294d37ebe11dcb55c2fbfc Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Fri, 10 Apr 2026 22:01:44 +0200
Subject: [PATCH 032/124] upload strava zip
---
bincio/extract/ingest.py | 5 +-
bincio/extract/models.py | 1 +
bincio/extract/strava_api.py | 5 ++
bincio/extract/strava_zip.py | 147 +++++++++++++++++++++++++++++++++++
bincio/serve/server.py | 49 ++++++++++++
docs/deployment/vps.md | 3 +-
site/src/layouts/Base.astro | 105 +++++++++++++++++++++++++
tests/test_server_imports.py | 1 +
8 files changed, 313 insertions(+), 3 deletions(-)
create mode 100644 bincio/extract/strava_zip.py
diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py
index 24daf4e..de44618 100644
--- a/bincio/extract/ingest.py
+++ b/bincio/extract/ingest.py
@@ -46,8 +46,9 @@ def ingest_parsed(
raise FileExistsError(f"Activity already exists: {activity_id}")
metrics = compute(parsed)
- write_activity(parsed, metrics, data_dir, privacy=privacy, rdp_epsilon=rdp_epsilon)
- summary = build_summary(parsed, metrics, activity_id, privacy)
+ effective_privacy = parsed.privacy if parsed.privacy is not None else privacy
+ write_activity(parsed, metrics, data_dir, privacy=effective_privacy, rdp_epsilon=rdp_epsilon)
+ summary = build_summary(parsed, metrics, activity_id, effective_privacy)
index_path = data_dir / "index.json"
if index_path.exists():
diff --git a/bincio/extract/models.py b/bincio/extract/models.py
index 253bb38..b5821b0 100644
--- a/bincio/extract/models.py
+++ b/bincio/extract/models.py
@@ -55,4 +55,5 @@ class ParsedActivity:
description: Optional[str] = None
gear: Optional[str] = None
strava_id: Optional[str] = None
+ privacy: Optional[str] = None # "public", "private", or None (caller decides)
laps: list[LapData] = field(default_factory=list)
diff --git a/bincio/extract/strava_api.py b/bincio/extract/strava_api.py
index 348fbd7..c60ecd4 100644
--- a/bincio/extract/strava_api.py
+++ b/bincio/extract/strava_api.py
@@ -201,6 +201,10 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
source = f"strava:{meta['id']}"
source_hash = "sha256:" + hashlib.sha256(source.encode()).hexdigest()
+ # Map Strava visibility to BAS privacy: only_me → private, everything else → public
+ visibility = meta.get("visibility") or ""
+ is_private = meta.get("private", False) or visibility == "only_me"
+
return ParsedActivity(
points=points,
sport=normalise_sport(meta.get("sport_type") or meta.get("type") or ""),
@@ -210,4 +214,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
title=meta.get("name") or None,
description=meta.get("description") or None,
strava_id=str(meta["id"]),
+ privacy="private" if is_private else "public",
)
diff --git a/bincio/extract/strava_zip.py b/bincio/extract/strava_zip.py
new file mode 100644
index 0000000..65777e7
--- /dev/null
+++ b/bincio/extract/strava_zip.py
@@ -0,0 +1,147 @@
+"""Process a Strava bulk export ZIP file into a BAS data store.
+
+The ZIP (downloaded from strava.com/athlete/delete_your_account or the data export
+page) contains:
+ activities/ ← GPX, FIT, TCX files (plain or .gz variants)
+ activities.csv ← metadata (title, description, gear, strava ID)
+ bikes.csv / shoes.csv / … (ignored here)
+
+Processing strategy: stream one activity at a time to keep disk usage low.
+The ZIP is never fully extracted; each activity file is extracted to a temp path,
+parsed, ingested, then immediately deleted. The ZIP itself is deleted once done.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import tempfile
+import zipfile
+from pathlib import Path
+from typing import Generator, Optional
+
+
+# File extensions recognised as activity files inside the ZIP.
+_ACTIVITY_SUFFIXES = {".gpx", ".fit", ".tcx", ".gpx.gz", ".fit.gz", ".tcx.gz"}
+
+
+def _is_activity_file(name: str) -> bool:
+ n = name.lower()
+ return any(n.endswith(s) for s in _ACTIVITY_SUFFIXES)
+
+
+def strava_zip_iter(
+ zip_path: Path,
+ data_dir: Path,
+ originals_dir: Optional[Path] = None,
+) -> Generator[dict, None, None]:
+ """Process a Strava export ZIP, yielding SSE-style progress dicts.
+
+ Event types:
+ {"type": "validating"}
+ {"type": "error", "message": str}
+ {"type": "extracting_csv"}
+ {"type": "progress", "n": int, "total": int, "name": str, "status": "imported"|"skipped"|"error"}
+ {"type": "done", "imported": int, "skipped": int, "error_count": int, "errors": list[str]}
+
+ The zip_path file is deleted after processing regardless of success/failure.
+ """
+ from bincio.extract.ingest import ingest_parsed
+ from bincio.extract.parsers.factory import parse_file
+ from bincio.extract.strava_csv import StravaMetadata
+
+ yield {"type": "validating"}
+
+ try:
+ zf = zipfile.ZipFile(zip_path, "r")
+ except zipfile.BadZipFile as e:
+ zip_path.unlink(missing_ok=True)
+ yield {"type": "error", "message": f"Not a valid ZIP file: {e}"}
+ return
+
+ try:
+ names = zf.namelist()
+
+ # Validate structure
+ has_csv = "activities.csv" in names
+ activity_files = [n for n in names if n.startswith("activities/") and _is_activity_file(n)]
+
+ if not has_csv:
+ yield {"type": "error", "message": "This doesn't look like a Strava export: activities.csv not found"}
+ return
+ if not activity_files:
+ yield {"type": "error", "message": "No activity files found in activities/ folder"}
+ return
+
+ # Load activities.csv into memory (it's small — ~700 KB)
+ yield {"type": "extracting_csv"}
+ csv_bytes = zf.read("activities.csv")
+ with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp_csv:
+ tmp_csv.write(csv_bytes)
+ tmp_csv_path = Path(tmp_csv.name)
+ try:
+ metadata = StravaMetadata(tmp_csv_path)
+ finally:
+ tmp_csv_path.unlink(missing_ok=True)
+
+ total = len(activity_files)
+ imported = 0
+ skipped = 0
+ errors: list[str] = []
+
+ for n, zip_entry in enumerate(activity_files, 1):
+ entry_name = Path(zip_entry).name # e.g. "12345678.fit.gz"
+ # Title from metadata if available; fall back to filename stem
+ meta_row = metadata.lookup(entry_name)
+ display_name = (meta_row or {}).get("Activity Name", "").strip() or entry_name
+
+ # Determine activity ID from entry to check for duplicates before extracting
+ # (can't do this without parsing, so we extract to a small temp file)
+ suffix = "".join(Path(entry_name).suffixes) # ".fit.gz" or ".gpx" etc.
+ tmp_path: Optional[Path] = None
+ try:
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=data_dir) as tmp:
+ tmp.write(zf.read(zip_entry))
+ tmp_path = Path(tmp.name)
+
+ parsed = parse_file(tmp_path)
+
+ # Enrich with CSV metadata
+ if meta_row:
+ if not parsed.title and meta_row.get("Activity Name"):
+ parsed.title = meta_row["Activity Name"].strip()
+ if not parsed.description and meta_row.get("Activity Description"):
+ parsed.description = meta_row["Activity Description"].strip()
+ if not parsed.strava_id and meta_row.get("Activity ID"):
+ parsed.strava_id = meta_row["Activity ID"].strip()
+
+ if originals_dir is not None:
+ import shutil
+ orig_dest = originals_dir / entry_name
+ shutil.copy2(tmp_path, orig_dest)
+
+ ingest_parsed(parsed, data_dir, privacy="public")
+ imported += 1
+ yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "imported"}
+
+ except FileExistsError:
+ skipped += 1
+ yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "skipped"}
+ except Exception as exc:
+ errors.append(f"{entry_name}: {type(exc).__name__}")
+ yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "error"}
+ finally:
+ if tmp_path is not None:
+ tmp_path.unlink(missing_ok=True)
+
+ finally:
+ zf.close()
+ zip_path.unlink(missing_ok=True)
+
+ yield {
+ "type": "done",
+ "imported": imported,
+ "skipped": skipped,
+ "error_count": len(errors),
+ "errors": errors[:5],
+ }
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 83051fa..7c1f297 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -587,6 +587,55 @@ async def upload_activity(
return JSONResponse({"ok": True, "added": len(added), "results": results})
+@app.post("/api/upload/strava-zip")
+async def upload_strava_zip(
+ file: UploadFile = File(...),
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> 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.
+ """
+ user = _require_user(bincio_session)
+ if not file.filename or not file.filename.lower().endswith(".zip"):
+ raise HTTPException(400, "Please upload a .zip file")
+
+ dd = _get_data_dir() / user.handle
+ 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":
+ if any_imported:
+ merge_all(dd)
+ _trigger_rebuild(user.handle)
+ 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"},
+ )
+
+
# ── Feedback ──────────────────────────────────────────────────────────────────
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md
index 1a5f9b5..8842d08 100644
--- a/docs/deployment/vps.md
+++ b/docs/deployment/vps.md
@@ -239,7 +239,8 @@ server {
root /var/www/bincio;
index index.html;
- client_max_body_size 512M; # bulk activity uploads
+ client_max_body_size 2G; # Strava export ZIPs can exceed 1 GB
+ client_body_timeout 300s; # allow slow uploads without nginx dropping the connection
# API → bincio serve
location /api/ {
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index da29b81..85494f7 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -248,6 +248,16 @@ try {
Checking…
+
+ 📦
+
+
Strava export ZIP
+
Import your full Strava archive
+
+
@@ -307,6 +317,22 @@ try {
+
+
+
+
← Back
+
+ ⚠ The ZIP will be processed and immediately deleted from the server — originals are not kept. Make sure you keep your own copy.
+
+
+
Drop your Strava export .zip or click to browse
+
+
+
+
)}
@@ -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
+
+
+ Mark all imported activities as private
+ (Strava export doesn't include privacy settings)
+
@@ -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
+
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);
+ const parts = [];
+ if (d.added > 0) parts.push(`${d.added} added`);
+ if (d.csv_updates > 0) parts.push(`${d.csv_updates} updated from CSV`);
+ if (dupes) parts.push(`${dupes} duplicate${dupes > 1 ? 's' : ''}`);
+ if (errors) parts.push(`${errors} failed`);
+ if (parts.length === 0) parts.push('nothing to add');
+ fileStatus.textContent = parts.join(', ');
+ const anyGood = d.added > 0 || d.csv_updates > 0;
+ fileStatus.style.color = anyGood ? '#4ade80' : '#a1a1aa';
+ if (anyGood) setTimeout(() => { window.location.reload(); }, 1200);
else drop.style.pointerEvents = '';
} catch (e) {
fileStatus.textContent = 'Error: ' + e.message;
From 82830222ba8ead3f199596979d28f581398349ff Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 08:33:21 +0200
Subject: [PATCH 039/124] =?UTF-8?q?=20=20For=20users=20uploading:=20=20=20?=
=?UTF-8?q?-=20POST=20/api/upload=20now=20returns=20text/event-stream=20in?=
=?UTF-8?q?stead=20of=20JSON=20=20=20-=20Per-file=20progress=20events=20st?=
=?UTF-8?q?ream=20back=20as=20each=20file=20is=20processed:=20=E2=86=93=20?=
=?UTF-8?q?3/47=20(6%)=20=E2=80=94=20morning=5Fride.fit=20=20=20-=20Final?=
=?UTF-8?q?=20done=20event=20shows=20the=20summary:=20"12=20added,=2035=20?=
=?UTF-8?q?duplicates"=20=20=20-=20The=20Vite=20proxy=20is=20configured=20?=
=?UTF-8?q?to=20stream=20this=20properly=20(no=20buffering)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
For the admin:
- New GET /api/admin/jobs endpoint (admin-only) returns the list of active upload jobs, each with
user, started_at, total, done, current (filename being processed)
- A pulsing amber badge appears in the nav bar for admins when any user has an active upload running
— it shows e.g. "2 uploads running" with a tooltip listing each user's progress (@alice: 12/50
files)
- Polls every 5 seconds, disappears automatically when all jobs finish
---
bincio/edit/server.py | 132 ++++++++++++------------
bincio/serve/server.py | 200 ++++++++++++++++++++++++------------
site/astro.config.mjs | 7 +-
site/src/layouts/Base.astro | 116 +++++++++++++++++----
4 files changed, 302 insertions(+), 153 deletions(-)
diff --git a/bincio/edit/server.py b/bincio/edit/server.py
index f70faee..cc3acd6 100644
--- a/bincio/edit/server.py
+++ b/bincio/edit/server.py
@@ -540,11 +540,11 @@ def _file_suffix(name: str) -> str:
async def upload_activity(
files: list[UploadFile] = File(...),
store_original: bool = Form(False),
-) -> JSONResponse:
- """Accept FIT/GPX/TCX files and/or activities.csv, extract, update index, re-merge.
+) -> StreamingResponse:
+ """Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
activities.csv (Strava export format) can be included in the batch to:
- - Enrich activity files being uploaded in the same batch (matched by filename)
+ - Enrich activity files in the same batch (matched by filename)
- Retroactively update sidecars for existing activities (matched by strava_id)
"""
from bincio.extract.ingest import ingest_parsed
@@ -556,90 +556,96 @@ async def upload_activity(
staging = dd / "_uploads"
staging.mkdir(exist_ok=True)
- _MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
+ # Read all files into memory now (async), then process synchronously in the generator
+ csv_bytes_list: list[bytes] = []
+ activity_items: list[tuple[str, bytes]] = []
- # 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)
+ fname = Path(f.filename or "").name
+ raw = await f.read()
+ if fname.lower().endswith(".csv"):
+ csv_bytes_list.append(raw)
else:
- activity_files.append(f)
+ activity_items.append((fname, raw))
# Build metadata from the first CSV found (activities.csv from Strava export)
metadata = None
- if csv_files:
+ if csv_bytes_list:
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.write(csv_bytes_list[0])
tmp_path = Path(tmp.name)
try:
metadata = StravaMetadata(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
- results = []
- any_added = False
+ total_files = len(activity_items)
- 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
+ def event_stream():
+ added = 0
+ duplicates = 0
+ errors = 0
+ any_added = False
- 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"})
+ for n, (name, contents) in enumerate(activity_items, 1):
+ suffix = _file_suffix(name)
+ if suffix not in _SUPPORTED_SUFFIXES:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
continue
- ingest_parsed(activity, dd, privacy="public")
+ if len(contents) > _MAX_UPLOAD_BYTES:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
+ continue
- if store_original:
- originals_dir = dd / "originals"
- originals_dir.mkdir(exist_ok=True)
- staged.rename(originals_dir / name)
- kept = True
+ staged = staging / name
+ staged.write_bytes(contents)
+ kept = False
+ try:
+ activity = parse_file(staged)
+ if metadata is not None:
+ metadata.enrich(name, activity)
+ activity_id = make_activity_id(activity)
+ if (dd / "activities" / f"{activity_id}.json").exists():
+ duplicates += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
+ 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
+ added += 1
+ any_added = True
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'imported'})}\n\n"
+ except Exception:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error'})}\n\n"
+ finally:
+ if not kept:
+ staged.unlink(missing_ok=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)
+ 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 csv_updates:
+ yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
- # 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)
- if any_added or csv_updates:
- merge_all(dd)
+ yield f"data: {json.dumps({'type': 'done', 'added': added, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
- added = [r for r in results if r["ok"]]
- return JSONResponse({"ok": True, "added": len(added), "csv_updates": csv_updates, "results": results})
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
@app.post("/api/import-bas")
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index ef69c02..52057cf 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -13,7 +13,9 @@ import re
import secrets
import shutil
import subprocess
+import threading
import time
+import uuid
from pathlib import Path
from typing import Any, Optional
@@ -42,6 +44,40 @@ from bincio.serve.db import (
use_invite,
)
+# ── Active job tracker ───────────────────────────────────────────────────────
+# Tracks in-progress upload/processing jobs so admins can see what's running.
+# Jobs are added when a streaming upload starts and removed when it finishes.
+
+_jobs_lock = threading.Lock()
+_active_jobs: dict[str, dict] = {}
+
+
+def _job_start(user_handle: str, total_files: int) -> str:
+ job_id = uuid.uuid4().hex[:8]
+ with _jobs_lock:
+ _active_jobs[job_id] = {
+ "id": job_id,
+ "user": user_handle,
+ "started_at": int(time.time()),
+ "total": total_files,
+ "done": 0,
+ "current": "",
+ }
+ return job_id
+
+
+def _job_update(job_id: str, done: int, current: str) -> None:
+ with _jobs_lock:
+ if job_id in _active_jobs:
+ _active_jobs[job_id]["done"] = done
+ _active_jobs[job_id]["current"] = current
+
+
+def _job_finish(job_id: str) -> None:
+ with _jobs_lock:
+ _active_jobs.pop(job_id, None)
+
+
# ── Globals (set by CLI before uvicorn starts) ────────────────────────────────
data_dir: Path | None = None
@@ -346,6 +382,15 @@ async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> J
} for u in users])
+@app.get("/api/admin/jobs")
+async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Return currently active upload/processing jobs. Admin only."""
+ _require_admin(bincio_session)
+ with _jobs_lock:
+ jobs = list(_active_jobs.values())
+ return JSONResponse(jobs)
+
+
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
def _user_data_dir(handle: str) -> Path:
@@ -529,12 +574,17 @@ async def upload_activity(
files: list[UploadFile] = File(...),
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.
+) -> StreamingResponse:
+ """Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
activities.csv (Strava export format) can be included in the batch to:
- - Enrich activity files being uploaded in the same batch (matched by filename)
+ - Enrich activity files in the same batch (matched by filename)
- Retroactively update sidecars for existing activities (matched by strava_id)
+
+ SSE events:
+ {"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"duplicate"|"error"}
+ {"type": "csv", "updates": N} -- only when CSV was included
+ {"type": "done", "added": N, "csv_updates": N, "duplicates": N, "errors": N}
"""
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.factory import parse_file
@@ -546,86 +596,106 @@ 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)
+ # Read all files into memory now (async), then process synchronously in the generator
+ csv_bytes_list: list[bytes] = []
+ activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes)
- # Build metadata from the first CSV found (activities.csv from Strava export)
+ for f in files:
+ fname = Path(f.filename or "").name
+ raw = await f.read()
+ if fname.lower().endswith(".csv"):
+ csv_bytes_list.append(raw)
+ else:
+ activity_items.append((fname, raw))
+
+ # Build metadata from the first CSV
metadata = None
- if csv_files:
+ if csv_bytes_list:
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.write(csv_bytes_list[0])
tmp_path = Path(tmp.name)
try:
metadata = StravaMetadata(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
- results = []
- any_added = False
+ total_files = len(activity_items)
+ job_id = _job_start(user.handle, total_files) if total_files > 0 else None
- 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 '{suffix}'"})
- continue
+ def event_stream():
+ added = 0
+ duplicates = 0
+ errors = 0
+ any_added = False
- contents = await file.read()
- if len(contents) > 50 * 1024 * 1024:
- results.append({"name": name, "ok": False, "error": "File too large (max 50 MB)"})
- continue
+ for n, (name, contents) in enumerate(activity_items, 1):
+ if job_id:
+ _job_update(job_id, n - 1, name)
- 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"})
+ suffix = _file_suffix(name)
+ if suffix not in _SUPPORTED_SUFFIXES:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
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 len(contents) > 50 * 1024 * 1024:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
+ continue
- if any_added or csv_updates:
- merge_all(dd)
- if any_added:
- _trigger_rebuild(user.handle)
+ staged = staging / name
+ staged.write_bytes(contents)
+ kept = False
+ try:
+ activity = parse_file(staged)
+ if metadata is not None:
+ metadata.enrich(name, activity)
+ activity_id = make_activity_id(activity)
+ if (dd / "activities" / f"{activity_id}.json").exists():
+ duplicates += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
+ 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
+ added += 1
+ any_added = True
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'imported'})}\n\n"
+ except Exception:
+ errors += 1
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error'})}\n\n"
+ finally:
+ if not kept:
+ staged.unlink(missing_ok=True)
- added = [r for r in results if r["ok"]]
- return JSONResponse({"ok": True, "added": len(added), "csv_updates": csv_updates, "results": results})
+ # Retroactively apply CSV metadata to existing activities
+ 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 csv_updates:
+ yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
+
+ if any_added or csv_updates:
+ merge_all(dd)
+ if any_added:
+ _trigger_rebuild(user.handle)
+
+ yield f"data: {json.dumps({'type': 'done', 'added': added, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
+
+ if job_id:
+ _job_finish(job_id)
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
@app.post("/api/upload/strava-zip")
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index 5622396..6bd511f 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -25,10 +25,11 @@ 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.
+ // Both /api/upload and /api/upload/strava-zip return SSE streams in response
+ // to POST requests. Vite's default proxy buffers the full body before forwarding,
+ // which breaks streaming and causes EPIPE on long uploads.
// selfHandleResponse + manual pipe sends chunks as they arrive.
- '/api/upload/strava-zip': {
+ '/api/upload': {
target: serveTarget,
changeOrigin: true,
selfHandleResponse: true,
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index a44c785..cccea1b 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -190,6 +190,14 @@ try {
{!isPublicPage && (
<>
+
+
+
+ `@${j.user}: ${j.done}/${j.total} files`
+ ).join(' · ');
+ badge.title = summary;
+ badge.textContent = `${jobs.length} upload${jobs.length > 1 ? 's' : ''} running`;
+ badge.style.display = '';
+ }
+ } catch (_) {}
+ }
+ pollJobs();
+ setInterval(pollJobs, 5000);
+ }
} catch (_) {}
})();
@@ -510,38 +543,77 @@ try {
});
input.addEventListener('change', () => { if (input.files?.length) doUpload(input.files); });
- async function doUpload(files) {
+ 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.textContent = `Uploading…`;
fileStatus.style.color = 'var(--text-4)';
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());
- const d = await r.json();
- const dupes = d.results.filter(r => r.error === 'duplicate').length;
- const errors = d.results.filter(r => !r.ok && r.error !== 'duplicate').length;
- const parts = [];
- if (d.added > 0) parts.push(`${d.added} added`);
- if (d.csv_updates > 0) parts.push(`${d.csv_updates} updated from CSV`);
- if (dupes) parts.push(`${dupes} duplicate${dupes > 1 ? 's' : ''}`);
- if (errors) parts.push(`${errors} failed`);
- if (parts.length === 0) parts.push('nothing to add');
- fileStatus.textContent = parts.join(', ');
- const anyGood = d.added > 0 || d.csv_updates > 0;
- fileStatus.style.color = anyGood ? '#4ade80' : '#a1a1aa';
- if (anyGood) setTimeout(() => { window.location.reload(); }, 1200);
- else drop.style.pointerEvents = '';
- } catch (e) {
- fileStatus.textContent = 'Error: ' + e.message;
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', `${editUrl}/api/upload`);
+ xhr.withCredentials = true;
+ xhr.setRequestHeader('Accept', 'text/event-stream');
+
+ let buf = '';
+ let added = 0, dupes = 0, errors = 0, csvUpdates = 0;
+
+ xhr.onprogress = () => {
+ 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 === 'progress') {
+ const pct = Math.round((ev.n / ev.total) * 100);
+ const icon = ev.status === 'imported' ? '↓' : ev.status === 'duplicate' ? '·' : '✗';
+ fileStatus.textContent = `${icon} ${ev.n}/${ev.total} (${pct}%) — ${ev.name}`;
+ if (ev.status === 'imported') added++;
+ else if (ev.status === 'duplicate') dupes++;
+ else errors++;
+ } else if (ev.type === 'csv') {
+ csvUpdates = ev.updates;
+ } else if (ev.type === 'done') {
+ added = ev.added; dupes = ev.duplicates; errors = ev.errors; csvUpdates = ev.csv_updates;
+ const parts = [];
+ if (added > 0) parts.push(`${added} added`);
+ if (csvUpdates > 0) parts.push(`${csvUpdates} updated from CSV`);
+ if (dupes) parts.push(`${dupes} duplicate${dupes > 1 ? 's' : ''}`);
+ if (errors) parts.push(`${errors} failed`);
+ if (parts.length === 0) parts.push('nothing to add');
+ fileStatus.textContent = parts.join(', ');
+ const anyGood = added > 0 || csvUpdates > 0;
+ fileStatus.style.color = anyGood ? '#4ade80' : '#a1a1aa';
+ if (anyGood) setTimeout(() => window.location.reload(), 1200);
+ else drop.style.pointerEvents = '';
+ input.value = '';
+ }
+ } catch (_) {}
+ }
+ };
+
+ xhr.onload = () => {
+ if (xhr.status !== 200) {
+ fileStatus.textContent = `Upload failed (${xhr.status}).`;
+ fileStatus.style.color = '#f87171';
+ drop.style.pointerEvents = '';
+ input.value = '';
+ }
+ };
+
+ xhr.onerror = () => {
+ fileStatus.textContent = 'Upload failed — check your connection.';
fileStatus.style.color = '#f87171';
drop.style.pointerEvents = '';
input.value = '';
- }
+ };
+
+ xhr.send(fd);
}
// ── Strava ────────────────────────────────────────────────────────────
From ef5b06c5b3ef79b1038e34eac386b5503a266ee2 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 08:47:27 +0200
Subject: [PATCH 040/124] trigger rebuild after activities upload
---
bincio/serve/cli.py | 8 ++++-
bincio/serve/server.py | 69 ++++++++++++++++++++++++++++++++++--------
docs/deployment/vps.md | 1 +
3 files changed, 64 insertions(+), 14 deletions(-)
diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py
index ae07a44..9825cc0 100644
--- a/bincio/serve/cli.py
+++ b/bincio/serve/cli.py
@@ -20,9 +20,11 @@ console = Console()
@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.")
+@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
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], public_url: Optional[str]) -> None:
+ max_users: Optional[int], public_url: Optional[str],
+ webroot: Optional[str]) -> None:
"""Start the bincio multi-user application server.
Handles auth, user management, and write operations.
@@ -54,6 +56,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
srv.strava_client_secret = strava_client_secret
if public_url:
srv.public_url = public_url
+ if webroot and site_dir:
+ srv.webroot = Path(webroot).expanduser().resolve()
db = open_db(dd)
current_limit = get_setting(db, "max_users")
@@ -63,6 +67,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
console.print(f" Data: [cyan]{dd}[/cyan]")
if srv.site_dir:
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
+ if srv.webroot:
+ console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
if current_limit and int(current_limit) > 0:
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 52057cf..7126b9c 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -82,6 +82,7 @@ def _job_finish(job_id: str) -> None:
data_dir: Path | None = None
site_dir: Path | None = None # for post-write rebuild trigger
+webroot: Path | None = None # nginx webroot — when set, trigger full rebuild + rsync
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
@@ -201,25 +202,67 @@ def _unique_image_name(directory: Path, filename: str) -> str:
# ── Post-write rebuild ────────────────────────────────────────────────────────
+# Serialises concurrent rebuilds — only one full build runs at a time.
+# A second upload that arrives while a build is in progress will queue and
+# run after the first finishes, picking up all data written in between.
+_rebuild_lock = threading.Lock()
+
+
def _trigger_rebuild(handle: str) -> None:
- """Asynchronously re-merge one user's shard and rewrite the root manifest."""
+ """Asynchronously re-merge and optionally rebuild + rsync the site.
+
+ - Without --webroot: fast path — merges sidecars + rewrites root manifest
+ (~1 s). New activity pages require the nginx try_files fallback to work.
+ - With --webroot: full Astro build + rsync to the nginx webroot (~30–60 s,
+ serialised). New activity pages are immediately accessible.
+ """
if site_dir is None:
return
if not _VALID_HANDLE.match(handle):
return # safety: never pass untrusted strings to subprocess
+
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,
- "--no-build"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- except Exception:
- pass # rebuild failure must never 500 the calling endpoint
+ _data_dir = str(data_dir)
+ _site_dir = str(site_dir)
+ _webroot = str(webroot) if webroot else None
+ _handle = handle
+
+ def _run() -> None:
+ try:
+ if _webroot is None:
+ # Fast: only update data, skip Astro build
+ subprocess.run(
+ [uv, "run", "bincio", "render",
+ "--data-dir", _data_dir,
+ "--site-dir", _site_dir,
+ "--handle", _handle,
+ "--no-build"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ else:
+ # Full build + rsync — serialised so concurrent uploads don't race
+ with _rebuild_lock:
+ result = subprocess.run(
+ [uv, "run", "bincio", "render",
+ "--data-dir", _data_dir,
+ "--site-dir", _site_dir,
+ "--handle", _handle],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ if result.returncode == 0:
+ # Rsync built site to nginx webroot
+ subprocess.run(
+ ["rsync", "-a", "--delete",
+ f"{_site_dir}/dist/", _webroot + "/"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except Exception:
+ pass # rebuild failure must never affect the calling request
+
+ threading.Thread(target=_run, daemon=True).start()
# ── Auth endpoints ────────────────────────────────────────────────────────────
diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md
index 8842d08..21a9538 100644
--- a/docs/deployment/vps.md
+++ b/docs/deployment/vps.md
@@ -113,6 +113,7 @@ WorkingDirectory=/opt/bincio
ExecStart=/root/.local/bin/uv run bincio serve \
--data-dir /var/bincio/data \
--site-dir /opt/bincio/site \
+ --webroot /var/www/bincio \
--host 127.0.0.1 \
--port 4041 \
--public-url https://yourdomain.com
From 18551f9f360f2aa4972898afceb2ca75bdab61fc Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 09:01:34 +0200
Subject: [PATCH 041/124] document where feedback is saved
---
CLAUDE.md | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index f8249e0..f721bea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -248,6 +248,31 @@ When a user uploads a FIT/GPX/TCX file the server may keep the source in `{user_
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`.
+## Feedback storage
+
+User feedback submitted via `/feedback/` is stored as flat files under the instance data root (NOT inside a user's own data dir):
+
+```
+{data_root}/
+ _feedback/
+ {handle}.json ← append-only list of submissions for that user
+ {handle}/
+ {timestamp}_{token}_{filename} ← attached images
+```
+
+Each entry in `{handle}.json`:
+```json
+{ "id": "1712345678_ab12cd34", "handle": "brut", "submitted_at": "...", "text": "...", "images": ["..."] }
+```
+
+To read feedback on the VPS:
+```bash
+cat /var/bincio/data/_feedback/brut.json | python3 -m json.tool
+ls /var/bincio/data/_feedback/brut/ # attached images
+```
+
+There is no admin UI for feedback — it is intentionally read via SSH/shell only.
+
## 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:
From 8219db7bfa502837d4283b77b6260273eb569691 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 09:01:47 +0200
Subject: [PATCH 042/124] shorten bincioactivity to ba on mobile
---
site/src/layouts/Base.astro | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index cccea1b..7300983 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -156,9 +156,10 @@ try {
style="border-color: var(--border)"
>
-
+
- BincioActivity
+ BincioActivity
+ BA
{!isPublicPage && (
From 087ef1b7769e67ccec1ec1022db4e0f1df98da47 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 09:02:58 +0200
Subject: [PATCH 043/124] fix power chart ranges
---
site/src/components/MmpChart.svelte | 2 ++
1 file changed, 2 insertions(+)
diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte
index 4a9efcb..cf7b3b4 100644
--- a/site/src/components/MmpChart.svelte
+++ b/site/src/components/MmpChart.svelte
@@ -101,10 +101,12 @@
label: 'Duration',
tickFormat: (d: number) => formatDuration(d),
grid: true,
+ domain: [data[0]?.d ?? 1, Math.max(7200, ...data.map(d => d.d))],
},
y: {
label: 'Avg power (W)',
grid: true,
+ domain: [0, null],
},
color: {
domain: selectedKeys,
From ff8981b3a109838e19c84bd3796dd12006f676ca Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 10:54:13 +0200
Subject: [PATCH 044/124] fix power curve y-axis: use zero:true instead of
domain:[0,null]
---
site/src/components/MmpChart.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte
index cf7b3b4..40e3744 100644
--- a/site/src/components/MmpChart.svelte
+++ b/site/src/components/MmpChart.svelte
@@ -106,7 +106,7 @@
y: {
label: 'Avg power (W)',
grid: true,
- domain: [0, null],
+ zero: true,
},
color: {
domain: selectedKeys,
From f4008c0f514e2068e157ade7528a88b090b16c8a Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 10:56:16 +0200
Subject: [PATCH 045/124] pin @observablehq/plot to 0.6.17
---
site/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/site/package.json b/site/package.json
index 264bc1a..8853b0c 100644
--- a/site/package.json
+++ b/site/package.json
@@ -22,7 +22,7 @@
"@capacitor/filesystem": "^8.1.2",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/ios": "^8.3.0",
- "@observablehq/plot": "^0.6.0",
+ "@observablehq/plot": "0.6.17",
"@types/dompurify": "^3.0.5",
"astro": "^5.0.0",
"dompurify": "^3.3.3",
From 5de99671271290c63d065c9777e5e9ac22dfc9ed Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 14:12:54 +0200
Subject: [PATCH 046/124] fix upload 500: add missing _file_suffix to serve
server; fix iOS file picker accept types
---
bincio/serve/server.py | 8 ++++++++
site/src/layouts/Base.astro | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 7126b9c..e4d190e 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -612,6 +612,14 @@ async def save_athlete(
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
+def _file_suffix(name: str) -> str:
+ """Return the effective suffix, including .gz double-extension."""
+ p = Path(name.lower())
+ if p.suffix == ".gz":
+ return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz"
+ return p.suffix
+
+
@app.post("/api/upload")
async def upload_activity(
files: list[UploadFile] = File(...),
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index 7300983..b20cd7f 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -285,7 +285,7 @@ try {
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, TCX, or activities.csv or click to browse
-
+
Date: Sat, 11 Apr 2026 14:17:42 +0200
Subject: [PATCH 047/124] trigger site rebuild after new user registration so
profile pages exist immediately
---
bincio/serve/server.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index e4d190e..94e7525 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -380,6 +380,9 @@ async def register(request: Request) -> JSONResponse:
from bincio.render.cli import _write_root_manifest
_write_root_manifest(dd)
+ # Rebuild site so the new user's profile pages exist immediately
+ _trigger_rebuild(handle)
+
token = create_session(_get_db(), handle)
resp = JSONResponse({"ok": True, "handle": handle})
_set_session_cookie(resp, token)
From 0b569b727cdc8ccee57834677c30993370054233 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 14:17:42 +0200
Subject: [PATCH 048/124] trigger site rebuild after new user registration so
profile pages exist immediately
---
bincio/serve/server.py | 3 +++
docs/deployment/vps.md | 5 +++--
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index e4d190e..94e7525 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -380,6 +380,9 @@ async def register(request: Request) -> JSONResponse:
from bincio.render.cli import _write_root_manifest
_write_root_manifest(dd)
+ # Rebuild site so the new user's profile pages exist immediately
+ _trigger_rebuild(handle)
+
token = create_session(_get_db(), handle)
resp = JSONResponse({"ok": True, "handle": handle})
_set_session_cookie(resp, token)
diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md
index 21a9538..a973dfe 100644
--- a/docs/deployment/vps.md
+++ b/docs/deployment/vps.md
@@ -264,9 +264,10 @@ server {
try_files $uri $uri/ /activity/index.html;
}
- # Per-user profile pages: same fallback for new users.
+ # Per-user profile pages: fall back to the home page while the background
+ # rebuild (triggered automatically on registration) completes.
location /u/ {
- try_files $uri $uri/ =404;
+ try_files $uri $uri/ /index.html;
}
# Static files
From 705e00f8527e8a800030699eec457edc447c5006 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 14:39:19 +0200
Subject: [PATCH 049/124] adding community tab
---
site/src/components/CommunityView.svelte | 348 +++++++++++++++++++++++
site/src/layouts/Base.astro | 8 +-
site/src/pages/about/ca/index.astro | 30 +-
site/src/pages/about/es/index.astro | 30 +-
site/src/pages/about/index.astro | 30 +-
site/src/pages/about/it/index.astro | 30 +-
site/src/pages/community/index.astro | 7 +
7 files changed, 443 insertions(+), 40 deletions(-)
create mode 100644 site/src/components/CommunityView.svelte
create mode 100644 site/src/pages/community/index.astro
diff --git a/site/src/components/CommunityView.svelte b/site/src/components/CommunityView.svelte
new file mode 100644
index 0000000..1fffc4d
--- /dev/null
+++ b/site/src/components/CommunityView.svelte
@@ -0,0 +1,348 @@
+
+
+
+
+
+
Community
+
What everyone's been up to — together.
+
+
+ {#if loading}
+
Loading…
+ {:else if error}
+
{error}
+ {:else}
+
+
+
+ {#each PERIODS as p}
+ period = p.key}
+ class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors"
+ class:bg-blue-500={period === p.key}
+ class:border-blue-500={period === p.key}
+ class:text-white={period === p.key}
+ class:border-zinc-700={period !== p.key}
+ class:text-zinc-400={period !== p.key}
+ class:hover:text-white={period !== p.key}
+ >{p.label}
+ {/each}
+
+
+
+ {#if totals.users > 0}
+
+ {#each [
+ { label: 'Activities', value: totals.count.toLocaleString() },
+ { label: 'Distance', value: formatDistance(totals.distance_m) },
+ { label: 'Elevation', value: `${Math.round(totals.elevation_m / 1000).toLocaleString()} km↑` },
+ { label: 'Time', value: formatDuration(totals.duration_s) },
+ ] as item}
+
+
{item.value}
+
{item.label}
+
+ {/each}
+
+ {/if}
+
+
+ {#if totals.users === 0}
+
No public activities in this period yet.
+ {:else}
+
+
+
+ {#if rowsCount.length}
+
+
Out there
+
Who kept showing up
+ {#each rowsCount as u, i}
+
+
+
{u.count} {u.count === 1 ? 'activity' : 'activities'}
+
+ {/each}
+
+ {/if}
+
+
+ {#if rowsDistance.length}
+
+
Going far
+
Who covered the most ground
+ {#each rowsDistance as u, i}
+
+
+
{formatDistance(u.distance_m)}
+
+ {/each}
+
+ {/if}
+
+
+ {#if rowsElevation.length}
+
+
Reaching new heights
+
Who climbed the most
+ {#each rowsElevation as u, i}
+
+
+
{Math.round(u.elevation_m).toLocaleString()} m
+
+ {/each}
+
+ {/if}
+
+
+ {#if rowsDuration.length}
+
+
Hours on the move
+
Who invested the most time
+ {#each rowsDuration as u, i}
+
+
+
{formatDuration(u.duration_s)}
+
+ {/each}
+
+ {/if}
+
+
+ {#if rowsSports.length}
+
+
Explorer
+
Who tried the most sports
+ {#each rowsSports as u, i}
+
+
+
+ {#each u.sports as s}{sportIcon(s)}{/each}
+
+
+ {/each}
+
+ {/if}
+
+
+ {#if rowsStreak.length}
+
+
Never stopped
+
Longest streak of consecutive days (all time)
+ {#each rowsStreak as u, i}
+
+ {/each}
+
+ {/if}
+
+
+ {/if}
+ {/if}
+
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index b20cd7f..d224300 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -176,6 +176,9 @@ try {
Stats
Athlete
+ {!singleHandle && (
+ Community
+ )}
{mobileApp && (
Record
)}
@@ -183,7 +186,6 @@ try {
Convert
)}
About
- Feedback
)}
@@ -423,11 +425,9 @@ try {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
});
- // Show logout button and feedback link
+ // Show logout button
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/about/ca/index.astro b/site/src/pages/about/ca/index.astro
index a4f928e..742644f 100644
--- a/site/src/pages/about/ca/index.astro
+++ b/site/src/pages/about/ca/index.astro
@@ -23,15 +23,25 @@ const labels = {
Seguiment d'activitats de codi obert i allotjament propi
-
- ☕ Dona suport a Ko-fi
-
+
@@ -146,6 +156,8 @@ const labels = {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
+ const feedbackBtn = document.getElementById('feedback-btn');
+ if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
diff --git a/site/src/pages/about/es/index.astro b/site/src/pages/about/es/index.astro
index 610b38a..01bb4b7 100644
--- a/site/src/pages/about/es/index.astro
+++ b/site/src/pages/about/es/index.astro
@@ -23,15 +23,25 @@ const labels = {
Seguimiento de actividades open-source y autoalojado
-
- ☕ Apoya en Ko-fi
-
+
@@ -145,6 +155,8 @@ const labels = {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
+ const feedbackBtn = document.getElementById('feedback-btn');
+ if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro
index 5340971..2b43f9d 100644
--- a/site/src/pages/about/index.astro
+++ b/site/src/pages/about/index.astro
@@ -24,15 +24,25 @@ const labels = {
Open-source, self-hosted activity tracking
-
- ☕ Support on Ko-fi
-
+
@@ -142,6 +152,8 @@ const labels = {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return; // not logged in — hide community section
+ const feedbackBtn = document.getElementById('feedback-btn');
+ if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
diff --git a/site/src/pages/about/it/index.astro b/site/src/pages/about/it/index.astro
index eae5ce4..1dce80f 100644
--- a/site/src/pages/about/it/index.astro
+++ b/site/src/pages/about/it/index.astro
@@ -23,15 +23,25 @@ const labels = {
Tracciamento attività open-source e self-hosted
-
- ☕ Supporta su Ko-fi
-
+
@@ -145,6 +155,8 @@ const labels = {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
+ const feedbackBtn = document.getElementById('feedback-btn');
+ if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
diff --git a/site/src/pages/community/index.astro b/site/src/pages/community/index.astro
new file mode 100644
index 0000000..c7a2d72
--- /dev/null
+++ b/site/src/pages/community/index.astro
@@ -0,0 +1,7 @@
+---
+import Base from '../../layouts/Base.astro';
+import CommunityView from '../../components/CommunityView.svelte';
+---
+
+
+
From 78581d5487c6a8f66913376fa74340fb496a07f0 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sat, 11 Apr 2026 14:50:54 +0200
Subject: [PATCH 050/124] redesign community page as sortable table
---
site/src/components/CommunityView.svelte | 244 +++++++++--------------
1 file changed, 94 insertions(+), 150 deletions(-)
diff --git a/site/src/components/CommunityView.svelte b/site/src/components/CommunityView.svelte
index 1fffc4d..4351c81 100644
--- a/site/src/components/CommunityView.svelte
+++ b/site/src/components/CommunityView.svelte
@@ -6,6 +6,7 @@
export let base: string = '/';
type Period = 'week' | 'month' | 'year' | 'all';
+ type SortKey = 'display_name' | 'count' | 'distance_m' | 'elevation_m' | 'duration_s' | 'sports' | 'streak';
interface UserRaw {
handle: string;
@@ -33,6 +34,8 @@
}
let period: Period = 'month';
+ let sortKey: SortKey = 'distance_m';
+ let sortAsc = false;
let users: UserRaw[] = [];
let stats: UserStat[] = [];
let totals: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
@@ -46,7 +49,6 @@
if (p === 'all') return new Date(0);
if (p === 'year') return new Date(now.getFullYear(), 0, 1);
if (p === 'month') return new Date(now.getFullYear(), now.getMonth(), 1);
- // week: Monday
const d = new Date(now);
const day = d.getDay();
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
@@ -54,7 +56,6 @@
return d;
}
- /** Max consecutive active days across all public activities (all-time). */
function maxStreak(activities: ActivitySummary[]): number {
if (!activities.length) return 0;
const days = [...new Set(activities.map(a => a.started_at.slice(0, 10)))].sort();
@@ -75,7 +76,6 @@
for (const u of rawUsers) {
const pub = u.activities.filter(a => a.privacy !== 'private');
const filtered = pub.filter(a => new Date(a.started_at) >= start);
- if (filtered.length === 0) continue;
const stat: UserStat = {
handle: u.handle,
@@ -117,24 +117,15 @@
try {
const rootUrl = `${base}data/index.json`;
const root: BASIndex = await fetch(rootUrl).then(r => r.json());
-
const userShards = (root.shards ?? []).filter(s => s.handle);
- if (userShards.length === 0) {
- error = 'No community members found.';
- return;
- }
+ if (userShards.length === 0) { error = 'No community members found.'; return; }
const results = await Promise.allSettled(
userShards.map(async shard => {
- const shardBase = `${base}data/`;
- const url = shard.url.startsWith('http') ? shard.url : `${shardBase}${shard.url}`;
+ const url = shard.url.startsWith('http') ? shard.url : `${base}data/${shard.url}`;
const shardIndex: BASIndex = await fetch(url).then(r => r.json());
const activities = await fetchShard(url);
- return {
- handle: shard.handle!,
- display_name: shardIndex.owner?.display_name ?? shard.handle!,
- activities,
- } as UserRaw;
+ return { handle: shard.handle!, display_name: shardIndex.owner?.display_name ?? shard.handle!, activities } as UserRaw;
})
);
@@ -147,37 +138,30 @@
}
}
- $: if (users.length) {
- ({ stats, totals } = computeStats(users, period));
+ $: if (users.length) ({ stats, totals } = computeStats(users, period));
+
+ $: sorted = [...stats].sort((a, b) => {
+ let av: number | string, bv: number | string;
+ if (sortKey === 'display_name') { av = a.display_name.toLowerCase(); bv = b.display_name.toLowerCase(); }
+ else if (sortKey === 'sports') { av = a.sports.length; bv = b.sports.length; }
+ else { av = a[sortKey] as number; bv = b[sortKey] as number; }
+ if (av < bv) return sortAsc ? -1 : 1;
+ if (av > bv) return sortAsc ? 1 : -1;
+ return 0;
+ });
+
+ function setSort(key: SortKey) {
+ if (sortKey === key) sortAsc = !sortAsc;
+ else { sortKey = key; sortAsc = false; }
+ }
+
+ function chevron(key: SortKey) {
+ if (sortKey !== key) return '';
+ return sortAsc ? ' ↑' : ' ↓';
}
onMount(loadData);
- // ── Ranking helpers ───────────────────────────────────────────────────────
-
- function top3(key: K, min = 0): UserStat[] {
- return [...stats]
- .filter(u => (u[key] as number) > min)
- .sort((a, b) => (b[key] as number) - (a[key] as number))
- .slice(0, 3);
- }
-
- function top3Sports(): UserStat[] {
- return [...stats]
- .filter(u => u.sports.length > 1)
- .sort((a, b) => b.sports.length - a.sports.length)
- .slice(0, 3);
- }
-
- const MEDAL = ['🥇', '🥈', '🥉'];
-
- $: rowsCount = top3('count', 0);
- $: rowsDistance = top3('distance_m', 0);
- $: rowsElevation = top3('elevation_m', 0);
- $: rowsDuration = top3('duration_s', 0);
- $: rowsSports = top3Sports();
- $: rowsStreak = [...stats].filter(u => u.streak > 1).sort((a, b) => b.streak - a.streak).slice(0, 3);
-
const PERIODS: { key: Period; label: string }[] = [
{ key: 'week', label: 'This week' },
{ key: 'month', label: 'This month' },
@@ -186,8 +170,7 @@
];
-
-
+
Community
What everyone's been up to — together.
@@ -215,7 +198,7 @@
{/each}
-
+
{#if totals.users > 0}
{#each [
@@ -232,117 +215,78 @@
{/if}
-
+
{#if totals.users === 0}
No public activities in this period yet.
{:else}
-
-
-
- {#if rowsCount.length}
-
-
Out there
-
Who kept showing up
- {#each rowsCount as u, i}
-
-
-
{u.count} {u.count === 1 ? 'activity' : 'activities'}
-
- {/each}
-
- {/if}
-
-
- {#if rowsDistance.length}
-
-
Going far
-
Who covered the most ground
- {#each rowsDistance as u, i}
-
-
-
{formatDistance(u.distance_m)}
-
- {/each}
-
- {/if}
-
-
- {#if rowsElevation.length}
-
-
Reaching new heights
-
Who climbed the most
- {#each rowsElevation as u, i}
-
-
-
{Math.round(u.elevation_m).toLocaleString()} m
-
- {/each}
-
- {/if}
-
-
- {#if rowsDuration.length}
-
-
Hours on the move
-
Who invested the most time
- {#each rowsDuration as u, i}
-
-
-
{formatDuration(u.duration_s)}
-
- {/each}
-
- {/if}
-
-
- {#if rowsSports.length}
-
-
Explorer
-
Who tried the most sports
- {#each rowsSports as u, i}
-
-
-
+
+
+
+
+ #
+
+ setSort('display_name')} class="hover:text-white transition-colors">
+ Athlete{chevron('display_name')}
+
+
+
+ setSort('count')} class="hover:text-white transition-colors">
+ Activities{chevron('count')}
+
+
+
+ setSort('distance_m')} class="hover:text-white transition-colors">
+ Distance{chevron('distance_m')}
+
+
+
+ setSort('elevation_m')} class="hover:text-white transition-colors">
+ Elevation{chevron('elevation_m')}
+
+
+
+ setSort('duration_s')} class="hover:text-white transition-colors">
+ Time{chevron('duration_s')}
+
+
+
+ setSort('sports')} class="hover:text-white transition-colors">
+ Sports{chevron('sports')}
+
+
+
+ setSort('streak')} class="hover:text-white transition-colors">
+ Streak{chevron('streak')}
+
+
+
+
+
+ {#each sorted as u, i}
+
+ {i + 1}
+
+
+ {u.display_name}
+
+ @{u.handle}
+
+ {u.count}
+ {u.distance_m > 0 ? formatDistance(u.distance_m) : '—'}
+ {u.elevation_m > 0 ? `${Math.round(u.elevation_m).toLocaleString()} m` : '—'}
+ {u.duration_s > 0 ? formatDuration(u.duration_s) : '—'}
+
{#each u.sports as s}{sportIcon(s)}{/each}
-
-
+
+
+ {u.streak > 0 ? `${u.streak}d` : '—'}
+
+
{/each}
-
- {/if}
-
-
- {#if rowsStreak.length}
-
-
Never stopped
-
Longest streak of consecutive days (all time)
- {#each rowsStreak as u, i}
-
- {/each}
-
- {/if}
-
+
+
{/if}
+
{/if}
From e80231b44256f9e59129ffd966a277734394348b Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 14:55:33 +0200
Subject: [PATCH 051/124] fix strava sync rebuild: trigger before yielding done
event, not after
---
bincio/serve/server.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 94e7525..e8031ca 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -1003,9 +1003,9 @@ async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(defaul
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)
+ _trigger_rebuild(user.handle) # start before client closes connection
+ yield f"data: {json.dumps(event)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
From 6c431e8821d23906620e40fdcaaf3a230d2572fc Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 15:12:20 +0200
Subject: [PATCH 052/124] Here's what was built and why each decision was
made:
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Key at data_dir.parent/.garmin_key — nginx serves location /data/ { alias /var/bincio/data/; } so
anything inside that dir is reachable. The key lives one level up at /var/bincio/.garmin_key,
outside nginx's reach.
Two-layer storage — garmin_creds.json holds the encrypted email+password (needed for re-login when
tokens expire); garmin_session/ holds the garth OAuth tokens in plain JSON (short-lived, not the
user's actual password).
test_login() — called by the connect endpoint before saving anything, so credentials are only
persisted if they actually work.
get_client() — tries the session first (fast, no network), falls back to full re-login
transparently. The caller never needs to think about whether the session is fresh.
---
.gitignore | 1 +
bincio/extract/garmin_api.py | 225 ++++++++++++++++++++++++++++++
docs/garmin_connect_disclaimer.md | 75 ++++++++++
pyproject.toml | 4 +
4 files changed, 305 insertions(+)
create mode 100644 bincio/extract/garmin_api.py
create mode 100644 docs/garmin_connect_disclaimer.md
diff --git a/.gitignore b/.gitignore
index d98680e..b80d778 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ build/
htmlcov/
.coverage
.idea*
+feedback*
# uv
uv.lock
diff --git a/bincio/extract/garmin_api.py b/bincio/extract/garmin_api.py
new file mode 100644
index 0000000..0087389
--- /dev/null
+++ b/bincio/extract/garmin_api.py
@@ -0,0 +1,225 @@
+"""Garmin Connect credential storage and client factory.
+
+Credential storage layout
+─────────────────────────
+ {data_dir.parent}/.garmin_key ← Fernet key (outside nginx webroot, chmod 600)
+ {user_dir}/garmin_creds.json ← encrypted email + password
+ {user_dir}/garmin_session/ ← garth OAuth token directory (plain JSON, short-lived)
+
+Security model
+──────────────
+- The Fernet key lives one directory above the data root, which nginx does NOT serve.
+ For a standard VPS install: data_dir = /var/bincio/data/ → key at /var/bincio/.garmin_key.
+- Credentials are encrypted with that key before being written to disk.
+- The garth session directory holds OAuth tokens (not the user's password).
+ These expire independently and are refreshed automatically by the library.
+- If the session expires and re-login is needed, the stored credentials are decrypted
+ and used automatically — the user does not need to re-enter them.
+
+DISCLAIMER
+──────────
+This module uses the unofficial `garminconnect` library.
+See docs/garmin_connect_disclaimer.md before shipping this feature to users.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import stat
+from pathlib import Path
+from typing import Optional
+
+# ── Constants ─────────────────────────────────────────────────────────────────
+
+_CREDS_FILE = "garmin_creds.json"
+_SESSION_DIR = "garmin_session"
+_KEY_FILENAME = ".garmin_key"
+
+
+class GarminError(Exception):
+ pass
+
+
+# ── Encryption key management ─────────────────────────────────────────────────
+
+def _key_path(data_dir: Path) -> Path:
+ """Return the path to the Fernet key file (one level above data_dir)."""
+ return data_dir.parent / _KEY_FILENAME
+
+
+def _get_or_create_key(data_dir: Path) -> bytes:
+ """Load the Fernet key, creating and locking it down on first use."""
+ from cryptography.fernet import Fernet
+
+ kp = _key_path(data_dir)
+ if kp.exists():
+ return kp.read_bytes().strip()
+
+ key = Fernet.generate_key()
+ kp.parent.mkdir(parents=True, exist_ok=True)
+ kp.write_bytes(key)
+ kp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 — owner read/write only
+ return key
+
+
+def _fernet(data_dir: Path):
+ from cryptography.fernet import Fernet
+ return Fernet(_get_or_create_key(data_dir))
+
+
+# ── Credential encryption helpers ─────────────────────────────────────────────
+
+def _encrypt(data_dir: Path, value: str) -> str:
+ return _fernet(data_dir).encrypt(value.encode()).decode()
+
+
+def _decrypt(data_dir: Path, token: str) -> str:
+ try:
+ return _fernet(data_dir).decrypt(token.encode()).decode()
+ except Exception as exc:
+ raise GarminError("Failed to decrypt Garmin credentials — key may have changed") from exc
+
+
+# ── Credential CRUD ───────────────────────────────────────────────────────────
+
+def has_credentials(user_dir: Path) -> bool:
+ return (user_dir / _CREDS_FILE).exists()
+
+
+def save_credentials(data_dir: Path, user_dir: Path, email: str, password: str) -> None:
+ """Encrypt and persist the user's Garmin email + password."""
+ payload = {
+ "email": _encrypt(data_dir, email),
+ "password": _encrypt(data_dir, password),
+ }
+ creds_path = user_dir / _CREDS_FILE
+ creds_path.write_text(json.dumps(payload, indent=2))
+ creds_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
+
+
+def load_credentials(data_dir: Path, user_dir: Path) -> tuple[str, str]:
+ """Return (email, password) decrypted from disk."""
+ creds_path = user_dir / _CREDS_FILE
+ if not creds_path.exists():
+ raise GarminError("No Garmin credentials stored for this user")
+ try:
+ raw = json.loads(creds_path.read_text())
+ except Exception as exc:
+ raise GarminError("Garmin credentials file is corrupt") from exc
+ return _decrypt(data_dir, raw["email"]), _decrypt(data_dir, raw["password"])
+
+
+def delete_credentials(user_dir: Path) -> None:
+ """Remove stored credentials and session (disconnect)."""
+ creds_path = user_dir / _CREDS_FILE
+ if creds_path.exists():
+ creds_path.unlink()
+
+ session_dir = user_dir / _SESSION_DIR
+ if session_dir.exists():
+ import shutil
+ shutil.rmtree(session_dir)
+
+
+# ── Session management (garth OAuth tokens) ───────────────────────────────────
+
+def _session_dir(user_dir: Path) -> Path:
+ d = user_dir / _SESSION_DIR
+ d.mkdir(exist_ok=True)
+ return d
+
+
+def _save_session(user_dir: Path, client) -> None:
+ """Persist garth OAuth tokens so the next sync skips re-login."""
+ try:
+ client.garth.dump(str(_session_dir(user_dir)))
+ except Exception:
+ pass # session save is best-effort
+
+
+def _load_session(user_dir: Path, client) -> bool:
+ """Try to restore a saved garth session. Returns True on success."""
+ sd = user_dir / _SESSION_DIR
+ if not sd.exists():
+ return False
+ try:
+ client.garth.load(str(sd))
+ return True
+ except Exception:
+ return False
+
+
+# ── Client factory ────────────────────────────────────────────────────────────
+
+def get_client(data_dir: Path, user_dir: Path):
+ """Return a logged-in Garmin client.
+
+ Strategy:
+ 1. Try to resume a saved garth session (fast, no network round-trip).
+ 2. If that fails or the session has expired, re-login using the stored
+ (encrypted) credentials.
+ 3. Persist the refreshed session for next time.
+
+ Raises GarminError if credentials are missing or login fails.
+ """
+ try:
+ import garminconnect
+ except ImportError as exc:
+ raise GarminError(
+ "garminconnect is not installed. "
+ "Run: uv sync --extra garmin"
+ ) from exc
+
+ client = garminconnect.Garmin()
+
+ # Try cached session first
+ if _load_session(user_dir, client):
+ try:
+ client.garth.refresh_oauth2() # renew access token if needed
+ _save_session(user_dir, client) # persist refreshed token
+ return client
+ except Exception:
+ pass # session is dead — fall through to full re-login
+
+ # Full login with stored credentials
+ email, password = load_credentials(data_dir, user_dir)
+ try:
+ client = garminconnect.Garmin(email=email, password=password)
+ client.login()
+ except Exception as exc:
+ raise GarminError(f"Garmin login failed: {exc}") from exc
+
+ _save_session(user_dir, client)
+ return client
+
+
+def test_login(data_dir: Path, user_dir: Path, email: str, password: str) -> dict:
+ """Attempt a login with the supplied credentials (does not save them).
+
+ Returns a dict with display_name and full_name on success.
+ Raises GarminError on failure.
+ """
+ try:
+ import garminconnect
+ except ImportError as exc:
+ raise GarminError("garminconnect is not installed") from exc
+
+ try:
+ client = garminconnect.Garmin(email=email, password=password)
+ client.login()
+ except Exception as exc:
+ raise GarminError(f"Login failed: {exc}") from exc
+
+ try:
+ profile = client.get_profile_user_summary()
+ display = profile.get("displayName", email)
+ full = f"{profile.get('firstName', '')} {profile.get('lastName', '')}".strip()
+ except Exception:
+ display, full = email, ""
+
+ # Credentials are valid — save them and the session
+ save_credentials(data_dir, user_dir, email, password)
+ _save_session(user_dir, client)
+
+ return {"display_name": display, "full_name": full}
diff --git a/docs/garmin_connect_disclaimer.md b/docs/garmin_connect_disclaimer.md
new file mode 100644
index 0000000..8d88475
--- /dev/null
+++ b/docs/garmin_connect_disclaimer.md
@@ -0,0 +1,75 @@
+# Garmin Connect Sync — Disclaimer
+
+**This feature uses an unofficial, community-maintained library to access Garmin Connect.
+It is not affiliated with, endorsed by, or supported by Garmin Ltd. or its subsidiaries.**
+
+---
+
+## What this feature does
+
+When you enable Garmin Connect sync, BincioActivity will:
+
+1. Ask for your Garmin Connect **email address and password**
+2. Store those credentials on the server, encrypted at rest
+3. Use them to log in to Garmin Connect on your behalf and download your activity files (FIT format)
+4. Import those activities into your BincioActivity account
+
+---
+
+## What you need to know before enabling this
+
+### Your credentials are stored on the server
+
+Unlike Strava (which uses OAuth — you authorize without sharing your password),
+Garmin Connect has no official third-party API. This feature works by logging in
+as you, using your actual email and password.
+
+This means:
+
+- The server operator has technical access to your stored credentials
+- You are trusting both the software and the person running the server
+- Only enable this on a server you control or run by someone you fully trust
+
+### This uses an unofficial API
+
+Garmin does not provide a public developer API for activity data.
+This feature relies on a reverse-engineered interface that:
+
+- May break without notice when Garmin changes their systems
+- Is not covered by any Garmin service agreement or SLA
+- May violate Garmin Connect's Terms of Service
+
+BincioActivity takes no responsibility for account restrictions or bans
+that may result from using this feature.
+
+### Two-factor authentication (2FA)
+
+If your Garmin account has 2FA enabled, this feature may not work or may
+require additional steps. Garmin has changed their authentication flow
+several times; compatibility depends on the current state of the underlying library.
+
+### Rate limits
+
+Garmin does not publish API rate limits. Syncing too frequently or importing
+large volumes of activities may result in temporary or permanent IP blocks.
+BincioActivity applies conservative limits, but cannot guarantee uninterrupted access.
+
+---
+
+## How to revoke access
+
+BincioActivity does not hold an OAuth token that can be revoked from Garmin's settings.
+To stop BincioActivity from accessing your Garmin account:
+
+1. Delete your stored credentials from BincioActivity (Settings → Garmin Connect → Disconnect)
+2. **Change your Garmin Connect password** — this is the only way to guarantee that
+ no previously stored credentials can be used
+
+---
+
+## Recommendation
+
+If you have concerns about credential storage, consider the alternative:
+export your activities from Garmin Connect or Garmin Express as FIT files
+and upload them directly to BincioActivity. This requires no credentials
+and is always available.
diff --git a/pyproject.toml b/pyproject.toml
index 34004a5..2e1bb5b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,10 @@ serve = [
strava = [
"requests>=2.32",
]
+garmin = [
+ "garminconnect>=0.2",
+ "cryptography>=42.0",
+]
dev = [
"pytest>=9.0",
"pytest-cov>=5.0",
From f003fdd89f89339f451c7c4a13fbf35c9ea4acba Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 15:36:21 +0200
Subject: [PATCH 053/124] garmin sync first attempt
---
bincio/extract/garmin_sync.py | 196 +++++++++++++++++++++++++++++
bincio/serve/server.py | 108 ++++++++++++++++
docs/garmin_connect_disclaimer.md | 34 ++++++
site/src/layouts/Base.astro | 197 ++++++++++++++++++++++++++++++
4 files changed, 535 insertions(+)
create mode 100644 bincio/extract/garmin_sync.py
diff --git a/bincio/extract/garmin_sync.py b/bincio/extract/garmin_sync.py
new file mode 100644
index 0000000..395c886
--- /dev/null
+++ b/bincio/extract/garmin_sync.py
@@ -0,0 +1,196 @@
+"""Garmin Connect incremental sync — generator-based, mirrors strava_sync_iter.
+
+Sync state is stored in {user_dir}/garmin_sync.json:
+ {
+ "last_sync_at": "2026-04-12" ← date of last successful sync (YYYY-MM-DD)
+ }
+
+We query Garmin for all activities from (last_sync_at - 1 day) to today,
+then skip any that already exist (FileExistsError from ingest_parsed).
+The -1 day buffer catches activities that were saved to Garmin slightly
+after their recorded end time crosses midnight.
+
+Each yielded dict has a ``type`` key:
+ - ``"fetching"`` — about to contact Garmin
+ - ``"progress"`` — one activity processed; keys: n, total, name, status, garmin_id
+ - ``"done"`` — final summary; keys: imported, skipped, error_count, errors
+ - ``"error"`` — fatal error; key: message
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import zipfile
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Generator
+
+_SYNC_FILE = "garmin_sync.json"
+
+
+# ── Sync state helpers ────────────────────────────────────────────────────────
+
+def _load_sync_state(user_dir: Path) -> dict:
+ p = user_dir / _SYNC_FILE
+ if not p.exists():
+ return {}
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return {}
+
+
+def _save_sync_state(user_dir: Path, state: dict) -> None:
+ (user_dir / _SYNC_FILE).write_text(json.dumps(state, indent=2))
+
+
+# ── FIT extraction from ZIP ───────────────────────────────────────────────────
+
+def _extract_fit(zip_bytes: bytes) -> tuple[bytes, str]:
+ """Return (fit_bytes, filename) from a Garmin activity ZIP.
+
+ Garmin always packages the original FIT as the first .fit entry.
+ Raises ValueError if no FIT file is found.
+ """
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
+ fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
+ if not fit_names:
+ raise ValueError(f"No FIT file in archive. Contents: {zf.namelist()}")
+ name = fit_names[0]
+ return zf.read(name), name
+
+
+# ── Main generator ────────────────────────────────────────────────────────────
+
+def garmin_sync_iter(
+ data_dir: Path,
+ user_dir: Path,
+) -> Generator[dict, None, None]:
+ """Fetch new activities from Garmin Connect and ingest them.
+
+ Args:
+ data_dir: Root data directory (used for encryption key lookup).
+ user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
+ """
+ from bincio.extract.garmin_api import GarminError, get_client
+ from bincio.extract.ingest import ingest_parsed
+ from bincio.extract.parsers.fit import FitParser
+
+ # ── Login ──────────────────────────────────────────────────────────────────
+ try:
+ client = get_client(data_dir, user_dir)
+ except GarminError as exc:
+ yield {"type": "error", "message": str(exc)}
+ return
+
+ yield {"type": "fetching"}
+
+ # ── Determine date range ───────────────────────────────────────────────────
+ state = _load_sync_state(user_dir)
+ last = state.get("last_sync_at")
+
+ if last:
+ # Start one day before last sync to catch edge cases around midnight
+ start_dt = datetime.fromisoformat(last) - timedelta(days=1)
+ else:
+ # First sync: import everything Garmin has
+ start_dt = datetime(2000, 1, 1)
+
+ start_date = start_dt.strftime("%Y-%m-%d")
+ end_date = datetime.now().strftime("%Y-%m-%d")
+
+ # ── Fetch activity list ────────────────────────────────────────────────────
+ try:
+ activities = client.get_activities_by_date(
+ startdate=start_date,
+ enddate=end_date,
+ )
+ except Exception as exc:
+ yield {"type": "error", "message": f"Failed to fetch activity list: {exc}"}
+ return
+
+ total = len(activities)
+ imported = 0
+ skipped = 0
+ errors: list[str] = []
+ parser = FitParser()
+
+ # ── Process each activity ──────────────────────────────────────────────────
+ for n, meta in enumerate(activities, 1):
+ garmin_id = meta.get("activityId")
+ name = meta.get("activityName") or "Untitled"
+
+ try:
+ # Download original FIT (wrapped in a ZIP by Garmin)
+ try:
+ zip_bytes = client.download_activity(
+ garmin_id,
+ dl_fmt=client.ActivityDownloadFormat.ORIGINAL,
+ )
+ except Exception as exc:
+ raise RuntimeError(f"Download failed: {exc}") from exc
+
+ try:
+ fit_bytes, fit_name = _extract_fit(zip_bytes)
+ except Exception as exc:
+ raise RuntimeError(f"ZIP extraction failed: {exc}") from exc
+
+ # Parse FIT — pass a dummy Path so the parser has a filename for
+ # any format-detection logic; raw bytes are the actual data.
+ fake_path = Path(fit_name)
+ try:
+ parsed = parser.parse(fake_path, fit_bytes)
+ except Exception as exc:
+ raise RuntimeError(f"FIT parse error: {exc}") from exc
+
+ # Ingest — raises FileExistsError if already present (dedup)
+ ingest_parsed(parsed, user_dir)
+ imported += 1
+ yield {
+ "type": "progress",
+ "n": n, "total": total, "name": name,
+ "status": "imported",
+ "garmin_id": garmin_id,
+ }
+
+ except FileExistsError:
+ skipped += 1
+ yield {
+ "type": "progress",
+ "n": n, "total": total, "name": name,
+ "status": "skipped",
+ "garmin_id": garmin_id,
+ }
+
+ except Exception as exc:
+ errors.append(f"{garmin_id} ({name}): {type(exc).__name__}: {exc}")
+ yield {
+ "type": "progress",
+ "n": n, "total": total, "name": name,
+ "status": "error",
+ "garmin_id": garmin_id,
+ }
+
+ # ── Persist sync state ─────────────────────────────────────────────────────
+ state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ _save_sync_state(user_dir, state)
+
+ yield {
+ "type": "done",
+ "imported": imported,
+ "skipped": skipped,
+ "error_count": len(errors),
+ "errors": errors[:5],
+ }
+
+
+def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict:
+ """Blocking wrapper around garmin_sync_iter for non-SSE callers."""
+ result: dict = {}
+ for event in garmin_sync_iter(data_dir, user_dir):
+ if event["type"] == "done":
+ result = event
+ elif event["type"] == "error":
+ raise RuntimeError(event["message"])
+ return result
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index e8031ca..db95d01 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -1034,3 +1034,111 @@ async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)
raise HTTPException(502, str(e))
_trigger_rebuild(user.handle)
return JSONResponse(result)
+
+
+# ── Garmin Connect endpoints ──────────────────────────────────────────────────
+
+def _garmin_user_message(exc: Exception) -> str:
+ """Return a human-friendly error message for common Garmin login failures."""
+ msg = str(exc)
+ fallback = (
+ " In the meantime, you can export your activities from Garmin Connect "
+ "(garmin.com → Activities → Export) or Garmin Express as FIT files "
+ "and upload them directly."
+ )
+ if "429" in msg or "rate limit" in msg.lower():
+ return (
+ "Garmin is rate-limiting this server's IP address (HTTP 429). "
+ "Wait a few hours and try again." + fallback
+ )
+ if "403" in msg:
+ return (
+ "Cloudflare is blocking the login request (HTTP 403). "
+ "This is a known upstream issue — try again later or update garminconnect "
+ "(uv sync --extra garmin)." + fallback
+ )
+ if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower():
+ return (
+ "Garmin's login page returned a CAPTCHA or MFA challenge that "
+ "cannot be completed automatically. Try again later, or disable "
+ "two-factor authentication on your Garmin account." + fallback
+ )
+ return f"Login failed: {exc}" + fallback
+
+@app.get("/api/garmin/status")
+async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Return whether Garmin credentials are stored for the current user."""
+ user = _require_user(bincio_session)
+ dd = _get_data_dir() / user.handle
+ from bincio.extract.garmin_api import has_credentials
+ from bincio.extract.garmin_sync import _load_sync_state
+ connected = has_credentials(dd)
+ last_sync = None
+ if connected:
+ state = _load_sync_state(dd)
+ last_sync = state.get("last_sync_at")
+ return JSONResponse({"connected": connected, "last_sync": last_sync})
+
+
+@app.post("/api/garmin/connect")
+async def garmin_connect(
+ request: Request,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Test Garmin login with the supplied credentials and save them on success."""
+ user = _require_user(bincio_session)
+ body = await request.json()
+ email = (body.get("email") or "").strip()
+ password = body.get("password") or ""
+ if not email or not password:
+ raise HTTPException(400, "email and password are required")
+
+ data_dir = _get_data_dir()
+ user_dir = data_dir / user.handle
+ from bincio.extract.garmin_api import GarminError, test_login
+ try:
+ info = test_login(data_dir, user_dir, email, password)
+ except GarminError as exc:
+ raise HTTPException(400, _garmin_user_message(exc))
+ return JSONResponse({"ok": True, **info})
+
+
+@app.post("/api/garmin/disconnect")
+async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Remove stored Garmin credentials and session for the current user."""
+ user = _require_user(bincio_session)
+ dd = _get_data_dir() / user.handle
+ from bincio.extract.garmin_api import delete_credentials
+ delete_credentials(dd)
+ return JSONResponse({"ok": True})
+
+
+@app.get("/api/garmin/sync/stream")
+async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
+ """SSE endpoint — streams per-activity Garmin sync progress."""
+ user = _require_user(bincio_session)
+ data_dir = _get_data_dir()
+ user_dir = data_dir / user.handle
+
+ from bincio.extract.garmin_api import GarminError, has_credentials
+ if not has_credentials(user_dir):
+ raise HTTPException(400, "No Garmin credentials stored — connect first")
+
+ from bincio.extract.garmin_sync import garmin_sync_iter
+
+ def event_stream():
+ try:
+ for event in garmin_sync_iter(data_dir, user_dir):
+ if event["type"] == "done":
+ _trigger_rebuild(user.handle)
+ yield f"data: {json.dumps(event)}\n\n"
+ except GarminError as exc:
+ yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
+ except Exception as exc:
+ yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
diff --git a/docs/garmin_connect_disclaimer.md b/docs/garmin_connect_disclaimer.md
index 8d88475..5a00a0c 100644
--- a/docs/garmin_connect_disclaimer.md
+++ b/docs/garmin_connect_disclaimer.md
@@ -42,6 +42,40 @@ This feature relies on a reverse-engineered interface that:
BincioActivity takes no responsibility for account restrictions or bans
that may result from using this feature.
+### Cloudflare bot protection and rate limiting
+
+Garmin's login page (`sso.garmin.com`) is protected by Cloudflare, which
+periodically blocks automated login attempts. When this happens, the sync
+feature will fail at the login step with a "Login failed" error — even if
+your credentials are correct.
+
+The underlying `garth` library tries three login strategies in sequence.
+A blocked session typically looks like this in the server logs:
+
+```
+mobile+cffi returned 429: Mobile login returned 429 — IP rate limited by Garmin
+mobile+requests failed: Mobile login failed (non-JSON): HTTP 403
+widget+cffi failed: Widget login: unexpected title 'GARMIN Authentication Application'
+```
+
+What each error means:
+- **429** — Garmin is rate-limiting the server's IP address
+- **403** — Cloudflare is blocking the request outright
+- **unexpected title 'GARMIN Authentication Application'** — the login flow hit a
+ CAPTCHA or MFA challenge page that the library cannot handle automatically
+
+This is an upstream issue outside BincioActivity's control. The underlying
+`garminconnect`/`garth` library usually releases a fix within days to weeks.
+The workaround is to update those packages on the server:
+
+```bash
+uv sync --extra garmin
+```
+
+If login consistently fails despite updating, check the
+[garminconnect issue tracker](https://github.com/cyberjunky/python-garminconnect/issues)
+for the current status.
+
### Two-factor authentication (2FA)
If your Garmin account has 2FA enabled, this feature may not work or may
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index d224300..befcaae 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -276,6 +276,16 @@ try {
Import your full Strava archive
+
+ ⌚
+
+
Sync from Garmin Connect
+
Checking…
+
+
@@ -356,6 +366,47 @@ try {
+
+
+
+
← Back
+
+ ⚠ Garmin Connect has no official API. Your credentials are encrypted at rest and used to log in on your behalf.
Learn more .
+
+
+
+
Enter your Garmin Connect credentials to sync activities.
+
+
+
Connect
+
+
+
+
Last sync: never
+
Sync now
+
Disconnect
+
+
+
)}
@@ -476,12 +527,15 @@ try {
const viewFile = document.getElementById('upload-view-file');
const viewStrava = document.getElementById('upload-view-strava');
const viewZip = document.getElementById('upload-view-zip');
+ const viewGarmin = document.getElementById('upload-view-garmin');
const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava');
const chooseZip = document.getElementById('upload-choose-zip');
+ const chooseGarmin = document.getElementById('upload-choose-garmin');
const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava');
const backZip = document.getElementById('upload-back-zip');
+ const backGarmin = document.getElementById('upload-back-garmin');
const zipDrop = document.getElementById('zip-drop');
const zipInput = document.getElementById('zip-input');
const zipLabel = document.getElementById('zip-label');
@@ -501,6 +555,16 @@ try {
const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn');
const stravaLastSync = document.getElementById('strava-last-sync');
const stravaChooseSub = document.getElementById('strava-choose-sub');
+ const garminStatus = document.getElementById('garmin-status');
+ const garminConnect = document.getElementById('garmin-connect-area');
+ const garminSync = document.getElementById('garmin-sync-area');
+ const garminEmail = document.getElementById('garmin-email');
+ const garminPassword = document.getElementById('garmin-password');
+ const garminConnBtn = document.getElementById('garmin-connect-btn');
+ const garminSyncBtn = document.getElementById('garmin-sync-btn');
+ const garminDisconnBtn = document.getElementById('garmin-disconnect-btn');
+ const garminLastSync = document.getElementById('garmin-last-sync');
+ const garminChooseSub = document.getElementById('garmin-choose-sub');
// ── view helpers ──────────────────────────────────────────────────────
function showView(name) {
@@ -508,6 +572,7 @@ try {
viewFile.style.display = name === 'file' ? '' : 'none';
viewStrava.style.display = name === 'strava' ? '' : 'none';
viewZip.style.display = name === 'zip' ? '' : 'none';
+ viewGarmin.style.display = name === 'garmin' ? '' : 'none';
}
function openModal() {
@@ -531,6 +596,7 @@ try {
backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose'));
backZip.addEventListener('click', () => showView('choose'));
+ backGarmin.addEventListener('click', () => showView('choose'));
// ── file upload ───────────────────────────────────────────────────────
drop.addEventListener('click', () => input.click());
@@ -845,6 +911,137 @@ try {
doZipUpload(e.dataTransfer?.files?.[0]);
});
+ // ── Garmin Connect ────────────────────────────────────────────────────
+ async function loadGarminStatus() {
+ try {
+ const r = await fetch(`${editUrl}/api/garmin/status`, { credentials: 'include' });
+ if (!r.ok) throw new Error();
+ const d = await r.json();
+ garminChooseSub.textContent = d.connected ? 'Connected' : 'Not connected';
+ garminConnect.style.display = d.connected ? 'none' : '';
+ garminSync.style.display = d.connected ? '' : 'none';
+ if (d.last_sync) garminLastSync.textContent = new Date(d.last_sync).toLocaleString();
+ } catch (_) {
+ garminChooseSub.textContent = 'Unavailable';
+ }
+ }
+ loadGarminStatus();
+
+ chooseGarmin.addEventListener('click', () => {
+ garminStatus.textContent = '';
+ showView('garmin');
+ });
+
+ garminConnBtn.addEventListener('click', async () => {
+ const email = garminEmail.value.trim();
+ const password = garminPassword.value;
+ if (!email || !password) {
+ garminStatus.textContent = 'Enter email and password.';
+ garminStatus.style.color = '#f87171';
+ return;
+ }
+ garminConnBtn.disabled = true;
+ garminConnBtn.textContent = 'Connecting…';
+ garminStatus.textContent = 'Contacting Garmin — this may take up to a minute…';
+ garminStatus.style.color = '#a1a1aa';
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 90_000);
+ const r = await fetch(`${editUrl}/api/garmin/connect`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
+ const d = await r.json();
+ if (!r.ok) {
+ garminStatus.textContent = 'Error: ' + (d.detail || 'Login failed');
+ garminStatus.style.color = '#f87171';
+ } else {
+ garminPassword.value = '';
+ garminStatus.textContent = `Connected as ${d.display_name || email}!`;
+ garminStatus.style.color = '#4ade80';
+ garminConnect.style.display = 'none';
+ garminSync.style.display = '';
+ garminLastSync.textContent = 'never';
+ garminChooseSub.textContent = 'Connected';
+ }
+ } catch (e) {
+ const msg = e.name === 'AbortError'
+ ? 'Timed out — Garmin login is taking too long. Try again later.'
+ : 'Error: ' + e.message;
+ garminStatus.textContent = msg;
+ garminStatus.style.color = '#f87171';
+ } finally {
+ garminConnBtn.disabled = false;
+ garminConnBtn.textContent = 'Connect';
+ }
+ });
+
+ garminSyncBtn.addEventListener('click', () => {
+ garminSyncBtn.disabled = true;
+ garminSyncBtn.textContent = 'Syncing…';
+ garminStatus.textContent = '';
+ garminStatus.style.color = '';
+
+ const es = new EventSource(`${editUrl}/api/garmin/sync/stream`, { withCredentials: true });
+ es.onmessage = e => {
+ try {
+ const d = JSON.parse(e.data);
+ if (d.type === 'fetching') {
+ garminStatus.textContent = 'Fetching activity list from Garmin…';
+ } else if (d.type === 'progress') {
+ const pct = Math.round((d.n / d.total) * 100);
+ const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·';
+ garminStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`;
+ } else if (d.type === 'done') {
+ es.close();
+ garminLastSync.textContent = new Date().toLocaleString();
+ const errNote = d.error_count ? `, ${d.error_count} errors` : '';
+ garminStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`;
+ garminStatus.style.color = '#4ade80';
+ garminSyncBtn.disabled = false;
+ garminSyncBtn.textContent = 'Sync now';
+ } else if (d.type === 'error') {
+ es.close();
+ garminStatus.textContent = 'Error: ' + d.message;
+ garminStatus.style.color = '#f87171';
+ garminSyncBtn.disabled = false;
+ garminSyncBtn.textContent = 'Sync now';
+ }
+ } catch (_) {}
+ };
+ es.onerror = () => {
+ if (garminSyncBtn.disabled) {
+ garminStatus.textContent = 'Connection lost. Check logs.';
+ garminStatus.style.color = '#f87171';
+ garminSyncBtn.disabled = false;
+ garminSyncBtn.textContent = 'Sync now';
+ }
+ es.close();
+ };
+ });
+
+ garminDisconnBtn.addEventListener('click', async () => {
+ garminDisconnBtn.disabled = true;
+ garminStatus.textContent = '';
+ try {
+ await fetch(`${editUrl}/api/garmin/disconnect`, { method: 'POST', credentials: 'include' });
+ garminSync.style.display = 'none';
+ garminConnect.style.display = '';
+ garminStatus.textContent = 'Disconnected.';
+ garminStatus.style.color = '#a1a1aa';
+ garminChooseSub.textContent = 'Not connected';
+ } catch (e) {
+ garminStatus.textContent = 'Error: ' + e.message;
+ garminStatus.style.color = '#f87171';
+ } finally {
+ garminDisconnBtn.disabled = false;
+ }
+ });
+
// Handle ?strava= param set by the callback redirect (popup scenario)
const sp = new URLSearchParams(window.location.search);
if (sp.has('strava')) {
From 6d702ed45472055cad873bd7415a421835531653 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 15:47:09 +0200
Subject: [PATCH 054/124] modify post hook to install garmin packages
---
docs/deployment/vps.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md
index a973dfe..87b6d5b 100644
--- a/docs/deployment/vps.md
+++ b/docs/deployment/vps.md
@@ -58,7 +58,7 @@ while read oldrev newrev refname; do
echo "--- Syncing Python deps ---"
cd $DEPLOY
- ~/.local/bin/uv sync --extra serve --extra strava
+ ~/.local/bin/uv sync --extra serve --extra strava --extra garmin
echo "--- Syncing JS deps ---"
cd $DEPLOY/site
From 2774f436d8effea7cad6a9108499f7ae440cf498 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 15:47:27 +0200
Subject: [PATCH 055/124] login payoff
---
site/src/pages/login/index.astro | 3 +++
1 file changed, 3 insertions(+)
diff --git a/site/src/pages/login/index.astro b/site/src/pages/login/index.astro
index 9db2e98..599e1d1 100644
--- a/site/src/pages/login/index.astro
+++ b/site/src/pages/login/index.astro
@@ -4,6 +4,9 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
---
+
+ mangia bevi stai calmo non strappare
+
Sign in
From d659b90cd9aef95968b6d46ed40b573f6c60af79 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 17:46:28 +0200
Subject: [PATCH 056/124] =?UTF-8?q?=20=20-=20DELETE=20/api/admin/users/{ha?=
=?UTF-8?q?ndle}/activities=20=E2=80=94=20deletes=20all=20activities/*.jso?=
=?UTF-8?q?n,=20wipes=20=5Fmerged/=20and=20=20=20index.json,=20then=20trig?=
=?UTF-8?q?gers=20a=20rebuild.=20Admin-only.=20=20=20-=20/admin/=20page=20?=
=?UTF-8?q?=E2=80=94=20lists=20all=20users,=20each=20with=20a=20"Delete=20?=
=?UTF-8?q?activities"=20button.=20Clicking=20asks=20for=20=20=20confirmat?=
=?UTF-8?q?ion=20in=20a=20=20before=20firing=20the=20request.=20Bu?=
=?UTF-8?q?tton=20shows=20"Deleted=20(N)"=20or=20an=20error=20inline.=20?=
=?UTF-8?q?=20=20-=20"Admin"=20nav=20link=20=E2=80=94=20appears=20in=20the?=
=?UTF-8?q?=20top-right=20for=20admins=20only,=20hidden=20for=20everyone?=
=?UTF-8?q?=20else.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bincio/serve/server.py | 31 +++++++++++
site/src/layouts/Base.astro | 11 +++-
site/src/pages/admin/index.astro | 93 ++++++++++++++++++++++++++++++++
3 files changed, 134 insertions(+), 1 deletion(-)
create mode 100644 site/src/pages/admin/index.astro
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index db95d01..e0887e5 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -437,6 +437,37 @@ async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JS
return JSONResponse(jobs)
+@app.delete("/api/admin/users/{handle}/activities")
+async def admin_delete_activities(
+ handle: str,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Delete all activity JSON files for a user and wipe the merged cache."""
+ _require_admin(bincio_session)
+ user_dir = _get_data_dir() / handle
+ if not user_dir.is_dir():
+ raise HTTPException(404, f"No data directory for user '{handle}'")
+
+ deleted = 0
+ activities_dir = user_dir / "activities"
+ if activities_dir.is_dir():
+ for f in activities_dir.glob("*.json"):
+ f.unlink()
+ deleted += 1
+
+ # Wipe merged cache and top-level index so they don't show stale data
+ import shutil
+ merged_dir = user_dir / "_merged"
+ if merged_dir.exists():
+ shutil.rmtree(merged_dir)
+ index_file = user_dir / "index.json"
+ if index_file.exists():
+ index_file.unlink()
+
+ _trigger_rebuild(handle)
+ return JSONResponse({"ok": True, "deleted": deleted})
+
+
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
def _user_data_dir(handle: str) -> Path:
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index befcaae..4989589 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -200,6 +200,13 @@ try {
title=""
class="text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300 border border-amber-700/50 animate-pulse cursor-default"
>
+
+ Admin
+
+
Admin
+
+
Users
+
+
+
+
+ Delete all activities for ? This cannot be undone.
+
+ Cancel
+ Delete
+
+
+
+
+
+
From 79e428ff0f7478f4d821ecd0e069e6a264850179 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 19:48:13 +0200
Subject: [PATCH 057/124] script to rebuild pages
---
scripts/rebuild.sh | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100755 scripts/rebuild.sh
diff --git a/scripts/rebuild.sh b/scripts/rebuild.sh
new file mode 100755
index 0000000..c52a34e
--- /dev/null
+++ b/scripts/rebuild.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+set -e
+
+REPO=/opt/bincio-repo.git
+DEPLOY=/opt/bincio
+DATA=/var/bincio/data
+
+echo "--- Syncing Python deps ---"
+cd $DEPLOY
+~/.local/bin/uv sync --extra serve --extra strava --extra garmin
+
+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 "WARNING: bincio service restart failed — check journalctl -u bincio"
+
+echo "--- Done ---"
\ No newline at end of file
From 7e526c14e11f3300c66224f2bd082c4cff1c14cf Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Sun, 12 Apr 2026 19:55:13 +0200
Subject: [PATCH 058/124] fix commit d659b90cd9aef95968b6d46ed40b573f6c60af79
---
bincio/serve/server.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index e0887e5..6518728 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -455,14 +455,15 @@ async def admin_delete_activities(
f.unlink()
deleted += 1
- # Wipe merged cache and top-level index so they don't show stale data
+ # Wipe merged cache, top-level index, and dedup cache so re-uploads aren't blocked
import shutil
merged_dir = user_dir / "_merged"
if merged_dir.exists():
shutil.rmtree(merged_dir)
- index_file = user_dir / "index.json"
- if index_file.exists():
- index_file.unlink()
+ for name in ("index.json", ".bincio_cache.json"):
+ f = user_dir / name
+ if f.exists():
+ f.unlink()
_trigger_rebuild(handle)
return JSONResponse({"ok": True, "deleted": deleted})
From 7b37f451803b841acaf9a99362aaaf137889689f Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Mon, 13 Apr 2026 12:24:59 +0200
Subject: [PATCH 059/124] =?UTF-8?q?=20=20Bug=20fixed=20=E2=80=94=20temp=20?=
=?UTF-8?q?ZIPs=20now=20go=20to=20/tmp/=20(system=20temp)=20and=20are=20al?=
=?UTF-8?q?ways=20deleted=20in=20a=20finally=20block,=20so=20they=20can't?=
=?UTF-8?q?=20leak.=20A=20startup=20hook=20also=20auto-cleans=20any=20left?=
=?UTF-8?q?overs=20on=20=20=20next=20server=20restart.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Admin page now shows:
- Overall disk bar (used/free/%)
- Per-user table: total, activities (with file count), originals (with Strava breakdown), merged, images
- A mini bar per user showing relative size
- Red ⚠ warning if orphaned temp ZIPs are still present for a user
- Delete activities button (reloads sizes after)
---
bincio/serve/server.py | 68 ++++++++++++++-
scripts/disk_report.sh | 72 ++++++++++++++++
site/src/pages/admin/index.astro | 137 +++++++++++++++++++++++--------
3 files changed, 242 insertions(+), 35 deletions(-)
create mode 100644 scripts/disk_report.sh
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 6518728..4c74fbf 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -106,6 +106,19 @@ def _get_data_dir() -> Path:
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
+
+@app.on_event("startup")
+async def _cleanup_orphaned_tmp_zips() -> None:
+ """Remove tmp*.zip files left in user data dirs by the pre-fix upload handler."""
+ import glob as _glob
+ data_dir = _get_data_dir()
+ for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")):
+ try:
+ Path(p).unlink()
+ except Exception:
+ pass
+
+
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
@@ -437,6 +450,56 @@ async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JS
return JSONResponse(jobs)
+@app.get("/api/admin/disk")
+async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Per-user disk usage breakdown. Admin only."""
+ _require_admin(bincio_session)
+ import shutil
+
+ data_dir = _get_data_dir()
+
+ def _mb(path: Path) -> float:
+ if not path.exists():
+ return 0.0
+ total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
+ return round(total / 1_048_576, 1)
+
+ def _count(path: Path, pattern: str = "*") -> int:
+ if not path.exists():
+ return 0
+ return sum(1 for f in path.glob(pattern) if f.is_file())
+
+ users = []
+ for user_dir in sorted(data_dir.iterdir()):
+ if not user_dir.is_dir() or user_dir.name.startswith("_"):
+ continue
+ # leaked tmp zips
+ leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
+ users.append({
+ "handle": user_dir.name,
+ "total_mb": _mb(user_dir),
+ "activities_mb": _mb(user_dir / "activities"),
+ "activities_count": _count(user_dir / "activities", "*.json"),
+ "merged_mb": _mb(user_dir / "_merged"),
+ "originals_mb": _mb(user_dir / "originals"),
+ "originals_strava_mb": _mb(user_dir / "originals" / "strava"),
+ "images_mb": _mb(user_dir / "edits" / "images"),
+ "leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
+ "leaked_zips_count": len(leaked),
+ })
+
+ disk = shutil.disk_usage("/")
+ return JSONResponse({
+ "disk": {
+ "total_gb": round(disk.total / 1_073_741_824, 1),
+ "used_gb": round(disk.used / 1_073_741_824, 1),
+ "free_gb": round(disk.free / 1_073_741_824, 1),
+ "percent": round(disk.used / disk.total * 100, 1),
+ },
+ "users": users,
+ })
+
+
@app.delete("/api/admin/users/{handle}/activities")
async def admin_delete_activities(
handle: str,
@@ -803,7 +866,7 @@ async def upload_strava_zip(
dd = _get_data_dir() / user.handle
import tempfile
- tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd)
+ tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
zip_path = Path(tmp.name)
try:
while chunk := await file.read(1024 * 1024): # 1 MB chunks
@@ -826,8 +889,9 @@ async def upload_strava_zip(
merge_all(dd)
_trigger_rebuild(user.handle)
except Exception as exc:
- zip_path.unlink(missing_ok=True)
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
+ finally:
+ zip_path.unlink(missing_ok=True)
return StreamingResponse(
event_stream(),
diff --git a/scripts/disk_report.sh b/scripts/disk_report.sh
new file mode 100644
index 0000000..a7ce70a
--- /dev/null
+++ b/scripts/disk_report.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Bincio VPS disk usage report
+# Run on the VPS: bash scripts/disk_report.sh
+# Or remotely: ssh root@ 'bash -s' < scripts/disk_report.sh
+
+DATA=/var/bincio/data
+SITE=/var/bincio/site # adjust if your site build lives elsewhere
+
+hr() { echo; echo "── $* ──────────────────────────────────────"; }
+
+hr "DISK OVERVIEW"
+df -h / | tail -1 | awk '{printf "Used: %s / %s (%s full)\n", $3, $2, $5}'
+
+hr "BINCIO ROOT"
+du -sh /var/bincio/ 2>/dev/null
+
+hr "DATA ROOT: $DATA"
+du -sh "$DATA" 2>/dev/null
+
+hr "PER-USER BREAKDOWN"
+for user_dir in "$DATA"/*/; do
+ handle=$(basename "$user_dir")
+ [[ "$handle" == _* ]] && continue # skip _feedback etc.
+
+ total=$(du -sh "$user_dir" 2>/dev/null | cut -f1)
+
+ act=$(du -sh "$user_dir/activities" 2>/dev/null | cut -f1 || echo "—")
+ merged=$(du -sh "$user_dir/_merged" 2>/dev/null | cut -f1 || echo "—")
+ edits=$(du -sh "$user_dir/edits" 2>/dev/null | cut -f1 || echo "—")
+ images=$(du -sh "$user_dir/edits/images" 2>/dev/null | cut -f1 || echo "—")
+ orig=$(du -sh "$user_dir/originals" 2>/dev/null | cut -f1 || echo "—")
+ orig_strava=$(du -sh "$user_dir/originals/strava" 2>/dev/null | cut -f1 || echo "—")
+ orig_fit=$(du -sh "$user_dir/originals" 2>/dev/null) # will count below by extension
+
+ n_act=$(find "$user_dir/activities" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
+ n_orig=$(find "$user_dir/originals" -type f 2>/dev/null | wc -l | tr -d ' ')
+ n_strava=$(find "$user_dir/originals/strava" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
+
+ echo ""
+ echo " @$handle (total: $total)"
+ echo " activities/ $act ($n_act JSON files)"
+ echo " _merged/ $merged"
+ echo " edits/ $edits (images: $images)"
+ echo " originals/ $orig ($n_orig files)"
+ echo " strava/ $orig_strava ($n_strava JSON)"
+done
+
+hr "FEEDBACK"
+du -sh "$DATA/_feedback" 2>/dev/null || echo " (none)"
+
+hr "SITE BUILD"
+du -sh "$SITE" 2>/dev/null || echo " (not found at $SITE)"
+
+hr "LOGS"
+journalctl --disk-usage 2>/dev/null || echo " (journalctl unavailable)"
+
+hr "LARGEST FILES IN DATA (top 20)"
+find "$DATA" -type f -printf '%s\t%p\n' 2>/dev/null \
+ | sort -rn | head -20 \
+ | awk '{
+ size=$1; path=$2;
+ if (size >= 1048576) printf "%6.1f MB %s\n", size/1048576, path;
+ else if (size >= 1024) printf "%6.1f KB %s\n", size/1024, path;
+ else printf "%6d B %s\n", size, path;
+ }'
+
+hr "EXTENSION BREAKDOWN IN originals/"
+find "$DATA" -path "*/originals/*" -type f 2>/dev/null \
+ | sed 's/.*\.//' | sort | uniq -c | sort -rn \
+ | awk '{printf " %6d .%s\n", $1, $2}'
+
+echo
diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro
index 89ab8bf..9070c77 100644
--- a/site/src/pages/admin/index.astro
+++ b/site/src/pages/admin/index.astro
@@ -2,12 +2,33 @@
import Base from '../../layouts/Base.astro';
---
-
+
Admin
+
+
+
+
Users
-
-
Loading…
+
+
+
+
+ Handle
+ Total
+ Activities
+ Originals
+ Merged
+ Images
+
+
+
+
+ Loading…
+
+
@@ -22,40 +43,88 @@ import Base from '../../layouts/Base.astro';
@@ -284,11 +302,26 @@
{saving ? 'Saving…' : 'Save'}
+
confirmDelete = false}
+ >
+ {deleting ? 'Deleting…' : confirmDelete ? 'Confirm delete?' : 'Delete'}
+
{#if saveStatus}
{saveStatus}
From d2ba96c26ae4463fa4c5fddd6314d9e31d6fb2cc Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Mon, 13 Apr 2026 20:10:15 +0200
Subject: [PATCH 067/124] fix admin delete to wipe originals/edits/geojson;
rename button to Reset data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The old DELETE /api/admin/users/{handle}/activities only removed *.json
files and _merged/, leaving originals/ (Strava FIT files) and edits/
untouched — causing the 968 MB disk usage after a delete.
_wipe_user_activities() now removes activities/, edits/, originals/,
_merged/, index.json, athlete.json, and .bincio_cache.json. Admin page
button renamed to "Reset data" with updated confirmation text.
---
bincio/serve/server.py | 55 +++++++++++++++++++++-----------
site/src/pages/admin/index.astro | 8 +++--
2 files changed, 42 insertions(+), 21 deletions(-)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 9003d02..2c86469 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -517,38 +517,57 @@ async def admin_rebuild(
return JSONResponse({"ok": True})
+def _wipe_user_activities(user_dir: Path) -> int:
+ """Delete all extracted activity files and caches for a user.
+
+ Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
+ _merged/, index.json, athlete.json, and the dedup cache.
+ Leaves the user directory itself intact (account remains in the DB).
+ Returns the number of files deleted.
+ """
+ import shutil
+ deleted = 0
+
+ for subdir in ("activities", "edits", "originals"):
+ d = user_dir / subdir
+ if d.exists():
+ for f in d.rglob("*"):
+ if f.is_file():
+ deleted += 1
+ shutil.rmtree(d)
+
+ for name in ("_merged", ):
+ d = user_dir / name
+ if d.exists():
+ shutil.rmtree(d)
+
+ for name in ("index.json", "athlete.json", ".bincio_cache.json"):
+ f = user_dir / name
+ if f.exists():
+ f.unlink()
+ deleted += 1
+
+ return deleted
+
+
@app.delete("/api/admin/users/{handle}/activities")
async def admin_delete_activities(
handle: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
- """Delete all activity JSON files for a user and wipe the merged cache."""
+ """Delete all activity data for a user and wipe the merged cache."""
_require_admin(bincio_session)
user_dir = _get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for user '{handle}'")
- deleted = 0
- activities_dir = user_dir / "activities"
- if activities_dir.is_dir():
- for f in activities_dir.glob("*.json"):
- f.unlink()
- deleted += 1
-
- # Wipe merged cache, top-level index, and dedup cache so re-uploads aren't blocked
- import shutil
- merged_dir = user_dir / "_merged"
- if merged_dir.exists():
- shutil.rmtree(merged_dir)
- for name in ("index.json", ".bincio_cache.json"):
- f = user_dir / name
- if f.exists():
- f.unlink()
-
+ deleted = _wipe_user_activities(user_dir)
_trigger_rebuild(handle)
return JSONResponse({"ok": True, "deleted": deleted})
+
+
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
def _user_data_dir(handle: str) -> Path:
diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro
index c67ac33..1789631 100644
--- a/site/src/pages/admin/index.astro
+++ b/site/src/pages/admin/index.astro
@@ -33,10 +33,11 @@ import Base from '../../layouts/Base.astro';
- Delete all activities for ? This cannot be undone.
+ Reset all data for ?
+ Removes all activities, originals, edits, and images. The account is kept. This cannot be undone.
Cancel
- Delete
+ Reset
@@ -124,7 +125,8 @@ import Base from '../../layouts/Base.astro';
Delete activities
+ title="Wipe all activities, originals, edits and images — account is kept"
+ >Reset data
From 13643479efc46c11ca31aee28d3b37c12e18918c Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 21:58:50 +0200
Subject: [PATCH 068/124] add password reset via admin-generated one-time code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
db.py: reset_codes table (code, handle, created_by, created_at,
expires_at, used_at); create_reset_code() invalidates any prior unused
code for the same handle; use_reset_code() validates handle match,
expiry (24 h), and single-use; change_password() updates the hash.
server.py: POST /api/admin/users/{handle}/reset-password-code (admin)
returns a code; POST /api/auth/reset-password (public) validates the
code + handle and sets the new password.
Admin page: "Reset pwd" button per user — shows the code inline on
click (monospace, click-to-copy).
/reset-password/ page: handle + code + new password form.
Login page: "Forgot password?" link.
---
bincio/serve/db.py | 69 ++++++++++++++++++
bincio/serve/server.py | 34 +++++++++
site/src/pages/admin/index.astro | 34 +++++++++
site/src/pages/login/index.astro | 3 +
site/src/pages/reset-password/index.astro | 86 +++++++++++++++++++++++
5 files changed, 226 insertions(+)
create mode 100644 site/src/pages/reset-password/index.astro
diff --git a/bincio/serve/db.py b/bincio/serve/db.py
index 4485a01..9a901cf 100644
--- a/bincio/serve/db.py
+++ b/bincio/serve/db.py
@@ -45,6 +45,15 @@ CREATE TABLE IF NOT EXISTS invites (
used_at INTEGER
);
+CREATE TABLE IF NOT EXISTS reset_codes (
+ code TEXT PRIMARY KEY,
+ handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
+ created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ used_at INTEGER
+);
+
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -52,10 +61,12 @@ CREATE TABLE IF NOT EXISTS settings (
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
+CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
"""
_SESSION_DAYS = 30
_INVITE_LENGTH = 8
+_RESET_CODE_TTL_S = 24 * 3600 # 24 hours
# ── Data classes ──────────────────────────────────────────────────────────────
@@ -143,6 +154,13 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
)
+def change_password(db: sqlite3.Connection, handle: str, new_password: str) -> None:
+ """Replace the password hash for a user."""
+ new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
+ db.execute("UPDATE users SET password_hash = ? WHERE handle = ?", (new_hash, handle))
+ db.commit()
+
+
def list_users(db: sqlite3.Connection) -> list[User]:
rows = db.execute("SELECT * FROM users ORDER BY created_at").fetchall()
return [User(handle=r["handle"], display_name=r["display_name"],
@@ -317,3 +335,54 @@ def get_invite(db: sqlite3.Connection, code: str) -> Optional[Invite]:
created_at=row["created_at"],
used_at=row["used_at"],
)
+
+
+# ── Password reset codes ──────────────────────────────────────────────────────
+
+def create_reset_code(db: sqlite3.Connection, handle: str, created_by: str) -> str:
+ """Generate a password reset code for a user (admin only, out-of-band delivery).
+
+ Any previous unused codes for this handle are invalidated first.
+ Returns the new code.
+ """
+ now = int(time.time())
+ # Invalidate existing unused codes for this handle
+ db.execute(
+ "DELETE FROM reset_codes WHERE handle = ? AND used_at IS NULL",
+ (handle,),
+ )
+ code = secrets.token_urlsafe(_INVITE_LENGTH)[:_INVITE_LENGTH].upper()
+ db.execute(
+ "INSERT INTO reset_codes (code, handle, created_by, created_at, expires_at) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (code, handle, created_by, now, now + _RESET_CODE_TTL_S),
+ )
+ db.commit()
+ return code
+
+
+def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
+ """Validate a reset code for the given handle and mark it used.
+
+ Returns False if the code is invalid, already used, expired, or
+ belongs to a different handle.
+ """
+ now = int(time.time())
+ row = db.execute(
+ "SELECT handle, expires_at, used_at FROM reset_codes WHERE code = ?",
+ (code,),
+ ).fetchone()
+ if not row:
+ return False
+ if row["handle"] != handle:
+ return False
+ if row["used_at"] is not None:
+ return False
+ if row["expires_at"] < now:
+ return False
+ db.execute(
+ "UPDATE reset_codes SET used_at = ? WHERE code = ?",
+ (now, code),
+ )
+ db.commit()
+ return True
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 2c86469..c8ffe24 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -346,6 +346,25 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp
+@app.post("/api/auth/reset-password")
+async def reset_password(request: Request) -> JSONResponse:
+ """Validate a reset code and set a new password. Public endpoint."""
+ from bincio.serve.db import use_reset_code, change_password
+ body = await request.json()
+ handle = (body.get("handle") or "").strip().lower()
+ code = (body.get("code") or "").strip().upper()
+ new_pw = body.get("password") or ""
+ if not handle or not code or not new_pw:
+ raise HTTPException(400, "handle, code, and password are required")
+ if len(new_pw) < 8:
+ raise HTTPException(400, "Password must be at least 8 characters")
+ db = _get_db()
+ if not use_reset_code(db, code, handle):
+ raise HTTPException(400, "Invalid or expired reset code")
+ change_password(db, handle, new_pw)
+ return JSONResponse({"ok": True})
+
+
# ── Registration ──────────────────────────────────────────────────────────────
@app.post("/api/register")
@@ -503,6 +522,21 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS
})
+@app.post("/api/admin/users/{handle}/reset-password-code")
+async def admin_reset_password_code(
+ handle: str,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Generate a one-time password reset code for a user. Admin only."""
+ from bincio.serve.db import create_reset_code
+ admin = _require_admin(bincio_session)
+ db = _get_db()
+ if not get_user(db, handle):
+ raise HTTPException(404, f"User '{handle}' not found")
+ code = create_reset_code(db, handle, admin.handle)
+ return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24})
+
+
@app.post("/api/admin/users/{handle}/rebuild")
async def admin_rebuild(
handle: str,
diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro
index 1789631..3a977ec 100644
--- a/site/src/pages/admin/index.astro
+++ b/site/src/pages/admin/index.astro
@@ -122,6 +122,11 @@ import Base from '../../layouts/Base.astro';
data-handle="${u.handle}"
title="Re-run merge_all and trigger a site rebuild"
>Rebuild
+ Reset pwd
('.pwreset-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const h = btn.dataset.handle!;
+ btn.disabled = true;
+ btn.textContent = '…';
+ try {
+ const r = await fetch(`/api/admin/users/${h}/reset-password-code`, {
+ method: 'POST',
+ credentials: 'include',
+ });
+ const d = await r.json();
+ if (r.ok) {
+ btn.textContent = d.code;
+ btn.title = `Code for ${h} — valid 24 h. Click to copy.`;
+ btn.classList.add('text-yellow-300', 'font-mono');
+ btn.addEventListener('click', () => navigator.clipboard.writeText(d.code), { once: true });
+ } else {
+ btn.textContent = 'Error';
+ btn.classList.add('text-red-400');
+ btn.disabled = false;
+ }
+ } catch {
+ btn.textContent = 'Error';
+ btn.classList.add('text-red-400');
+ btn.disabled = false;
+ }
+ });
+ });
+
tbodyEl.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
pendingHandle = btn.dataset.handle!;
diff --git a/site/src/pages/login/index.astro b/site/src/pages/login/index.astro
index 599e1d1..2779abf 100644
--- a/site/src/pages/login/index.astro
+++ b/site/src/pages/login/index.astro
@@ -33,6 +33,9 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
Have an invite? Create account
+
+ Forgot password?
+
)}
diff --git a/site/src/pages/reset-password/index.astro b/site/src/pages/reset-password/index.astro
new file mode 100644
index 0000000..8051850
--- /dev/null
+++ b/site/src/pages/reset-password/index.astro
@@ -0,0 +1,86 @@
+---
+import Base from '../../layouts/Base.astro';
+---
+
+
+
Reset password
+
Enter the reset code you received from the admin.
+
+
+
+ Reset code
+
+
+
+ Handle
+
+
+
+
New password
+
+
At least 8 characters
+
+
+ Password updated. Sign in
+
+ Set new password
+
+
+
+
+ Back to sign in
+
+
+
+
+
From 8fbd9a95e8324cd8128e6d75b5fa536f6e631498 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 22:22:34 +0200
Subject: [PATCH 069/124] stop pre-building activity pages to fix OOM build
failure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
getStaticPaths now returns [] — all /activity/{id}/ URLs are served by
the activity/index.html shell via nginx try_files and hydrated by
ActivityDetailLoader. Pre-rendering thousands of pages was exhausting
server RAM and killing the build. The dynamic loader already handles
public, unlisted, and local activities identically.
---
site/src/pages/activity/[id].astro | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/site/src/pages/activity/[id].astro b/site/src/pages/activity/[id].astro
index 4539f18..cffc987 100644
--- a/site/src/pages/activity/[id].astro
+++ b/site/src/pages/activity/[id].astro
@@ -6,6 +6,15 @@ import ActivityDetail from '../../components/ActivityDetail.svelte';
import type { BASIndex, ActivitySummary, AthleteZones } from '../../lib/types';
export async function getStaticPaths() {
+ // Activity pages are not pre-built — all /activity/{id}/ URLs are served
+ // by the activity/index.html shell via nginx try_files and loaded dynamically
+ // by ActivityDetailLoader. Pre-building thousands of pages at build time
+ // exhausts server memory. The shell handles public, unlisted, and local
+ // activities identically with no loss of functionality.
+ return [];
+
+ // Dead code below — kept for reference only.
+ /* eslint-disable no-unreachable */
try {
const candidates = [
process.env.BINCIO_DATA_DIR,
@@ -119,6 +128,7 @@ export async function getStaticPaths() {
} catch {
return [];
}
+ /* eslint-enable no-unreachable */
}
const { activity, athlete } = Astro.props as { activity: ActivitySummary; athlete: AthleteZones | null };
From 9419bd0c20c76e4aa9fbed4ff0961827bd2f2f19 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 22:34:15 +0200
Subject: [PATCH 070/124] document password reset flow in CLAUDE.md and
reset-password page
---
CLAUDE.md | 17 +++++++++++++++++
site/src/pages/reset-password/index.astro | 3 ++-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 414a8f9..ff8c7fa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -235,6 +235,23 @@ Key facts:
`fetch('/api/me')` auth wall; `/login/` and `/register/` have `public={true}` to skip it
- Incremental rebuild: `POST /api/activity/{id}` triggers `bincio render --handle {user}`
as a fire-and-forget subprocess (only if `--site-dir` was passed to `bincio serve`)
+
+### Password reset (no email — out-of-band code)
+
+There is no email infrastructure. Password resets work via admin-generated one-time codes:
+
+1. **Admin** opens `/admin/` → clicks **"Reset pwd"** next to the user → a code appears
+ inline (monospace, click to copy). Valid for **24 hours**, tied to that handle.
+2. **Admin** sends the code out-of-band (Signal, Telegram, etc.).
+3. **User** goes to `/reset-password/`, enters handle + code + new password → done.
+
+API:
+- `POST /api/admin/users/{handle}/reset-password-code` (admin) → `{code, expires_in_hours: 24}`
+- `POST /api/auth/reset-password` (public) → body `{handle, code, password}`
+
+DB: `reset_codes` table `(code, handle, created_by, created_at, expires_at, used_at)`.
+Generating a new code invalidates any prior unused code for the same handle.
+Used codes are kept for audit. `change_password()` in `db.py` updates the bcrypt hash.
- 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
diff --git a/site/src/pages/reset-password/index.astro b/site/src/pages/reset-password/index.astro
index 8051850..1379dad 100644
--- a/site/src/pages/reset-password/index.astro
+++ b/site/src/pages/reset-password/index.astro
@@ -4,7 +4,8 @@ import Base from '../../layouts/Base.astro';
Reset password
-
Enter the reset code you received from the admin.
+
Enter the reset code you received from the admin.
+
Don't have a code? Contact the instance admin — they can generate one for you from the admin panel. Codes expire after 24 hours.
From a14cee87106c7da11ebd8d9b69ac28dbd34a5b74 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 22:45:03 +0200
Subject: [PATCH 071/124] add architecture graph generator and docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
scripts/gen_graph.py parses FastAPI routes, frontend fetch() calls,
component imports, and Python imports to auto-generate:
- docs/architecture.mmd: Mermaid diagram with API/Pages/Components/Python subgraphs
- docs/graph.html: standalone vis.js interactive graph (dark theme, group filters,
search highlight, click-to-highlight connected nodes)
docs-proposal.md: proposal for a docs/ folder structure, API documentation strategy,
and tooling recommendations (plain markdown → MkDocs Material).
---
docs-proposal.md | 113 ++++
docs/architecture.mmd | 274 +++++++++
docs/graph.html | 1358 +++++++++++++++++++++++++++++++++++++++++
scripts/gen_graph.py | 532 ++++++++++++++++
4 files changed, 2277 insertions(+)
create mode 100644 docs-proposal.md
create mode 100644 docs/architecture.mmd
create mode 100644 docs/graph.html
create mode 100644 scripts/gen_graph.py
diff --git a/docs-proposal.md b/docs-proposal.md
new file mode 100644
index 0000000..bcfe977
--- /dev/null
+++ b/docs-proposal.md
@@ -0,0 +1,113 @@
+# Documentation proposal
+
+## Problem
+
+The project has no user-facing or developer-facing docs. Knowledge lives in `CLAUDE.md`
+(written for AI context, not humans), scattered inline comments, and the code itself.
+As the feature surface grows and more users join, we need:
+
+- A guide for **users** (how to upload, sync, edit, manage privacy)
+- A guide for **admins** (how to run an instance, manage users, reset passwords)
+- An **API reference** (what endpoints exist, what they expect, what they return)
+- A **developer guide** (how to run locally, architecture, how to contribute)
+
+---
+
+## Proposed structure
+
+```
+docs/
+ index.md Overview and quick links
+ user-guide.md End-user: upload, sync, edit, privacy, settings
+ admin-guide.md Admin: deploy, init, invite users, reset passwords, rebuild
+ api.md API reference (hand-written, augmented by OpenAPI)
+ architecture.md BAS schema, data flow, shard model, federation
+ developer-guide.md Local setup, how to run tests, how to contribute
+```
+
+`CLAUDE.md` stays as-is — it is AI context, not user docs. The two serve different
+audiences and should not be merged.
+
+---
+
+## API documentation strategy
+
+FastAPI auto-generates an OpenAPI 3.1 spec from the route decorators. It is already
+served at `/api/docs` (Swagger UI) and `/api/redoc` (ReDoc) when the server is running.
+Right now the auto-docs are sparse because:
+
+- Most endpoints return bare `JSONResponse` instead of typed Pydantic response models
+- Endpoint docstrings are minimal or absent
+- Request bodies are raw `request.json()` instead of Pydantic models
+
+### Recommended approach: two-layer docs
+
+**Layer 1 — machine-readable (OpenAPI, auto-generated)**
+
+Incrementally add Pydantic request/response models to the endpoints that matter most
+(auth, activity CRUD, admin actions). FastAPI will pick them up automatically and the
+Swagger UI becomes usable. No extra tooling needed.
+
+Priority endpoints to type first:
+- `POST /api/auth/login` / `logout` / `reset-password`
+- `POST /api/register`
+- `GET /api/me`
+- `GET|POST /api/activity/{id}`
+- `DELETE /api/activity/{id}`
+- `POST /api/admin/users/{handle}/reset-password-code`
+- `GET|POST /api/me/preferences` (once built)
+
+**Layer 2 — human-readable (`docs/api.md`)**
+
+A hand-written reference that groups endpoints by domain (auth, activities, admin,
+sync), explains the overall auth model (cookie-based, httpOnly), rate limiting, and
+covers things OpenAPI can't express well (SSE streams, error semantics, side effects
+like rebuild triggers).
+
+The OpenAPI spec and the hand-written doc are complementary, not duplicates:
+OpenAPI is precise and machine-readable; `api.md` gives context and explains *why*.
+
+---
+
+## Tooling options
+
+| Option | Pros | Cons |
+|--------|------|------|
+| Plain markdown in `docs/` | Zero tooling, lives in repo, renders on GitHub | No search, no versioning, no sidebar nav |
+| MkDocs + Material theme | Beautiful, search, auto-nav from folder structure, can embed OpenAPI via plugins | Needs Python dep + build step; another thing to deploy |
+| Docusaurus | Great for open-source projects, versioning, i18n | Node toolchain, heavier |
+| VitePress | Fast, Vite-based (already in the stack), markdown + Vue | Still a separate site to host |
+| Just the Swagger UI at `/api/docs` | Auto-generated, always up-to-date | Only covers the API, not user/admin/architecture |
+
+**Recommendation:** Start with plain markdown in `docs/` — no build step, always
+available, no new infrastructure. If the project goes public or the user base grows,
+migrate to MkDocs Material (one `mkdocs.yml` + `pip install mkdocs-material`).
+
+For the API specifically: enable the Swagger UI on the live server (currently it may
+be disabled in production) so admins can explore it directly at `/api/docs`.
+
+---
+
+## Enabling Swagger UI in production
+
+By default FastAPI serves `/docs` and `/redoc`. In `bincio serve`, the FastAPI app is
+created with:
+
+```python
+app = FastAPI(docs_url=None, redoc_url=None) # check current value
+```
+
+For a private instance (auth-walled), it is safe to expose `/api/docs` — add a note
+in `admin-guide.md` that it exists. Alternatively, serve it only when an env var is set.
+
+---
+
+## Suggested first milestone
+
+1. Create `docs/` with `index.md`, `admin-guide.md`, `api.md`
+2. `admin-guide.md`: deploy, init, invite, password reset, rebuild, reset data
+3. `api.md`: auth endpoints + activity endpoints, hand-written
+4. Enable Swagger UI on the server (or at least document that it exists at `/api/docs`)
+5. Add Pydantic models to the 8 priority endpoints above
+
+Everything else (user guide, architecture, developer guide, MkDocs) is second milestone.
diff --git a/docs/architecture.mmd b/docs/architecture.mmd
new file mode 100644
index 0000000..83a64be
--- /dev/null
+++ b/docs/architecture.mmd
@@ -0,0 +1,274 @@
+graph LR
+
+ subgraph API
+ subgraph api_activity["activity"]
+ api__api_activity__activity_id_["GET /api/activity/{activity_id}"]
+ api__api_activity__activity_id_["POST /api/activity/{activity_id}"]
+ api__api_activity__activity_id_["DELETE /api/activity/{activity_id}"]
+ api__api_activity__activity_id__images["GET /api/activity/{activity_id}/images"]
+ api__api_activity__activity_id__images["POST /api/activity/{activity_id}/images"]
+ api__api_activity__activity_id__images__filename_["DELETE /api/activity/{activity_id}/images/{filename}"]
+ end
+ subgraph api_admin["admin"]
+ api__api_admin_users["GET /api/admin/users"]
+ api__api_admin_jobs["GET /api/admin/jobs"]
+ api__api_admin_disk["GET /api/admin/disk"]
+ api__api_admin_users__handle__reset_password_code["POST /api/admin/users/{handle}/reset-password-code"]
+ api__api_admin_users__handle__rebuild["POST /api/admin/users/{handle}/rebuild"]
+ api__api_admin_users__handle__activities["DELETE /api/admin/users/{handle}/activities"]
+ end
+ subgraph api_athlete["athlete"]
+ api__api_athlete["GET /api/athlete"]
+ api__api_athlete["POST /api/athlete"]
+ end
+ subgraph api_auth["auth"]
+ api__api_auth_login["POST /api/auth/login"]
+ api__api_auth_logout["POST /api/auth/logout"]
+ api__api_auth_reset_password["POST /api/auth/reset-password"]
+ end
+ subgraph api_feedback["feedback"]
+ api__api_feedback["POST /api/feedback"]
+ end
+ subgraph api_garmin["garmin"]
+ api__api_garmin_status["GET /api/garmin/status"]
+ api__api_garmin_connect["POST /api/garmin/connect"]
+ api__api_garmin_disconnect["POST /api/garmin/disconnect"]
+ api__api_garmin_sync_stream["GET /api/garmin/sync/stream"]
+ end
+ subgraph api_invites["invites"]
+ api__api_invites["GET /api/invites"]
+ api__api_invites["POST /api/invites"]
+ end
+ subgraph api_me["me"]
+ api__api_me["GET /api/me"]
+ end
+ subgraph api_register["register"]
+ api__api_register["POST /api/register"]
+ end
+ subgraph api_stats["stats"]
+ api__api_stats["GET /api/stats"]
+ end
+ subgraph api_strava["strava"]
+ api__api_strava_status["GET /api/strava/status"]
+ api__api_strava_reset["POST /api/strava/reset"]
+ api__api_strava_auth_url["GET /api/strava/auth-url"]
+ api__api_strava_callback["GET /api/strava/callback"]
+ api__api_strava_sync_stream["GET /api/strava/sync/stream"]
+ api__api_strava_sync["POST /api/strava/sync"]
+ end
+ subgraph api_upload["upload"]
+ api__api_upload["POST /api/upload"]
+ api__api_upload_strava_zip["POST /api/upload/strava-zip"]
+ end
+ end
+
+ subgraph Pages
+ site_src_pages_about_ca_index_astro["pages/about/ca/"]
+ site_src_pages_about_es_index_astro["pages/about/es/"]
+ site_src_pages_about_index_astro["pages/about/"]
+ site_src_pages_about_it_index_astro["pages/about/it/"]
+ site_src_pages_activity__id__astro["pages/activity/[id].astro"]
+ site_src_pages_activity_index_astro["pages/activity/"]
+ site_src_pages_activity_local_index_astro["pages/activity/local/"]
+ site_src_pages_admin_index_astro["pages/admin/"]
+ site_src_pages_athlete_index_astro["pages/athlete/"]
+ site_src_pages_community_index_astro["pages/community/"]
+ site_src_pages_convert_index_astro["pages/convert/"]
+ site_src_pages_feedback_index_astro["pages/feedback/"]
+ site_src_pages_index_astro["pages/"]
+ site_src_pages_invites_index_astro["pages/invites/"]
+ site_src_pages_login_index_astro["pages/login/"]
+ site_src_pages_record_index_astro["pages/record/"]
+ site_src_pages_register_index_astro["pages/register/"]
+ site_src_pages_reset_password_index_astro["pages/reset-password/"]
+ site_src_pages_stats_index_astro["pages/stats/"]
+ site_src_pages_u__handle__athlete_index_astro["pages/u/[handle]/athlete/"]
+ site_src_pages_u__handle__index_astro["pages/u/[handle]/"]
+ site_src_pages_u__handle__stats_index_astro["pages/u/[handle]/stats/"]
+ end
+
+ subgraph Components
+ site_src_components_ActivityCharts_svelte["components/ActivityCharts.svelte"]
+ site_src_components_ActivityDetail_svelte["components/ActivityDetail.svelte"]
+ site_src_components_ActivityDetailLoader_svelte["components/ActivityDetailLoader.svelte"]
+ site_src_components_ActivityFeed_svelte["components/ActivityFeed.svelte"]
+ site_src_components_ActivityMap_svelte["components/ActivityMap.svelte"]
+ site_src_components_AthleteDrawer_svelte["components/AthleteDrawer.svelte"]
+ site_src_components_AthleteView_svelte["components/AthleteView.svelte"]
+ site_src_components_CommunityView_svelte["components/CommunityView.svelte"]
+ site_src_components_EditDrawer_svelte["components/EditDrawer.svelte"]
+ site_src_components_LocalActivityDetail_svelte["components/LocalActivityDetail.svelte"]
+ site_src_components_MmpChart_svelte["components/MmpChart.svelte"]
+ site_src_components_RecordsView_svelte["components/RecordsView.svelte"]
+ site_src_components_StatsView_svelte["components/StatsView.svelte"]
+ end
+
+ subgraph Python
+ subgraph py_edit["edit"]
+ bincio_edit_cli_py["cli"]
+ bincio_edit_ops_py["ops"]
+ bincio_edit_server_py["server"]
+ end
+ subgraph py_extract["extract"]
+ bincio_extract_cli_py["cli"]
+ bincio_extract_config_py["config"]
+ bincio_extract_dedup_py["dedup"]
+ bincio_extract_garmin_api_py["garmin_api"]
+ bincio_extract_garmin_sync_py["garmin_sync"]
+ bincio_extract_ingest_py["ingest"]
+ bincio_extract_metrics_py["metrics"]
+ bincio_extract_models_py["models"]
+ bincio_extract_parsers_base_py["base"]
+ bincio_extract_parsers_factory_py["factory"]
+ bincio_extract_parsers_fit_py["fit"]
+ bincio_extract_parsers_gpx_py["gpx"]
+ bincio_extract_parsers_tcx_py["tcx"]
+ bincio_extract_simplify_py["simplify"]
+ bincio_extract_sport_py["sport"]
+ bincio_extract_strava_api_py["strava_api"]
+ bincio_extract_strava_csv_py["strava_csv"]
+ bincio_extract_strava_zip_py["strava_zip"]
+ bincio_extract_timeseries_py["timeseries"]
+ bincio_extract_writer_py["writer"]
+ end
+ subgraph py_import_["import_"]
+ bincio_import__cli_py["cli"]
+ bincio_import__strava_py["strava"]
+ end
+ subgraph py_render["render"]
+ bincio_render_cli_py["cli"]
+ bincio_render_merge_py["merge"]
+ end
+ subgraph py_root["root"]
+ bincio_cli_py["cli"]
+ bincio_dev_py["dev"]
+ end
+ subgraph py_serve["serve"]
+ bincio_serve_cli_py["cli"]
+ bincio_serve_db_py["db"]
+ bincio_serve_init_cmd_py["init_cmd"]
+ bincio_serve_server_py["server"]
+ end
+ end
+
+ site_src_components_EditDrawer_svelte -->|fetch| api__api_activity_
+ site_src_components_AthleteDrawer_svelte -->|fetch| api__api_athlete
+ site_src_components_AthleteView_svelte -->|fetch| api__api_athlete
+ site_src_components_AthleteView_svelte -->|fetch| api__api_athlete__
+ site_src_layouts_Base_astro -->|fetch| api__api_me
+ site_src_layouts_Base_astro -->|fetch| api__api_admin_jobs
+ site_src_layouts_Base_astro -->|fetch| api__api_auth_logout
+ site_src_layouts_Base_astro -->|fetch| api__api_admin_jobs__
+ site_src_layouts_Base_astro -->|fetch| api__api_auth_logout__
+ site_src_layouts_Base_astro -->|fetch| api__api_upload
+ site_src_layouts_Base_astro -->|fetch| api__api_strava_status
+ site_src_layouts_Base_astro -->|fetch| api__api_strava_auth_url
+ site_src_layouts_Base_astro -->|fetch| api__api_strava_sync_stream
+ site_src_layouts_Base_astro -->|fetch| api__api_strava_reset
+ site_src_layouts_Base_astro -->|fetch| api__api_upload_strava_zip
+ site_src_layouts_Base_astro -->|fetch| api__api_garmin_status
+ site_src_layouts_Base_astro -->|fetch| api__api_garmin_connect
+ site_src_layouts_Base_astro -->|fetch| api__api_garmin_sync_stream
+ site_src_layouts_Base_astro -->|fetch| api__api_garmin_disconnect
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_disk
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___h__rebuild
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___h__reset_password_code
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___pendingHandle__activities
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_disk__
+ site_src_pages_admin_index_astro -->|fetch| api__api_admin_users_
+ site_src_pages_about_index_astro -->|fetch| api__api_me
+ site_src_pages_about_index_astro -->|fetch| api__api_stats
+ site_src_pages_about_index_astro -->|fetch| api__api_stats___
+ site_src_pages_feedback_index_astro -->|fetch| api__api_feedback
+ site_src_pages_feedback_index_astro -->|fetch| api__api_me
+ site_src_pages_feedback_index_astro -->|fetch| api__api_feedback__
+ site_src_pages_feedback_index_astro -->|fetch| api__api_me__
+ site_src_pages_register_index_astro -->|fetch| api__api_register
+ site_src_pages_reset_password_index_astro -->|fetch| api__api_auth_reset_password
+ site_src_pages_invites_index_astro -->|fetch| api__api_invites
+ site_src_pages_invites_index_astro -->|fetch| api__api_invites__
+ site_src_pages_login_index_astro -->|fetch| api__api_auth_login
+ site_src_pages_convert_index_astro -->|fetch| api__api_import_bas
+ site_src_pages_about_it_index_astro -->|fetch| api__api_me
+ site_src_pages_about_it_index_astro -->|fetch| api__api_stats
+ site_src_pages_about_it_index_astro -->|fetch| api__api_stats___
+ site_src_pages_about_ca_index_astro -->|fetch| api__api_me
+ site_src_pages_about_ca_index_astro -->|fetch| api__api_stats
+ site_src_pages_about_ca_index_astro -->|fetch| api__api_stats___
+ site_src_pages_about_es_index_astro -->|fetch| api__api_me
+ site_src_pages_about_es_index_astro -->|fetch| api__api_stats
+ site_src_pages_about_es_index_astro -->|fetch| api__api_stats___
+ site_src_components_ActivityDetail_svelte --> site_src_components_ActivityMap_svelte
+ site_src_components_ActivityDetail_svelte --> site_src_components_ActivityCharts_svelte
+ site_src_components_ActivityDetail_svelte --> site_src_components_EditDrawer_svelte
+ site_src_components_ActivityDetailLoader_svelte --> site_src_components_ActivityDetail_svelte
+ site_src_components_AthleteView_svelte --> site_src_components_MmpChart_svelte
+ site_src_components_AthleteView_svelte --> site_src_components_RecordsView_svelte
+ site_src_components_AthleteView_svelte --> site_src_components_AthleteDrawer_svelte
+ site_src_components_LocalActivityDetail_svelte --> site_src_components_ActivityDetail_svelte
+ site_src_pages_index_astro --> site_src_components_ActivityFeed_svelte
+ site_src_pages_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_record_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_activity__id__astro --> site_src_components_ActivityDetail_svelte
+ site_src_pages_activity__id__astro --> site_src_layouts_Base_astro
+ site_src_pages_activity_index_astro --> site_src_components_ActivityDetailLoader_svelte
+ site_src_pages_activity_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_admin_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_about_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_feedback_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_register_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_reset_password_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_community_index_astro --> site_src_components_CommunityView_svelte
+ site_src_pages_community_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_invites_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_login_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_convert_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_activity_local_index_astro --> site_src_components_LocalActivityDetail_svelte
+ site_src_pages_activity_local_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_u__handle__index_astro --> site_src_components_ActivityFeed_svelte
+ site_src_pages_u__handle__index_astro --> site_src_layouts_Base_astro
+ site_src_pages_u__handle__athlete_index_astro --> site_src_components_AthleteView_svelte
+ site_src_pages_u__handle__athlete_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_u__handle__stats_index_astro --> site_src_components_StatsView_svelte
+ site_src_pages_u__handle__stats_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_about_it_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_about_ca_index_astro --> site_src_layouts_Base_astro
+ site_src_pages_about_es_index_astro --> site_src_layouts_Base_astro
+ bincio_cli_py --> bincio_import__cli_py
+ bincio_cli_py --> bincio_extract_cli_py
+ bincio_cli_py --> bincio_dev_py
+ bincio_cli_py --> bincio_edit_cli_py
+ bincio_cli_py --> bincio_serve_cli_py
+ bincio_cli_py --> bincio_render_cli_py
+ bincio_cli_py --> bincio_serve_init_cmd_py
+ bincio_import__strava_py --> bincio_extract_models_py
+ bincio_import__strava_py --> bincio_extract_sport_py
+ bincio_edit_server_py --> bincio_edit_ops_py
+ bincio_extract_simplify_py --> bincio_extract_models_py
+ bincio_extract_metrics_py --> bincio_extract_models_py
+ bincio_extract_ingest_py --> bincio_extract_models_py
+ bincio_extract_strava_api_py --> bincio_extract_models_py
+ bincio_extract_strava_api_py --> bincio_extract_sport_py
+ bincio_extract_cli_py --> bincio_extract_parsers_factory_py
+ bincio_extract_cli_py --> bincio_extract_config_py
+ bincio_extract_cli_py --> bincio_extract_dedup_py
+ bincio_extract_writer_py --> bincio_extract_models_py
+ bincio_extract_writer_py --> bincio_extract_metrics_py
+ bincio_extract_writer_py --> bincio_extract_timeseries_py
+ bincio_extract_writer_py --> bincio_extract_simplify_py
+ bincio_extract_timeseries_py --> bincio_extract_models_py
+ bincio_serve_server_py --> bincio_edit_ops_py
+ bincio_serve_server_py --> bincio_serve_db_py
+ bincio_extract_parsers_tcx_py --> bincio_extract_models_py
+ bincio_extract_parsers_tcx_py --> bincio_extract_sport_py
+ bincio_extract_parsers_fit_py --> bincio_extract_models_py
+ bincio_extract_parsers_fit_py --> bincio_extract_sport_py
+ bincio_extract_parsers_gpx_py --> bincio_extract_models_py
+ bincio_extract_parsers_gpx_py --> bincio_extract_parsers_base_py
+ bincio_extract_parsers_gpx_py --> bincio_extract_sport_py
+ bincio_extract_parsers_factory_py --> bincio_extract_models_py
+ bincio_extract_parsers_factory_py --> bincio_extract_parsers_gpx_py
+ bincio_extract_parsers_factory_py --> bincio_extract_parsers_base_py
+ bincio_extract_parsers_factory_py --> bincio_extract_parsers_fit_py
+ bincio_extract_parsers_factory_py --> bincio_extract_parsers_tcx_py
+ bincio_extract_parsers_base_py --> bincio_extract_models_py
\ No newline at end of file
diff --git a/docs/graph.html b/docs/graph.html
new file mode 100644
index 0000000..5962988
--- /dev/null
+++ b/docs/graph.html
@@ -0,0 +1,1358 @@
+
+
+
+
+Bincio — architecture graph
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/gen_graph.py b/scripts/gen_graph.py
new file mode 100644
index 0000000..c96932c
--- /dev/null
+++ b/scripts/gen_graph.py
@@ -0,0 +1,532 @@
+#!/usr/bin/env python3
+"""Generate architecture graphs for the bincio codebase.
+
+Outputs:
+ docs/architecture.mmd — Mermaid source (embeddable in markdown / GitHub)
+ docs/graph.html — interactive vis.js graph (open in a browser)
+
+Usage:
+ uv run python scripts/gen_graph.py
+ # or just:
+ python scripts/gen_graph.py
+"""
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).parent.parent
+SITE_SRC = ROOT / "site" / "src"
+DOCS = ROOT / "docs"
+DOCS.mkdir(exist_ok=True)
+
+# ── helpers ───────────────────────────────────────────────────────────────────
+
+def read(path: Path) -> str:
+ try:
+ return path.read_text(encoding="utf-8")
+ except Exception:
+ return ""
+
+
+def short(path: Path, base: Path) -> str:
+ """Return a short display label for a file path."""
+ try:
+ rel = path.relative_to(base)
+ except ValueError:
+ rel = path
+ parts = rel.parts
+ # Drop leading site/src/ or bincio/
+ if parts[:2] == ("site", "src"):
+ parts = parts[2:]
+ elif parts[:1] == ("bincio",):
+ parts = parts[1:]
+ name = "/".join(parts)
+ # Strip index.astro → parent dir
+ if name.endswith("/index.astro"):
+ name = name[: -len("/index.astro")] + "/"
+ return name
+
+
+# ── 1. API routes from server.py ──────────────────────────────────────────────
+
+def extract_routes(server_path: Path) -> list[dict]:
+ """Parse @app.{method}("/api/...") decorators."""
+ text = read(server_path)
+ routes = []
+ for m in re.finditer(
+ r'@app\.(get|post|put|patch|delete)\("(/api/[^"]+)"',
+ text,
+ re.MULTILINE,
+ ):
+ method, path = m.group(1).upper(), m.group(2)
+ # Find the function name on the next non-blank line
+ tail = text[m.end():]
+ fn_m = re.search(r"async def (\w+)", tail[:200])
+ fn = fn_m.group(1) if fn_m else "?"
+ routes.append({"method": method, "path": path, "fn": fn})
+ return routes
+
+
+# ── 2. Frontend → API edges ───────────────────────────────────────────────────
+
+_FETCH_RE = re.compile(r"""fetch\(\s*[`'"](/api/[^`'"]+)[`'"]""")
+_INTERP_RE = re.compile(r"""`[^`]*/api/([^`$\s{]+)""") # template literals
+
+
+def extract_api_calls(file_path: Path) -> list[str]:
+ """Return all /api/... paths referenced by a frontend file."""
+ text = read(file_path)
+ found = []
+ for m in _FETCH_RE.finditer(text):
+ found.append(m.group(1).split("?")[0]) # strip query string
+ # Template literals: `/api/admin/users/${h}/rebuild` → /api/admin/users/{h}/rebuild
+ for m in _INTERP_RE.finditer(text):
+ raw = "/api/" + m.group(1)
+ normalised = re.sub(r"\$\{[^}]+\}", "{x}", raw)
+ found.append(normalised)
+ return found
+
+
+def normalise_route(path: str, routes: list[dict]) -> str | None:
+ """Match a raw path like /api/admin/users/brut/rebuild to a known route pattern."""
+ for r in routes:
+ pattern = re.sub(r"\{[^}]+\}", r"[^/]+", re.escape(r["path"])) + "$"
+ if re.match(pattern, path):
+ return r["path"]
+ return path # keep as-is if not matched
+
+
+# ── 3. Component imports (Svelte / Astro) ─────────────────────────────────────
+
+_IMPORT_SVELTE_RE = re.compile(
+ r"""import\s+\w+\s+from\s+['"]([^'"]+\.svelte)['"]"""
+)
+_IMPORT_ASTRO_RE = re.compile(
+ r"""import\s+\w+\s+from\s+['"]([^'"]+\.astro)['"]"""
+)
+
+
+def extract_component_imports(file_path: Path) -> list[Path]:
+ text = read(file_path)
+ results = []
+ for pattern in (_IMPORT_SVELTE_RE, _IMPORT_ASTRO_RE):
+ for m in pattern.finditer(text):
+ ref = m.group(1)
+ target = (file_path.parent / ref).resolve()
+ if target.exists():
+ results.append(target)
+ return results
+
+
+# ── 4. Python module imports ──────────────────────────────────────────────────
+
+_PY_FROM_RE = re.compile(r"^from (bincio\.\S+) import", re.MULTILINE)
+_PY_IMP_RE = re.compile(r"^import (bincio\.\S+)", re.MULTILINE)
+
+
+def extract_py_imports(file_path: Path, py_files: list[Path]) -> list[Path]:
+ text = read(file_path)
+ modules = set()
+ for m in _PY_FROM_RE.finditer(text):
+ modules.add(m.group(1))
+ for m in _PY_IMP_RE.finditer(text):
+ modules.add(m.group(1))
+
+ results = []
+ for mod in modules:
+ # bincio.serve.db → bincio/serve/db.py
+ candidate = ROOT / Path(*mod.split(".")).with_suffix(".py")
+ if candidate.exists() and candidate != file_path:
+ results.append(candidate)
+ return results
+
+
+# ── 5. Collect all data ───────────────────────────────────────────────────────
+
+def collect() -> dict:
+ server_path = ROOT / "bincio" / "serve" / "server.py"
+ routes = extract_routes(server_path)
+
+ # Frontend files
+ fe_files = list(SITE_SRC.rglob("*.svelte")) + list(SITE_SRC.rglob("*.astro"))
+
+ # Python files (bincio package only)
+ py_files = [
+ p for p in (ROOT / "bincio").rglob("*.py")
+ if "__pycache__" not in str(p) and p.name != "__init__.py"
+ ]
+
+ # --- edges: page/component → API endpoint
+ api_edges = [] # (source_file, route_path)
+ for f in fe_files:
+ calls = extract_api_calls(f)
+ for call in calls:
+ norm = normalise_route(call, routes)
+ api_edges.append((f, norm))
+
+ # --- edges: component imports
+ comp_edges = [] # (importer_file, imported_file)
+ for f in fe_files:
+ for dep in extract_component_imports(f):
+ comp_edges.append((f, dep))
+
+ # --- edges: python imports
+ py_edges = [] # (importer_file, imported_file)
+ for f in py_files:
+ for dep in extract_py_imports(f, py_files):
+ py_edges.append((f, dep))
+
+ return {
+ "routes": routes,
+ "fe_files": fe_files,
+ "py_files": py_files,
+ "api_edges": api_edges,
+ "comp_edges": comp_edges,
+ "py_edges": py_edges,
+ }
+
+
+# ── 6. Mermaid output ─────────────────────────────────────────────────────────
+
+def to_node_id(path: Path) -> str:
+ return re.sub(r"[^a-zA-Z0-9]", "_", str(path.relative_to(ROOT)))
+
+
+def write_mermaid(data: dict) -> Path:
+ lines = ["graph LR", ""]
+
+ routes = data["routes"]
+
+ # Subgraph: API endpoints grouped by domain
+ domains: dict[str, list[dict]] = {}
+ for r in routes:
+ parts = r["path"].strip("/").split("/")
+ domain = parts[1] if len(parts) > 1 else "other"
+ domains.setdefault(domain, []).append(r)
+
+ lines.append(" subgraph API")
+ for domain, rs in sorted(domains.items()):
+ lines.append(f" subgraph api_{domain}[\"{domain}\"]")
+ for r in rs:
+ nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", r["path"])
+ lines.append(f' {nid}["{r["method"]} {r["path"]}"]')
+ lines.append(" end")
+ lines.append(" end")
+ lines.append("")
+
+ # Subgraph: pages
+ pages = [f for f in data["fe_files"] if "/pages/" in str(f)]
+ lines.append(" subgraph Pages")
+ for f in sorted(pages):
+ nid = to_node_id(f)
+ label = short(f, ROOT)
+ lines.append(f' {nid}["{label}"]')
+ lines.append(" end")
+ lines.append("")
+
+ # Subgraph: components
+ comps = [f for f in data["fe_files"] if "/components/" in str(f)]
+ lines.append(" subgraph Components")
+ for f in sorted(comps):
+ nid = to_node_id(f)
+ label = short(f, ROOT)
+ lines.append(f' {nid}["{label}"]')
+ lines.append(" end")
+ lines.append("")
+
+ # Subgraph: Python modules
+ py_groups: dict[str, list[Path]] = {}
+ for f in data["py_files"]:
+ rel = f.relative_to(ROOT / "bincio")
+ group = rel.parts[0] if len(rel.parts) > 1 else "root"
+ py_groups.setdefault(group, []).append(f)
+
+ lines.append(" subgraph Python")
+ for group, files in sorted(py_groups.items()):
+ lines.append(f' subgraph py_{group}["{group}"]')
+ for f in sorted(files):
+ nid = to_node_id(f)
+ lines.append(f' {nid}["{f.stem}"]')
+ lines.append(" end")
+ lines.append(" end")
+ lines.append("")
+
+ # Edges: page/component → API
+ seen = set()
+ for src, route_path in data["api_edges"]:
+ src_nid = to_node_id(src)
+ dst_nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", route_path)
+ edge = f" {src_nid} -->|fetch| {dst_nid}"
+ if edge not in seen:
+ lines.append(edge)
+ seen.add(edge)
+
+ # Edges: component imports
+ seen_comp = set()
+ for src, dst in data["comp_edges"]:
+ src_nid = to_node_id(src)
+ dst_nid = to_node_id(dst)
+ edge = f" {src_nid} --> {dst_nid}"
+ if edge not in seen_comp:
+ lines.append(edge)
+ seen_comp.add(edge)
+
+ # Edges: python imports
+ seen_py = set()
+ for src, dst in data["py_edges"]:
+ src_nid = to_node_id(src)
+ dst_nid = to_node_id(dst)
+ edge = f" {src_nid} --> {dst_nid}"
+ if edge not in seen_py:
+ lines.append(edge)
+ seen_py.add(edge)
+
+ out = DOCS / "architecture.mmd"
+ out.write_text("\n".join(lines), encoding="utf-8")
+ return out
+
+
+# ── 7. vis.js HTML output ─────────────────────────────────────────────────────
+
+def write_visjs(data: dict) -> Path:
+ nodes: list[dict] = []
+ edges: list[dict] = []
+ node_ids: dict[str, int] = {}
+
+ def add_node(key: str, label: str, group: str, title: str = "") -> int:
+ if key in node_ids:
+ return node_ids[key]
+ nid = len(nodes)
+ node_ids[key] = nid
+ nodes.append({"id": nid, "label": label, "group": group, "title": title or label})
+ return nid
+
+ def add_edge(src_key: str, dst_key: str, label: str = "") -> None:
+ if src_key not in node_ids or dst_key not in node_ids:
+ return
+ e: dict = {"from": node_ids[src_key], "to": node_ids[dst_key], "arrows": "to"}
+ if label:
+ e["label"] = label
+ edges.append(e)
+
+ # API endpoint nodes
+ for r in data["routes"]:
+ key = f"api:{r['path']}"
+ label = f"{r['method']}\n{r['path']}"
+ add_node(key, label, "api", f"{r['method']} {r['path']} → {r['fn']}()")
+
+ # Frontend file nodes
+ for f in data["fe_files"]:
+ key = str(f)
+ label = f.name.replace("/index.astro", "/").replace("index.astro", f.parent.name + "/")
+ is_page = "/pages/" in str(f)
+ is_layout = "/layouts/" in str(f)
+ group = "page" if is_page else ("layout" if is_layout else "component")
+ title = short(f, ROOT)
+ add_node(key, label, group, title)
+
+ # Python module nodes
+ for f in data["py_files"]:
+ key = str(f)
+ rel = f.relative_to(ROOT / "bincio")
+ group = "py_" + rel.parts[0] if len(rel.parts) > 1 else "py_root"
+ add_node(key, f.stem, group, str(f.relative_to(ROOT)))
+
+ # Edges: page/component → API
+ seen = set()
+ for src, route_path in data["api_edges"]:
+ src_key = str(src)
+ dst_key = f"api:{route_path}"
+ k = (src_key, dst_key)
+ if k not in seen:
+ seen.add(k)
+ add_edge(src_key, dst_key, "fetch")
+
+ # Edges: component imports
+ seen_comp = set()
+ for src, dst in data["comp_edges"]:
+ k = (str(src), str(dst))
+ if k not in seen_comp:
+ seen_comp.add(k)
+ add_edge(str(src), str(dst))
+
+ # Edges: python imports
+ seen_py = set()
+ for src, dst in data["py_edges"]:
+ k = (str(src), str(dst))
+ if k not in seen_py:
+ seen_py.add(k)
+ add_edge(str(src), str(dst))
+
+ # Group colours for legend
+ groups = {
+ "api": {"color": {"background": "#f59e0b", "border": "#d97706"}, "font": {"color": "#000"}},
+ "page": {"color": {"background": "#3b82f6", "border": "#2563eb"}, "font": {"color": "#fff"}},
+ "component": {"color": {"background": "#8b5cf6", "border": "#7c3aed"}, "font": {"color": "#fff"}},
+ "layout": {"color": {"background": "#06b6d4", "border": "#0891b2"}, "font": {"color": "#000"}},
+ "py_extract": {"color": {"background": "#22c55e", "border": "#16a34a"}, "font": {"color": "#000"}},
+ "py_render": {"color": {"background": "#84cc16", "border": "#65a30d"}, "font": {"color": "#000"}},
+ "py_serve": {"color": {"background": "#ef4444", "border": "#dc2626"}, "font": {"color": "#fff"}},
+ "py_edit": {"color": {"background": "#f97316", "border": "#ea580c"}, "font": {"color": "#fff"}},
+ "py_root": {"color": {"background": "#6b7280", "border": "#4b5563"}, "font": {"color": "#fff"}},
+ }
+
+ html = f"""
+
+
+
+Bincio — architecture graph
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ out = DOCS / "graph.html"
+ out.write_text(html, encoding="utf-8")
+ return out
+
+
+# ── main ──────────────────────────────────────────────────────────────────────
+
+if __name__ == "__main__":
+ print("Collecting codebase graph data…")
+ data = collect()
+
+ r = len(data["routes"])
+ f = len(data["fe_files"])
+ p = len(data["py_files"])
+ ae = len(data["api_edges"])
+ ce = len(data["comp_edges"])
+ pe = len(data["py_edges"])
+ print(f" {r} API routes | {f} frontend files | {p} Python modules")
+ print(f" {ae} API call edges | {ce} component import edges | {pe} Python import edges")
+
+ mmd = write_mermaid(data)
+ print(f"\nMermaid → {mmd.relative_to(ROOT)}")
+
+ html = write_visjs(data)
+ print(f"vis.js → {html.relative_to(ROOT)}")
+ print("\nOpen docs/graph.html in a browser to explore interactively.")
From fcc70a8d90eecaaa857ba96d95f87fbe936ccf9f Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 22:48:37 +0200
Subject: [PATCH 072/124] fix graph.html: set explicit pixel height for vis.js
container
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
vis.js requires a pixel-sized container — flex:1 is ignored.
Use position:fixed toolbar + JS-measured height for the graph div,
stored as window._network for resize handling.
---
docs/graph.html | 66 ++++++++++++++++++++++++++------------------
scripts/gen_graph.py | 24 ++++++++++++----
2 files changed, 57 insertions(+), 33 deletions(-)
diff --git a/docs/graph.html b/docs/graph.html
index 5962988..14c6d20 100644
--- a/docs/graph.html
+++ b/docs/graph.html
@@ -6,17 +6,17 @@
@@ -999,7 +999,7 @@ const allEdges = [
},
{
"from": 66,
- "to": 71,
+ "to": 74,
"arrows": "to"
},
{
@@ -1009,12 +1009,7 @@ const allEdges = [
},
{
"from": 66,
- "to": 67,
- "arrows": "to"
- },
- {
- "from": 66,
- "to": 74,
+ "to": 69,
"arrows": "to"
},
{
@@ -1024,7 +1019,7 @@ const allEdges = [
},
{
"from": 66,
- "to": 69,
+ "to": 71,
"arrows": "to"
},
{
@@ -1032,6 +1027,11 @@ const allEdges = [
"to": 92,
"arrows": "to"
},
+ {
+ "from": 66,
+ "to": 67,
+ "arrows": "to"
+ },
{
"from": 70,
"to": 82,
@@ -1089,12 +1089,12 @@ const allEdges = [
},
{
"from": 87,
- "to": 82,
+ "to": 77,
"arrows": "to"
},
{
"from": 87,
- "to": 77,
+ "to": 76,
"arrows": "to"
},
{
@@ -1104,7 +1104,7 @@ const allEdges = [
},
{
"from": 87,
- "to": 76,
+ "to": 82,
"arrows": "to"
},
{
@@ -1114,12 +1114,12 @@ const allEdges = [
},
{
"from": 91,
- "to": 73,
+ "to": 90,
"arrows": "to"
},
{
"from": 91,
- "to": 90,
+ "to": 73,
"arrows": "to"
},
{
@@ -1142,6 +1142,11 @@ const allEdges = [
"to": 85,
"arrows": "to"
},
+ {
+ "from": 96,
+ "to": 85,
+ "arrows": "to"
+ },
{
"from": 96,
"to": 82,
@@ -1153,8 +1158,8 @@ const allEdges = [
"arrows": "to"
},
{
- "from": 96,
- "to": 85,
+ "from": 97,
+ "to": 96,
"arrows": "to"
},
{
@@ -1164,7 +1169,7 @@ const allEdges = [
},
{
"from": 97,
- "to": 96,
+ "to": 94,
"arrows": "to"
},
{
@@ -1177,11 +1182,6 @@ const allEdges = [
"to": 95,
"arrows": "to"
},
- {
- "from": 97,
- "to": 94,
- "arrows": "to"
- },
{
"from": 98,
"to": 82,
@@ -1272,6 +1272,17 @@ const groups = {
}
};
+// Size the graph container to fill below the toolbar
+function sizeGraph() {
+ const tb = document.getElementById('toolbar');
+ const g = document.getElementById('graph');
+ const h = tb.getBoundingClientRect().height;
+ g.style.top = h + 'px';
+ g.style.height = (window.innerHeight - h) + 'px';
+}
+sizeGraph();
+window.addEventListener('resize', () => { sizeGraph(); if (window._network) window._network.redraw(); });
+
const nodesDS = new vis.DataSet(allNodes);
const edgesDS = new vis.DataSet(allEdges);
@@ -1305,6 +1316,7 @@ const options = {
};
const network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, options);
+window._network = network;
// Info count
document.getElementById('info').textContent =
diff --git a/scripts/gen_graph.py b/scripts/gen_graph.py
index c96932c..262d0ca 100644
--- a/scripts/gen_graph.py
+++ b/scripts/gen_graph.py
@@ -380,17 +380,17 @@ def write_visjs(data: dict) -> Path:
@@ -418,6 +418,17 @@ const allNodes = {json.dumps(nodes, indent=2)};
const allEdges = {json.dumps(edges, indent=2)};
const groups = {json.dumps(groups, indent=2)};
+// Size the graph container to fill below the toolbar
+function sizeGraph() {{
+ const tb = document.getElementById('toolbar');
+ const g = document.getElementById('graph');
+ const h = tb.getBoundingClientRect().height;
+ g.style.top = h + 'px';
+ g.style.height = (window.innerHeight - h) + 'px';
+}}
+sizeGraph();
+window.addEventListener('resize', () => {{ sizeGraph(); if (window._network) window._network.redraw(); }});
+
const nodesDS = new vis.DataSet(allNodes);
const edgesDS = new vis.DataSet(allEdges);
@@ -451,6 +462,7 @@ const options = {{
}};
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, options);
+window._network = network;
// Info count
document.getElementById('info').textContent =
From 1e30f85bdc7498857cf1f7013d1d060e6554b953 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Tue, 14 Apr 2026 22:53:31 +0200
Subject: [PATCH 073/124] add structured logging and admin diagnostics to serve
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- bincio.serve logger wired into uvicorn output: rebuild steps, upload
errors, strava-zip progress all now appear in the server log
- _trigger_rebuild: capture stdout/stderr, log errors instead of silently
discarding; exceptions logged with traceback instead of swallowed
- upload handler: log per-file errors with traceback; include error detail
in the SSE event sent back to the browser
- strava-zip handler: log imported/error counts on completion
- GET /api/admin/users/{handle}/diag: snapshot of a user's data dir
(file counts, sizes, index activity counts, pending uploads)
- POST /api/admin/users/{handle}/rebuild-sync: blocking rebuild that
returns full stdout/stderr — for debugging without SSH log access
- Admin page: Diag button per user opens a modal showing the diag JSON
---
bincio/serve/cli.py | 9 +-
bincio/serve/server.py | 184 ++++++++++++++++++++++++++++---
site/src/pages/admin/index.astro | 43 +++++++-
3 files changed, 216 insertions(+), 20 deletions(-)
diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py
index 9825cc0..81b3c18 100644
--- a/bincio/serve/cli.py
+++ b/bincio/serve/cli.py
@@ -76,4 +76,11 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
console.print(f" Users: [dim]unlimited[/dim]")
console.print()
- uvicorn.run(srv.app, host=host, port=port, log_level="info")
+ log_config = uvicorn.config.LOGGING_CONFIG.copy()
+ # Make bincio.serve logger emit at INFO through uvicorn's handler
+ log_config["loggers"]["bincio.serve"] = {
+ "handlers": ["default"],
+ "level": "INFO",
+ "propagate": False,
+ }
+ uvicorn.run(srv.app, host=host, port=port, log_level="info", log_config=log_config)
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index c8ffe24..4e862bb 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -9,6 +9,7 @@ Run via `bincio serve` CLI command.
from __future__ import annotations
import json
+import logging
import re
import secrets
import shutil
@@ -19,6 +20,8 @@ import uuid
from pathlib import Path
from typing import Any, Optional
+log = logging.getLogger("bincio.serve")
+
from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
@@ -246,37 +249,52 @@ def _trigger_rebuild(handle: str) -> None:
# Fast: only update data, skip Astro build.
# Serialised with the same lock: merge_all wipes and recreates
# _merged/activities/ — concurrent runs would corrupt each other.
+ log.info("rebuild[%s]: merge-only (no webroot)", _handle)
with _rebuild_lock:
- subprocess.run(
+ result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle,
"--no-build"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
)
+ if result.returncode != 0:
+ log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
+ _handle, result.returncode, result.stdout, result.stderr)
+ else:
+ log.info("rebuild[%s]: merge done", _handle)
else:
# Full build + rsync — serialised so concurrent uploads don't race
+ log.info("rebuild[%s]: full build + rsync to %s", _handle, _webroot)
with _rebuild_lock:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
)
- if result.returncode == 0:
- # Rsync built site to nginx webroot
- subprocess.run(
+ if result.returncode != 0:
+ log.error("rebuild[%s]: build failed (rc=%d):\n%s\n%s",
+ _handle, result.returncode, result.stdout, result.stderr)
+ else:
+ log.info("rebuild[%s]: build done, rsyncing", _handle)
+ rsync = subprocess.run(
["rsync", "-a", "--delete",
f"{_site_dir}/dist/", _webroot + "/"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
)
+ if rsync.returncode != 0:
+ log.error("rebuild[%s]: rsync failed (rc=%d):\n%s\n%s",
+ _handle, rsync.returncode, rsync.stdout, rsync.stderr)
+ else:
+ log.info("rebuild[%s]: rsync done", _handle)
except Exception:
- pass # rebuild failure must never affect the calling request
+ log.exception("rebuild[%s]: unexpected error", _handle)
threading.Thread(target=_run, daemon=True).start()
@@ -551,6 +569,127 @@ async def admin_rebuild(
return JSONResponse({"ok": True})
+@app.post("/api/admin/users/{handle}/rebuild-sync")
+async def admin_rebuild_sync(
+ handle: str,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Run merge+rebuild synchronously and return full output. Admin only.
+
+ Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr.
+ Use for debugging when you need to see what went wrong.
+ """
+ _require_admin(bincio_session)
+ user_dir = _get_data_dir() / handle
+ if not user_dir.is_dir():
+ raise HTTPException(404, f"No data directory for user '{handle}'")
+ if site_dir is None:
+ raise HTTPException(503, "Server has no --site-dir configured; rebuild not available")
+
+ uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
+ cmd = [uv, "run", "bincio", "render",
+ "--data-dir", str(data_dir),
+ "--site-dir", str(site_dir),
+ "--handle", handle,
+ "--no-build"]
+ if webroot:
+ cmd = [uv, "run", "bincio", "render",
+ "--data-dir", str(data_dir),
+ "--site-dir", str(site_dir),
+ "--handle", handle]
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+ resp: dict[str, Any] = {
+ "ok": result.returncode == 0,
+ "returncode": result.returncode,
+ "stdout": result.stdout,
+ "stderr": result.stderr,
+ }
+
+ if result.returncode == 0 and webroot:
+ rsync = subprocess.run(
+ ["rsync", "-a", "--delete", f"{site_dir}/dist/", str(webroot) + "/"],
+ capture_output=True, text=True, timeout=120,
+ )
+ resp["rsync_returncode"] = rsync.returncode
+ resp["rsync_stdout"] = rsync.stdout
+ resp["rsync_stderr"] = rsync.stderr
+ resp["ok"] = rsync.returncode == 0
+
+ return JSONResponse(resp)
+
+
+@app.get("/api/admin/users/{handle}/diag")
+async def admin_diag(
+ handle: str,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Return a diagnostic snapshot of a user's data directory. Admin only."""
+ _require_admin(bincio_session)
+ user_dir = _get_data_dir() / handle
+ if not user_dir.is_dir():
+ raise HTTPException(404, f"No data directory for user '{handle}'")
+
+ def _count(path: Path, glob: str = "*") -> int:
+ return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0
+
+ def _size_mb(path: Path) -> float:
+ if not path.exists():
+ return 0.0
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576
+
+ activities_dir = user_dir / "activities"
+ merged_dir = user_dir / "_merged"
+ originals_dir = user_dir / "originals"
+ uploads_dir = user_dir / "_uploads"
+
+ merged_index = merged_dir / "index.json"
+ root_index = user_dir / "index.json"
+
+ merged_activity_count: int | None = None
+ if merged_index.exists():
+ try:
+ idx = json.loads(merged_index.read_text())
+ merged_activity_count = len(idx.get("activities", []))
+ except Exception:
+ merged_activity_count = -1
+
+ root_activity_count: int | None = None
+ if root_index.exists():
+ try:
+ idx = json.loads(root_index.read_text())
+ root_activity_count = len(idx.get("activities", []))
+ except Exception:
+ root_activity_count = -1
+
+ return JSONResponse({
+ "handle": handle,
+ "user_dir": str(user_dir),
+ "activities": {
+ "json_files": _count(activities_dir, "*.json"),
+ "geojson_files": _count(activities_dir, "*.geojson"),
+ "size_mb": round(_size_mb(activities_dir), 2),
+ },
+ "originals": {
+ "exists": originals_dir.exists(),
+ "size_mb": round(_size_mb(originals_dir), 2),
+ "strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0,
+ },
+ "merged": {
+ "exists": merged_dir.exists(),
+ "activity_count_in_index": merged_activity_count,
+ "size_mb": round(_size_mb(merged_dir), 2),
+ },
+ "root_index": {
+ "exists": root_index.exists(),
+ "activity_count": root_activity_count,
+ },
+ "pending_uploads": _count(uploads_dir),
+ "dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(),
+ "athlete_json_exists": (user_dir / "athlete.json").exists(),
+ })
+
+
def _wipe_user_activities(user_dir: Path) -> int:
"""Delete all extracted activity files and caches for a user.
@@ -937,9 +1076,10 @@ async def upload_activity(
added += 1
any_added = True
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'imported'})}\n\n"
- except Exception:
+ except Exception as exc:
errors += 1
- yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error'})}\n\n"
+ log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True)
+ yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n"
finally:
if not kept:
staged.unlink(missing_ok=True)
@@ -999,18 +1139,32 @@ async def upload_strava_zip(
from bincio.extract.strava_zip import strava_zip_iter
from bincio.render.merge import merge_all
+ log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy)
+
def event_stream():
any_imported = False
+ imported_count = 0
+ error_count = 0
try:
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
+ if event.get("type") == "progress":
+ status = event.get("status")
+ if status == "imported":
+ any_imported = True
+ imported_count += 1
+ elif status == "error":
+ error_count += 1
+ log.warning("strava-zip[%s]: error on %s: %s",
+ user.handle, event.get("name"), event.get("detail", ""))
if event.get("type") == "done":
+ log.info("strava-zip[%s]: done — imported=%d errors=%d",
+ user.handle, imported_count, error_count)
if any_imported:
merge_all(dd)
_trigger_rebuild(user.handle)
except Exception as exc:
+ log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
finally:
zip_path.unlink(missing_ok=True)
diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro
index 3a977ec..8f6502c 100644
--- a/site/src/pages/admin/index.astro
+++ b/site/src/pages/admin/index.astro
@@ -31,6 +31,15 @@ import Base from '../../layouts/Base.astro';
+
+
+
+
Data directory snapshot —
+ Close
+
+
+
+
Reset all data for ?
@@ -44,10 +53,15 @@ import Base from '../../layouts/Base.astro';
From 87a69bcc8bdf8c7839edd3eeebdf5b973da48fe0 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Wed, 15 Apr 2026 20:37:42 +0200
Subject: [PATCH 089/124] settings: add nav visibility prefs and per-user
Strava credentials
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- user_prefs table in db.py with get/set helpers
- GET/PUT /api/me/prefs endpoints for bulk pref management
- GET/PUT/DELETE /api/me/strava-credentials; PUT preserves existing
secret when client_secret field is left blank
- _strava_creds() helper resolves per-user → instance fallback across
all five Strava endpoints
- Settings page: Navigation card (hide Feed/Community/About toggles)
and Strava credentials card
- Base.astro: ids on feed/community/about nav links; applies
nav_hide_* prefs after login
---
bincio/serve/db.py | 38 ++++++
bincio/serve/server.py | 136 ++++++++++++++++++++--
site/src/layouts/Base.astro | 25 +++-
site/src/pages/settings/index.astro | 172 ++++++++++++++++++++++++++++
4 files changed, 358 insertions(+), 13 deletions(-)
diff --git a/bincio/serve/db.py b/bincio/serve/db.py
index 9a901cf..96f450e 100644
--- a/bincio/serve/db.py
+++ b/bincio/serve/db.py
@@ -59,9 +59,17 @@ CREATE TABLE IF NOT EXISTS settings (
value TEXT NOT NULL
);
+CREATE TABLE IF NOT EXISTS user_prefs (
+ handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY (handle, key)
+);
+
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
+CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle);
"""
_SESSION_DAYS = 30
@@ -361,6 +369,36 @@ def create_reset_code(db: sqlite3.Connection, handle: str, created_by: str) -> s
return code
+# ── User preferences ─────────────────────────────────────────────────────────
+
+def get_user_prefs(db: sqlite3.Connection, handle: str) -> dict[str, str]:
+ """Return all preferences for a user as a plain dict."""
+ rows = db.execute(
+ "SELECT key, value FROM user_prefs WHERE handle = ?", (handle,)
+ ).fetchall()
+ return {r["key"]: r["value"] for r in rows}
+
+
+def set_user_pref(db: sqlite3.Connection, handle: str, key: str, value: str) -> None:
+ db.execute(
+ "INSERT INTO user_prefs (handle, key, value) VALUES (?, ?, ?) "
+ "ON CONFLICT(handle, key) DO UPDATE SET value = excluded.value",
+ (handle, key, value),
+ )
+ db.commit()
+
+
+def set_user_prefs(db: sqlite3.Connection, handle: str, prefs: dict[str, str]) -> None:
+ """Bulk-upsert multiple preferences for a user."""
+ for key, value in prefs.items():
+ db.execute(
+ "INSERT INTO user_prefs (handle, key, value) VALUES (?, ?, ?) "
+ "ON CONFLICT(handle, key) DO UPDATE SET value = excluded.value",
+ (handle, key, value),
+ )
+ db.commit()
+
+
def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
"""Validate a reset code for the given handle and mark it used.
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index acac2dc..7d24c8e 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -41,6 +41,8 @@ from bincio.serve.db import (
get_session,
get_setting,
get_user,
+ get_user_prefs,
+ set_user_prefs,
list_invites,
list_users,
open_db,
@@ -99,6 +101,29 @@ def _get_db():
return _db
+_STRAVA_CREDS_FILE = "strava_credentials.json"
+
+
+def _strava_creds(handle: str) -> tuple[str, str]:
+ """Return (client_id, client_secret) for a user.
+
+ Per-user credentials stored in {user_dir}/strava_credentials.json take
+ precedence over the global instance-level strava_client_id/secret.
+ Returns ("", "") when neither is configured.
+ """
+ creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
+ if creds_path.exists():
+ try:
+ d = json.loads(creds_path.read_text(encoding="utf-8"))
+ cid = str(d.get("client_id", "")).strip()
+ csec = str(d.get("client_secret", "")).strip()
+ if cid and csec:
+ return cid, csec
+ except Exception:
+ pass
+ return strava_client_id, strava_client_secret
+
+
def _get_data_dir() -> Path:
if data_dir is None:
raise HTTPException(500, "Server not configured")
@@ -1013,6 +1038,92 @@ async def me_update_display_name(
return JSONResponse({"ok": True, "display_name": display_name})
+@app.get("/api/me/prefs")
+async def me_get_prefs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Return all user preferences as a key→value dict."""
+ user = _require_user(bincio_session)
+ return JSONResponse(get_user_prefs(_get_db(), user.handle))
+
+
+@app.put("/api/me/prefs")
+async def me_set_prefs(
+ request: Request,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
+ user = _require_user(bincio_session)
+ body = await request.json()
+ if not isinstance(body, dict):
+ raise HTTPException(400, "Body must be a JSON object")
+ # Coerce all values to strings; ignore unknown keys silently
+ prefs = {str(k): str(v) for k, v in body.items()}
+ set_user_prefs(_get_db(), user.handle, prefs)
+ return JSONResponse({"ok": True})
+
+
+@app.get("/api/me/strava-credentials")
+async def me_get_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Return whether per-user Strava credentials are configured (never returns the secret)."""
+ user = _require_user(bincio_session)
+ creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
+ has_user_creds = False
+ client_id_hint = ""
+ if creds_path.exists():
+ try:
+ d = json.loads(creds_path.read_text(encoding="utf-8"))
+ cid = str(d.get("client_id", "")).strip()
+ csec = str(d.get("client_secret", "")).strip()
+ if cid and csec:
+ has_user_creds = True
+ client_id_hint = cid
+ except Exception:
+ pass
+ return JSONResponse({
+ "has_user_creds": has_user_creds,
+ "client_id": client_id_hint,
+ "instance_configured": bool(strava_client_id),
+ })
+
+
+@app.put("/api/me/strava-credentials")
+async def me_set_strava_credentials(
+ request: Request,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Save per-user Strava credentials. Body: {client_id, client_secret}."""
+ user = _require_user(bincio_session)
+ body = await request.json()
+ cid = str(body.get("client_id", "")).strip()
+ csec = str(body.get("client_secret", "")).strip()
+ if not cid:
+ raise HTTPException(400, "client_id is required")
+ creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
+ # If client_secret is omitted, preserve existing secret (if any)
+ if not csec:
+ if creds_path.exists():
+ try:
+ existing = json.loads(creds_path.read_text(encoding="utf-8"))
+ csec = str(existing.get("client_secret", "")).strip()
+ except Exception:
+ pass
+ if not csec:
+ raise HTTPException(400, "client_secret is required (no existing secret to preserve)")
+ creds_path.write_text(
+ json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
+ encoding="utf-8",
+ )
+ return JSONResponse({"ok": True})
+
+
+@app.delete("/api/me/strava-credentials")
+async def me_delete_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
+ """Remove per-user Strava credentials (falls back to instance credentials)."""
+ user = _require_user(bincio_session)
+ creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
+ creds_path.unlink(missing_ok=True)
+ return JSONResponse({"ok": True})
+
+
@app.put("/api/me/password")
async def me_change_password(
request: Request,
@@ -1574,7 +1685,8 @@ _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:
+ cid, _ = _strava_creds(user.handle)
+ if not cid:
return JSONResponse({"configured": False, "connected": False, "last_sync": None})
dd = _get_data_dir() / user.handle
from bincio.extract.strava_api import load_token
@@ -1640,8 +1752,9 @@ async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(
@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:
+ user = _require_user(bincio_session)
+ cid, _ = _strava_creds(user.handle)
+ if not cid:
raise HTTPException(400, "Strava client ID not configured on this server")
state = secrets.token_urlsafe(16)
_strava_oauth_states.add(state)
@@ -1650,7 +1763,7 @@ async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cook
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)})
+ return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)})
@app.get("/api/strava/callback", name="strava_callback")
@@ -1670,12 +1783,13 @@ async def strava_callback(
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:
+ cid, csec = _strava_creds(user.handle)
+ if not cid or not csec:
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)
+ token = exchange_code(cid, csec, code)
except StravaError:
return RedirectResponse(f"{site_origin}/?strava=error")
save_token(dd, token)
@@ -1686,7 +1800,8 @@ async def strava_callback(
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:
+ cid, csec = _strava_creds(user.handle)
+ if not cid or not csec:
raise HTTPException(400, "Strava not configured on this server")
dd = _get_data_dir() / user.handle
store_orig_setting = get_setting(_get_db(), "store_originals")
@@ -1699,7 +1814,7 @@ async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(defaul
def event_stream():
try:
- for event in strava_sync_iter(dd, strava_client_id, strava_client_secret, originals_dir):
+ for event in strava_sync_iter(dd, cid, csec, originals_dir):
if event["type"] == "done":
_trigger_rebuild(user.handle) # start before client closes connection
yield f"data: {json.dumps(event)}\n\n"
@@ -1716,7 +1831,8 @@ async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(defaul
@app.post("/api/strava/sync")
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _require_user(bincio_session)
- if not strava_client_id or not strava_client_secret:
+ cid, csec = _strava_creds(user.handle)
+ if not cid or not csec:
raise HTTPException(400, "Strava not configured on this server")
dd = _get_data_dir() / user.handle
store_orig_setting = get_setting(_get_db(), "store_originals")
@@ -1726,7 +1842,7 @@ async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)
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, originals_dir=originals_dir)
+ result = run_strava_sync(dd, cid, csec, 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 85ab878..4ac1b65 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -166,7 +166,7 @@ try {
)}
@@ -511,6 +511,25 @@ try {
const chk = document.getElementById('upload-keep-original');
if (chk && user.store_originals_default) chk.checked = true;
+ // Apply nav visibility prefs
+ try {
+ const pr = await fetch('/api/me/prefs', { credentials: 'include' });
+ if (pr.ok) {
+ const prefs = await pr.json();
+ const navHideMap: Record = {
+ 'nav_hide_feed': 'nav-feed',
+ 'nav_hide_community': 'nav-community',
+ 'nav_hide_about': 'nav-about',
+ };
+ for (const [key, elId] of Object.entries(navHideMap)) {
+ if (prefs[key] === 'true') {
+ const el = document.getElementById(elId);
+ if (el) el.style.display = 'none';
+ }
+ }
+ }
+ } catch (_) {}
+
// Admin: show admin link and poll for active jobs
if (user.is_admin) {
const adminLink = document.getElementById('nav-admin');
diff --git a/site/src/pages/settings/index.astro b/site/src/pages/settings/index.astro
index b0d95b9..9443846 100644
--- a/site/src/pages/settings/index.astro
+++ b/site/src/pages/settings/index.astro
@@ -85,6 +85,58 @@ import Base from '../../layouts/Base.astro';
+
+
+
+
+
+
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';
-
-
-
- Original files are kept for reprocessing. Once your activities look correct you can free this space — the extracted data is not affected.
-
-
- Delete original files
-
-
-
@@ -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 original files
+
+
+
+
+
+
+
Delete all activity data
+
Wipes all extracted activities, edits, and photos. Your account and original files are kept. Cannot be undone.
Delete all activities
@@ -218,10 +218,6 @@ import Base from '../../layouts/Base.astro';
document.getElementById('st-strava-row')!.classList.remove('hidden');
}
- if (d.originals_mb > 0) {
- document.getElementById('del-originals-area')!.classList.remove('hidden');
- }
-
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (e: any) {
@@ -240,7 +236,8 @@ import Base from '../../layouts/Base.astro';
const d = await r.json();
if (r.ok) {
setStatus(statusEl, `Freed ${fmtMb(d.freed_mb)}.`, true);
- btn.closest('div')!.querySelector('button')!.remove();
+ btn.disabled = true;
+ btn.textContent = 'Already deleted';
loadStorage();
} else {
btn.disabled = false;
From a95dd07e224d5f146af4502fc5ed6afa2f6d668b Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Wed, 15 Apr 2026 20:48:11 +0200
Subject: [PATCH 091/124] fix: remove TS type annotation from define:vars
script (plain JS only)
---
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 4ac1b65..ee18dae 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -516,7 +516,7 @@ try {
const pr = await fetch('/api/me/prefs', { credentials: 'include' });
if (pr.ok) {
const prefs = await pr.json();
- const navHideMap: Record = {
+ const navHideMap = {
'nav_hide_feed': 'nav-feed',
'nav_hide_community': 'nav-community',
'nav_hide_about': 'nav-about',
From 5205a412249041f06505f6dab594b7a42e04feb0 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Wed, 15 Apr 2026 22:18:06 +0200
Subject: [PATCH 092/124] =?UTF-8?q?fix:=20theme-aware=20chart=20colors=20?=
=?UTF-8?q?=E2=80=94=20readable=20axes=20and=20tooltips=20in=20light=20mod?=
=?UTF-8?q?e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
site/src/components/ActivityCharts.svelte | 42 +++++++++++++++++------
site/src/components/MmpChart.svelte | 10 ++++--
2 files changed, 40 insertions(+), 12 deletions(-)
diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte
index b80deec..c23eef4 100644
--- a/site/src/components/ActivityCharts.svelte
+++ b/site/src/components/ActivityCharts.svelte
@@ -121,9 +121,27 @@
// Reset when switching away from a zone-capable metric or leaving histogram
$: if (!canAlignZones) alignZones = false;
+ // ── Theme-aware colours ──────────────────────────────────────────────────
+ function getThemeColors() {
+ const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+ return {
+ axis: isDark ? '#71717a' : '#52525b', // zinc-500 / zinc-600
+ rule: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)',
+ tooltipFg: isDark ? '#ffffff' : '#18181b',
+ tooltipBg: isDark ? '#09090b' : '#ffffff', // text outline backing
+ ruleY: isDark ? '#3f3f46' : '#d4d4d8', // baseline rule
+ };
+ }
+
// ── Rendering ────────────────────────────────────────────────────────────
- onMount(() => { renderChart(); });
- onDestroy(() => { chart?.remove(); chart = null; });
+ let themeObserver: MutationObserver | null = null;
+
+ onMount(() => {
+ renderChart();
+ themeObserver = new MutationObserver(() => renderChart());
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ });
+ onDestroy(() => { chart?.remove(); chart = null; themeObserver?.disconnect(); });
$: if (chartEl) {
activeTab; xMode; chartType; histData; histThresholds; alignZones;
@@ -162,6 +180,7 @@
function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) {
const x = xMode === 'distance' ? 'dist_km' : 't';
+ const tc = getThemeColors();
const marks: any[] = [];
if (activeTab === 'cadence') {
@@ -174,12 +193,14 @@
}
marks.push(
- Plot.ruleX(data, Plot.pointerX({ x, stroke: 'rgba(255,255,255,0.3)', strokeWidth: 1, strokeDasharray: '4,4' })),
- Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: 'white', strokeWidth: 1.5 })),
+ Plot.ruleX(data, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })),
+ Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: tc.tooltipBg, strokeWidth: 1.5 })),
Plot.text(data, Plot.pointerX({
x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
- dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
+ dy: -12,
+ fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
+ fontSize: 11, fontWeight: '600',
})),
);
@@ -193,7 +214,7 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
- style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
+ style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 },
y: { label: yLabel, grid: true, tickCount: 4 },
marks,
@@ -204,6 +225,7 @@
const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`;
const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null;
const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS;
+ const tc = getThemeColors();
// ── Zone-aligned: one colored bar per zone ──────────────────────────────
if (alignZones && rawZones?.length) {
@@ -224,7 +246,7 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
- style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
+ style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: yLabel, grid: false, domain: [clampedZones[0][0], clampedZones[clampedZones.length - 1][1]] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks: [
@@ -240,7 +262,7 @@
fontSize: 10, fontWeight: '600',
dy: -8,
}),
- Plot.ruleY([0], { stroke: '#52525b' }),
+ Plot.ruleY([0], { stroke: tc.ruleY }),
],
});
}
@@ -251,7 +273,7 @@
{ y: 'count' },
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
)),
- Plot.ruleY([0], { stroke: '#52525b' }),
+ Plot.ruleY([0], { stroke: tc.ruleY }),
];
if (rawZones?.length) {
@@ -282,7 +304,7 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
- style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
+ style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks,
diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte
index 40e3744..7432f1a 100644
--- a/site/src/components/MmpChart.svelte
+++ b/site/src/components/MmpChart.svelte
@@ -82,6 +82,10 @@
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
+ function getAxisColor() {
+ return document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
+ }
+
function renderChart(data: typeof plotData, cmap: typeof colorMap) {
if (!chartEl) return;
chartEl.innerHTML = '';
@@ -95,7 +99,7 @@
height: 320,
marginLeft: 52,
marginBottom: 40,
- style: { background: 'transparent', color: '#e4e4e7' },
+ style: { background: 'transparent', color: getAxisColor() },
x: {
type: 'log',
label: 'Duration',
@@ -160,7 +164,9 @@
onMount(() => {
const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap));
ro.observe(chartEl);
- return () => ro.disconnect();
+ const mo = new MutationObserver(() => renderChart(currentPlotData, currentColorMap));
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ return () => { ro.disconnect(); mo.disconnect(); };
});
// ── Toggle helpers ─────────────────────────────────────────────────────────
From bfb6432666bb455c069a687e928e1e7e2c3efddf Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Wed, 15 Apr 2026 22:53:48 +0200
Subject: [PATCH 093/124] fix: force black text in Plot tooltips (white bg,
grey text was unreadable)
---
site/src/components/MmpChart.svelte | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte
index 7432f1a..cbdb4bf 100644
--- a/site/src/components/MmpChart.svelte
+++ b/site/src/components/MmpChart.svelte
@@ -187,6 +187,11 @@
];
+
+
{#each allRangeKeys as key, i}
From 395182649b136831f3323dbd3122c4d7d028a640 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Wed, 15 Apr 2026 23:07:52 +0200
Subject: [PATCH 094/124] improve docs
---
.gitignore | 3 +
README.md | 3 +-
bincio/serve/server.py | 124 ++++++++++----
docs/admin-guide.md | 306 +++++++++++++++++++++++++++++++++
docs/architecture.md | 2 +-
docs/deployment/multi-user.md | 2 +-
docs/developer-guide.md | 307 ++++++++++++++++++++++++++++++++++
docs/getting-started.md | 2 +-
docs/index.md | 38 +++++
SCHEMA.md => docs/schema.md | 0
docs/user-guide.md | 180 ++++++++++++++++++++
mkdocs.yml | 66 ++++++++
pyproject.toml | 4 +
13 files changed, 1004 insertions(+), 33 deletions(-)
create mode 100644 docs/admin-guide.md
create mode 100644 docs/developer-guide.md
create mode 100644 docs/index.md
rename SCHEMA.md => docs/schema.md (100%)
create mode 100644 docs/user-guide.md
create mode 100644 mkdocs.yml
diff --git a/.gitignore b/.gitignore
index f20ab04..fa7a52d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,9 @@ site/node_modules/
site/dist/
site/.astro/
+# MkDocs
+mkdocs-site/
+
# BAS data stores (user data, not committed to the tool repo)
bincio_data/
*.bincio_cache.json
diff --git a/README.md b/README.md
index 6160ab4..a2cb2f5 100644
--- a/README.md
+++ b/README.md
@@ -203,7 +203,7 @@ Privacy is enforced at extract time. A `private` activity never enters `index.js
`index.json` is everything the feed page needs — no extra fetches until you open an activity. `{id}.json` contains the full timeseries (elevation, speed, HR, cadence, power at 1 Hz) for charts and the detail map. Both are human-readable and editable with any text editor.
-See [SCHEMA.md](SCHEMA.md) for the full specification.
+See [SCHEMA.md](docs/schema.md) for the full specification.
---
@@ -293,7 +293,6 @@ bincio/ Python package
server.py FastAPI write API (activity edits, image + file upload)
schema/
bas-v1.schema.json JSON Schema for BAS format
-SCHEMA.md Human-readable BAS specification
site/ Astro project
src/
pages/
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 7d24c8e..e486139 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -49,6 +49,73 @@ from bincio.serve.db import (
use_invite,
)
+from pydantic import BaseModel, Field
+
+# ── Pydantic request/response models ─────────────────────────────────────────
+
+
+class LoginRequest(BaseModel):
+ handle: str = Field(..., description="User handle (username)")
+ password: str = Field(..., description="User password")
+
+
+class LoginResponse(BaseModel):
+ ok: bool = Field(True, description="Success flag")
+ handle: str = Field(..., description="User handle")
+ display_name: str = Field(..., description="User's display name")
+
+
+class ResetPasswordRequest(BaseModel):
+ handle: str = Field(..., description="User handle")
+ code: str = Field(..., description="Reset code (24 hours valid)")
+ password: str = Field(..., description="New password (min 8 chars)")
+
+
+class RegisterRequest(BaseModel):
+ code: str = Field(..., description="Invite code")
+ handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
+ password: str = Field(..., description="Password (min 8 characters)")
+ display_name: str = Field(default="", description="Full name (optional, defaults to handle)")
+
+
+class RegisterResponse(BaseModel):
+ ok: bool = Field(True, description="Success flag")
+ handle: str = Field(..., description="New user's handle")
+
+
+class CurrentUserResponse(BaseModel):
+ handle: str = Field(..., description="User handle")
+ display_name: str = Field(..., description="User's display name")
+ is_admin: bool = Field(..., description="Whether user is an admin")
+ store_originals_default: bool = Field(
+ default=True,
+ description="Instance-wide default for storing original files"
+ )
+
+
+class ActivityEditRequest(BaseModel):
+ title: str | None = Field(default=None, description="Activity title")
+ description: str | None = Field(default=None, description="Activity description (markdown)")
+ sport: str | None = Field(default=None, description="Sport type")
+ private: bool | None = Field(default=None, description="Hide from public feed")
+ highlight: bool | None = Field(default=None, description="Mark as favorite")
+ gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')")
+
+
+class ActivityEditResponse(BaseModel):
+ ok: bool = Field(True, description="Success flag")
+
+
+class ResetPasswordCodeResponse(BaseModel):
+ ok: bool = Field(True, description="Success flag")
+ code: str = Field(..., description="One-time reset code")
+ expires_in_hours: int = Field(24, description="Code validity period in hours")
+
+
+class GenericResponse(BaseModel):
+ ok: bool = Field(True, description="Success flag")
+
+
# ── Active job tracker ───────────────────────────────────────────────────────
# Tracks in-progress upload/processing jobs so admins can see what's running.
# Jobs are added when a streaming upload starts and removed when it finishes.
@@ -132,7 +199,7 @@ def _get_data_dir() -> Path:
# ── App ───────────────────────────────────────────────────────────────────────
-app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
+app = FastAPI(title="BincioActivity Serve")
@app.on_event("startup")
@@ -326,7 +393,7 @@ def _trigger_rebuild(handle: str) -> None:
# ── Auth endpoints ────────────────────────────────────────────────────────────
-@app.get("/api/me")
+@app.get("/api/me", response_model=CurrentUserResponse)
async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _current_user(bincio_session)
if not user:
@@ -361,14 +428,16 @@ async def stats() -> JSONResponse:
})
-@app.post("/api/auth/login")
-async def login(request: Request) -> JSONResponse:
+@app.post("/api/auth/login", response_model=LoginResponse)
+async def login(
+ login_req: LoginRequest,
+ request: Request,
+) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
- body = await request.json()
- handle = body.get("handle", "").strip().lower()
- password = body.get("password", "")
+ handle = login_req.handle.strip().lower()
+ password = login_req.password
user = authenticate(_get_db(), handle, password)
if not user:
@@ -380,7 +449,7 @@ async def login(request: Request) -> JSONResponse:
return resp
-@app.post("/api/auth/logout")
+@app.post("/api/auth/logout", response_model=GenericResponse)
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
if bincio_session:
delete_session(_get_db(), bincio_session)
@@ -389,16 +458,13 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp
-@app.post("/api/auth/reset-password")
-async def reset_password(request: Request) -> JSONResponse:
+@app.post("/api/auth/reset-password", response_model=GenericResponse)
+async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
"""Validate a reset code and set a new password. Public endpoint."""
from bincio.serve.db import use_reset_code, change_password
- body = await request.json()
- handle = (body.get("handle") or "").strip().lower()
- code = (body.get("code") or "").strip().upper()
- new_pw = body.get("password") or ""
- if not handle or not code or not new_pw:
- raise HTTPException(400, "handle, code, and password are required")
+ handle = reset_req.handle.strip().lower()
+ code = reset_req.code.strip().upper()
+ new_pw = reset_req.password
if len(new_pw) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
db = _get_db()
@@ -410,16 +476,18 @@ async def reset_password(request: Request) -> JSONResponse:
# ── Registration ──────────────────────────────────────────────────────────────
-@app.post("/api/register")
-async def register(request: Request) -> JSONResponse:
+@app.post("/api/register", response_model=RegisterResponse)
+async def register(
+ register_req: RegisterRequest,
+ request: Request,
+) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
- body = await request.json()
- code = body.get("code", "").strip().upper()
- handle = body.get("handle", "").strip().lower()
- password = body.get("password", "")
- display = body.get("display_name", "").strip() or handle
+ code = register_req.code.strip().upper()
+ handle = register_req.handle.strip().lower()
+ password = register_req.password
+ display = register_req.display_name.strip() or handle
if not _VALID_HANDLE.match(handle):
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
@@ -568,7 +636,7 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS
})
-@app.post("/api/admin/users/{handle}/reset-password-code")
+@app.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
async def admin_reset_password_code(
handle: str,
bincio_session: Optional[str] = Cookie(default=None),
@@ -1171,10 +1239,10 @@ async def get_activity(
return JSONResponse(json.loads(path.read_text()))
-@app.post("/api/activity/{activity_id}")
+@app.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
async def post_activity(
activity_id: str,
- request: Request,
+ edit_req: ActivityEditRequest,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
@@ -1185,13 +1253,13 @@ async def post_activity(
raise HTTPException(404, "Activity not found")
from bincio.edit.ops import apply_sidecar_edit
- body = await request.json()
+ body = edit_req.model_dump(exclude_none=True)
# apply_sidecar_edit already calls merge_one internally — no full rebuild needed.
apply_sidecar_edit(activity_id, body, dd)
return JSONResponse({"ok": True})
-@app.delete("/api/activity/{activity_id}")
+@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
activity_id: str,
bincio_session: Optional[str] = Cookie(default=None),
diff --git a/docs/admin-guide.md b/docs/admin-guide.md
new file mode 100644
index 0000000..f72588d
--- /dev/null
+++ b/docs/admin-guide.md
@@ -0,0 +1,306 @@
+# Administrator Guide
+
+This guide covers everything needed to deploy and maintain a multi-user BincioActivity instance.
+
+## Before You Start
+
+**[Multi-user Deployment](deployment/multi-user.md)** has the complete step-by-step instructions. This guide focuses on day-to-day admin tasks once the instance is running.
+
+## Initializing an Instance
+
+```bash
+uv sync --extra serve
+
+uv run bincio init \
+ --data-dir /var/bincio \
+ --handle your_admin_handle \
+ --display-name "Your Name" \
+ --name "Instance Name"
+```
+
+You'll be prompted for a password. This creates:
+
+- `/var/bincio/instance.db` — SQLite database (users, sessions, invites, reset codes)
+- `/var/bincio/index.json` — root shard manifest (`"private": true` by default)
+- Your admin user account
+- A first invite code
+
+`bincio init` is idempotent — safe to re-run.
+
+Optional flags:
+
+- `--max-users N` — limit total registered users (0 or omitted = unlimited)
+- `--store-originals false` — don't keep uploaded source files (defaults to true)
+
+## Inviting Users
+
+### Generate an invite code (as admin)
+
+From the web UI at `/invites/` (requires login as admin), or via CLI:
+
+```bash
+uv run python -c "
+from pathlib import Path
+from bincio.serve.db import open_db, create_invite
+db = open_db(Path('/var/bincio'))
+code = create_invite(db, 'your_handle')
+print(f'https://yourdomain.com/register/?code={code}')
+"
+```
+
+### Invite limits
+
+- **Admins:** unlimited invites
+- **Regular users:** 3 invites each (configurable in `bincio/serve/db.py` as `_MAX_USER_INVITES`)
+
+### Share the invite link
+
+Send the registration link to the user:
+
+```
+https://yourdomain.com/register/?code=ABCD1234
+```
+
+They create their own handle and password. After registration, they can:
+- Upload activity files (GPX, FIT, TCX)
+- Sync from Strava
+- Edit activity titles, descriptions, photos
+- Control privacy per activity
+
+## Password Reset
+
+BincioActivity has no email system. Password resets work via **admin-generated one-time codes**.
+
+### Reset a user's password (as admin)
+
+1. Open `/admin/` in the web UI (must be logged in as admin)
+2. Find the user and click **Reset password**
+3. A code appears (monospace, click to copy)
+4. Send the code out-of-band (Signal, Telegram, WhatsApp, etc.)
+
+The code is valid for **24 hours**. Users reset their password at `/reset-password/` by entering:
+
+- Their **handle**
+- The **code**
+- Their **new password**
+
+### Reset code API (CLI)
+
+To generate a reset code programmatically:
+
+```bash
+uv run python -c "
+from pathlib import Path
+from bincio.serve.db import open_db, create_reset_code
+db = open_db(Path('/var/bincio'))
+code, expires_in_hours = create_reset_code(db, 'user_handle', 'your_handle')
+print(f'Code: {code} (expires in {expires_in_hours} hours)')
+"
+```
+
+## Monitoring Active Jobs
+
+The `/api/admin/jobs` endpoint (admin-only) shows which uploads/syncs are in progress:
+
+```bash
+curl -b "bincio_session=$(cat /tmp/session.txt)" http://localhost:4041/api/admin/jobs
+```
+
+Returns:
+
+```json
+[
+ {
+ "id": "a1b2c3d4",
+ "user": "alice",
+ "started_at": 1712345678,
+ "total": 50,
+ "done": 23,
+ "current": "activity_2026-03-15_120000Z.fit"
+ }
+]
+```
+
+## Triggering Rebuilds
+
+`bincio serve` can trigger incremental rebuilds when you pass `--site-dir`:
+
+```bash
+uv run bincio serve \
+ --data-dir /var/bincio \
+ --site-dir /var/www/bincio/src/site
+```
+
+After any write operation (edit, upload, Strava sync), the affected user's shard is rebuilt automatically and the static site is updated.
+
+To manually rebuild a single user's shard:
+
+```bash
+uv run bincio render \
+ --data-dir /var/bincio \
+ --handle alice
+```
+
+To rebuild everything (slow):
+
+```bash
+uv run bincio render --data-dir /var/bincio
+```
+
+## Instance Settings
+
+Settings are stored in `instance.db` and control instance-wide behavior:
+
+| Setting | Default | Controls |
+|---------|---------|----------|
+| `max_users` | unlimited | Maximum registered users allowed |
+| `store_originals` | `true` | Keep uploaded source files and Strava sync data |
+
+Read/set settings via CLI:
+
+```bash
+uv run python -c "
+from pathlib import Path
+from bincio.serve.db import open_db, get_setting, set_setting
+db = open_db(Path('/var/bincio'))
+print(get_setting(db, 'max_users'))
+set_setting(db, 'max_users', 100)
+db.commit()
+"
+```
+
+Or check the database directly:
+
+```bash
+sqlite3 /var/bincio/instance.db
+> SELECT key, value FROM settings;
+```
+
+## Instance Privacy
+
+By default, new instances are **private** — only authenticated users can view anything. Edit the root `index.json` to toggle:
+
+```json
+{
+ "private": false,
+ "shards": [...]
+}
+```
+
+- **`"private": true`** — all pages (except login/register) require authentication
+- **`"private": false`** — public access to all activities; individual activities can still be marked private via the `private` flag in sidecars
+
+After any change, run `bincio render` to apply it:
+
+```bash
+uv run bincio render --data-dir /var/bincio
+```
+
+## Data Directory Layout
+
+```
+/var/bincio/
+ instance.db ← SQLite: users, sessions, invites, reset codes
+ index.json ← root shard manifest
+ {handle}/
+ index.json ← user's BAS feed (activities list)
+ _merged/ ← sidecar-merged output (served to browser)
+ activities/ ← extracted activity JSON files
+ {id}.json
+ ...
+ edits/ ← user-made sidecar edits
+ {id}.md
+ images/{id}/
+ athlete.json ← profile (from Strava or manual)
+ strava_token.json ← OAuth token (if synced from Strava)
+ originals/ ← source files (if store_originals=true)
+ _feedback/ ← user feedback submissions
+ {handle}.json
+ {handle}/
+ {timestamp}_{id}_{filename}
+```
+
+## Database Schema
+
+`instance.db` contains:
+
+- **`users`** — handle, password hash, display_name, is_admin, created_at
+- **`sessions`** — session_id, handle, created_at, expires_at
+- **`invites`** — code, created_by, created_at, used_by, used_at
+- **`reset_codes`** — code, handle, created_by, created_at, expires_at, used_at
+- **`settings`** — key, value (instance config)
+- **`user_preferences`** — handle, key, value (per-user settings)
+
+Query the database directly:
+
+```bash
+sqlite3 /var/bincio/instance.db ".tables"
+sqlite3 /var/bincio/instance.db "SELECT handle, is_admin FROM users;"
+```
+
+## API Endpoints for Admins
+
+The `/api/admin/*` endpoints require authentication and admin privileges:
+
+- `GET /api/admin/users` — List all users
+- `POST /api/admin/users/{handle}/reset-password-code` — Generate a reset code
+- `GET /api/admin/jobs` — Show active uploads/syncs
+- `GET /api/stats` — Community stats (public)
+
+See [API Reference](reference/api.md) for full details.
+
+### Explore the API with Swagger UI
+
+When `bincio serve` is running, visit `/api/docs` to see an interactive Swagger UI. You can:
+- Browse all endpoints with their parameters and response types
+- Try out requests directly (if you're logged in as admin)
+- See live examples of request/response bodies
+
+ReDoc (another API documentation format) is also available at `/api/redoc` with a different UI.
+
+## Running as a systemd service
+
+See [Multi-user Deployment](deployment/multi-user.md#step-5--start-bincio-serve) for the systemd unit file. Key points:
+
+- Set `User=bincio` (unprivileged user)
+- Set `WorkingDirectory` to the repo root
+- Use `--site-dir` to enable incremental rebuilds
+- Restart policy: `Restart=on-failure`
+
+Monitor with:
+
+```bash
+systemctl status bincio
+journalctl -u bincio -f
+```
+
+## Troubleshooting
+
+### Activities not appearing after upload
+
+1. Check if the job is still running: `GET /api/admin/jobs`
+2. Check logs: `journalctl -u bincio -f`
+3. If `store_originals=true`, verify the source file is readable in `{handle}/originals/`
+4. Re-trigger the merge: `uv run bincio render --data-dir /var/bincio --handle alice`
+
+### Database locked
+
+If you see "database is locked":
+
+1. Verify no other `bincio` processes are running: `ps aux | grep bincio`
+2. Kill any stuck processes: `pkill -f 'uv run bincio'`
+3. Restart the service: `systemctl restart bincio`
+
+### High memory usage
+
+The first rebuild on a large instance can be memory-intensive. Consider:
+
+- Running `bincio render` during off-hours
+- Rebuilding one user at a time: `uv run bincio render --data-dir /var/bincio --handle alice`
+- Increasing swap or upgrading the machine
+
+## See also
+
+- [Multi-user Deployment](deployment/multi-user.md) — complete step-by-step setup
+- [Single-user Deployment](deployment/single-user.md) — if you're hosting a read-only site
+- [API Reference](reference/api.md) — all HTTP endpoints
diff --git a/docs/architecture.md b/docs/architecture.md
index d90a3e3..d8974ea 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -21,7 +21,7 @@ GPX / FIT / TCX files
Any static host (GitHub Pages, Netlify, VPS, USB stick, …)
```
-The BAS data store is the contract between the two stages. Any tool in any language can produce BAS-compliant JSON. See [SCHEMA.md](../SCHEMA.md) for the format.
+The BAS data store is the contract between the two stages. Any tool in any language can produce BAS-compliant JSON. See [SCHEMA.md](schema.md) for the format.
---
diff --git a/docs/deployment/multi-user.md b/docs/deployment/multi-user.md
index 3ddae04..373d139 100644
--- a/docs/deployment/multi-user.md
+++ b/docs/deployment/multi-user.md
@@ -204,4 +204,4 @@ The browser fetches and merges remote shards concurrently. Remote activities app
- [CLI reference — bincio serve](../reference/cli.md#bincio-serve)
- [CLI reference — bincio dev](../reference/cli.md#bincio-dev)
- [API reference](../reference/api.md)
-- [BAS schema — instance manifest](../../SCHEMA.md#instance-manifest)
+- [BAS schema — instance manifest](../schema.md#instance-manifest)
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
new file mode 100644
index 0000000..8273931
--- /dev/null
+++ b/docs/developer-guide.md
@@ -0,0 +1,307 @@
+# Developer Guide
+
+This guide is for developers contributing to BincioActivity.
+
+## Prerequisites
+
+- **Python 3.12+** with [uv](https://docs.astral.sh/uv/)
+- **Node 20+** with npm
+- **Git**
+
+## Local Setup
+
+```bash
+git clone https://github.com/brutsalvadi/bincio-activity.git
+cd bincio-activity
+
+# Install Python dependencies
+uv sync
+
+# Install optional extras for multi-user development
+uv sync --extra serve --extra edit
+
+# Install Node dependencies (for the site)
+cd site && npm install && cd ..
+```
+
+## Running Locally
+
+### Single-user (fastest for testing extract logic)
+
+```bash
+# Configure where to find your test activities
+cp extract_config.example.yaml extract_config.yaml
+$EDITOR extract_config.yaml # set input.dirs and output.dir
+
+# Extract activities
+uv run bincio extract
+
+# Start the dev server (no login, no API server)
+uv run bincio dev --data-dir ~/bincio_data
+# → http://localhost:4321/u/{handle}/
+```
+
+### Multi-user (for testing auth, write API, admin features)
+
+```bash
+# Create a test instance with an admin user
+uv run bincio init --data-dir /tmp/bincio_test --handle testadmin
+
+# Extract activities
+uv run bincio extract --output /tmp/bincio_test
+
+# Start everything (bincio serve + astro dev)
+uv run bincio dev --data-dir /tmp/bincio_test
+# → http://localhost:4321 (login with testadmin/{password})
+```
+
+Ctrl+C stops both servers.
+
+## Running Tests
+
+```bash
+# All tests
+uv run pytest
+
+# Specific test file
+uv run pytest tests/extract/test_parsers.py
+
+# Specific test function
+uv run pytest tests/extract/test_parsers.py::test_gpx_parser
+
+# With verbose output
+uv run pytest -vv
+
+# With coverage report
+uv run pytest --cov=bincio
+```
+
+Tests are in `tests/` and use pytest + fixtures for DRY test data.
+
+## Project Structure
+
+```
+bincio/
+ extract/ Python package for GPX/FIT/TCX parsing
+ models.py DataPoint, ParsedActivity, LapData
+ parsers/ GPX, FIT, TCX parsers + factory
+ sport.py Sport name normalization
+ metrics.py Haversine-based stats (distance, elevation)
+ timeseries.py 1Hz downsampling → BAS timeseries object
+ simplify.py RDP track simplification (no external deps)
+ dedup.py Exact + fuzzy duplicate detection
+ strava_csv.py Strava activities.csv importer
+ writer.py BAS JSON + GeoJSON output
+ config.py extract_config.yaml loader
+ cli.py `bincio extract` command
+ render/
+ cli.py `bincio render` command
+ merge.py Sidecar edit overlay (produces _merged/)
+ edit/
+ cli.py `bincio edit` FastAPI server
+ server.py Edit API endpoints
+ serve/
+ cli.py `bincio serve` command
+ server.py Multi-user FastAPI server (auth, invites, admin)
+ db.py SQLite data layer
+ init_cmd.py `bincio init` bootstrap
+ shared/ (if needed)
+
+site/ Astro + Svelte + Tailwind frontend
+ src/
+ layouts/ Base.astro (auth wall, nav)
+ pages/ Routes (activity feed, detail, login, etc.)
+ components/ Svelte components (maps, charts, edit drawer)
+ lib/ TypeScript utilities (types, format, dataloader)
+
+tests/ pytest test suite
+ extract/
+ render/
+ serve/
+ fixtures/ Shared test data
+```
+
+## Key Concepts
+
+### BAS (BincioActivity Schema)
+
+Activity data flows as **BAS JSON** files in `{user}/activities/`. The format is specified in [SCHEMA.md](schema.md).
+
+Key files:
+
+- `{id}.json` — activity metadata + timeseries
+- `_merged/` symlink — sidecar edits overlaid on activities
+- `edits/{id}.md` — user-created sidecar (optional)
+
+### Shard model
+
+Multi-user instances use a **shard manifest** (root `index.json`) that lists per-user shards. The browser fetches all shards concurrently and merges them. This allows:
+
+- Federation (remote shard URLs)
+- Yearly pagination
+- No data duplication
+
+### Extract pipeline
+
+```
+GPX/FIT/TCX files
+ ↓ (parse)
+ParsedActivity
+ ↓ (calculate metrics)
+BAS Activity JSON
+ ↓ (downsample to 1Hz)
+Timeseries
+ ↓ (simplify with RDP)
+GeoJSON
+ ↓ (write)
+activities/{id}.json + activities/{id}.geojson
+```
+
+### Render pipeline
+
+```
+{user}/
+ activities/*.json (extracted)
+ edits/*.md (user sidecars)
+ ↓ (merge_all)
+_merged/
+ index.json (sidecar edits applied)
+ activities/{id}.json
+ {id}.geojson
+ ↓ (astro build)
+site/dist/
+```
+
+Editing does not require re-extraction.
+
+## Making Changes
+
+### Adding a new endpoint
+
+1. Add a route in `bincio/serve/server.py` (or `bincio/edit/server.py` for single-user)
+2. Add Pydantic models for request/response if needed
+3. Add tests in `tests/serve/`
+4. Update `docs/reference/api.md` with the new endpoint
+5. If admin-only, protect it with `await _require_admin(bincio_session)`
+
+### Adding a parser for a new format
+
+1. Create `bincio/extract/parsers/myformat.py`
+2. Implement a parser class with `parse(file_path: Path) -> ParsedActivity`
+3. Register it in `bincio/extract/parsers/__init__.py`
+4. Add tests in `tests/extract/test_parsers.py`
+
+### Modifying BAS schema
+
+1. Edit `schema/bas-v1.schema.json` (JSON Schema)
+2. Update `SCHEMA.md` (human-readable spec)
+3. Update TypeScript types in `site/src/lib/types.ts`
+4. Add a migration if the change is breaking
+
+### Frontend changes
+
+**Svelte components** are in `site/src/components/`. Key ones:
+
+- `ActivityFeed.svelte` — activity grid + filters
+- `ActivityDetail.svelte` — activity page (maps, charts, photos)
+- `EditDrawer.svelte` — sidecar editor
+
+Use `uv run bincio dev` to test changes live. The site hot-reloads on file changes.
+
+## Code Style
+
+- **Python:** PEP 8, type hints where possible
+- **JavaScript/TypeScript:** ESLint + Prettier (configured in `site/`)
+- **Svelte:** No self-closing non-void tags; interactive divs need `role` + keyboard handler
+
+## Git Workflow
+
+1. Create a branch: `git checkout -b feature/my-feature`
+2. Make changes and test locally
+3. Commit: `git commit -m "Clear, specific commit message"`
+4. Push: `git push origin feature/my-feature`
+5. Open a pull request
+
+**Commit message style:**
+
+- Imperative mood ("add feature", not "added feature")
+- Reference issues if relevant: "fix #123"
+- First line ≤ 50 characters
+- Blank line, then detailed explanation if needed
+
+## Performance Considerations
+
+### Extract speed
+
+- **ProcessPoolExecutor with initializer** — large data (Strava lookups, hash sets) is sent once per worker, not per task
+- **Haversine** — 10x faster than geopy for distance calculations
+- **Lazy parsing** — FIT files decoded only once per task
+
+### Render speed
+
+- **RDP simplification** — custom implementation (no external wheels for Pyodide)
+- **Gzip compression** — activity JSON and geojson are served gzipped
+- **Concurrent shard fetch** — browser loads all shards in parallel
+
+### Frontend
+
+- **MapLibre GL v5** — requires explicit center/zoom and workarounds
+- **Observable Plot** — use hyphenated curve names (e.g. `"monotone-x"`)
+- **Client-only for complex components** — use `client:only="svelte"` for activity detail to avoid hydration mismatches
+
+## Debugging
+
+### Python
+
+```bash
+# Interactive debugger
+uv run python -m pdb -m bincio.extract.cli
+
+# Or use breakpoint() in code
+breakpoint()
+uv run bincio extract
+```
+
+### TypeScript
+
+Check your editor's TypeScript integration. The site has strict `tsconfig.json`.
+
+### Frontend
+
+- Open DevTools (F12)
+- Check the Network tab for API calls
+- Check Console for client-side errors
+
+### Database
+
+```bash
+# Inspect the SQLite database directly
+sqlite3 /tmp/bincio_test/instance.db
+> SELECT * FROM users;
+```
+
+## Documentation
+
+- User-facing docs go in `docs/`
+- API docs are auto-generated from FastAPI routes (and should be typed with Pydantic models)
+- Code comments should explain *why*, not *what*
+
+## Known Issues & Limitations
+
+See the [GitHub repository](https://github.com/brutsalvadi/bincio-activity) for known issues and planned features.
+
+## Contributing
+
+Contributions are welcome! Please:
+
+1. Check existing issues/PRs so you're not duplicating work
+2. Open an issue first for large changes
+3. Include tests for new features
+4. Update docs (user guide, API ref, or developer guide)
+5. Follow the code style guidelines
+
+## See also
+
+- [Architecture](architecture.md) — system design and data flow
+- [BAS Schema](schema.md) — activity data format
+- [API Reference](reference/api.md) — all HTTP endpoints
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 6f2d7b2..28062a3 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -117,4 +117,4 @@ In multi-user mode the edit UI is always available via `bincio serve` — no ext
- [Single-user deployment](deployment/single-user.md) — GitHub Pages, Netlify, VPS
- [Multi-user deployment](deployment/multi-user.md) — VPS with nginx, inviting users
- [CLI reference](reference/cli.md) — all commands and options
-- [BAS schema](../SCHEMA.md) — the data format and federation protocol
+- [BAS schema](schema.md) — the data format and federation protocol
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..6a2736b
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,38 @@
+# BincioActivity Documentation
+
+Welcome to BincioActivity — a federated, self-hosted activity stats platform. This documentation is organized by audience:
+
+## For Users
+
+**[Getting Started](getting-started.md)** — Extract your activities from Strava/Garmin, set up a local site, and deploy it.
+
+**[User Guide](user-guide.md)** — Upload activities, sync from Strava, edit titles/descriptions, manage photos, control privacy, configure your profile.
+
+## For Administrators
+
+**[Admin Guide](admin-guide.md)** — Deploy a multi-user instance, manage users, reset passwords, monitor rebuild status.
+
+**[Multi-user Deployment](deployment/multi-user.md)** — Step-by-step setup with nginx, systemd, and multi-user architecture.
+
+**[Single-user Deployment](deployment/single-user.md)** — Deploy as a read-only static site or with a local edit server.
+
+## For Developers
+
+**[Developer Guide](developer-guide.md)** — Local setup, how to run tests, architecture overview, how to contribute.
+
+**[Architecture](architecture.md)** — BAS data format, shard model, federation protocol, federation design.
+
+**[API Reference](reference/api.md)** — HTTP endpoints, request/response formats, authentication, rate limits.
+
+**[CLI Reference](reference/cli.md)** — All bincio commands and options.
+
+## Quick Links
+
+- [GitHub repo](https://github.com/brutsalvadi/bincio-activity)
+- [BAS Schema](schema.md) — The data format specification
+- [Architecture diagram](architecture.mmd) (Mermaid diagram)
+- Live Swagger UI at `/api/docs` (when server is running)
+
+---
+
+**Status:** This is early-stage, self-hosted software. See the [GitHub repo](https://github.com/brutsalvadi/bincio-activity) for known issues and planned features.
diff --git a/SCHEMA.md b/docs/schema.md
similarity index 100%
rename from SCHEMA.md
rename to docs/schema.md
diff --git a/docs/user-guide.md b/docs/user-guide.md
new file mode 100644
index 0000000..ec2de54
--- /dev/null
+++ b/docs/user-guide.md
@@ -0,0 +1,180 @@
+# User Guide
+
+This guide covers everything you can do as a BincioActivity user.
+
+## Getting Your Account
+
+Your instance administrator sends you a registration link:
+
+```
+https://yourdomain.com/register/?code=ABCD1234
+```
+
+Click it and create:
+
+- **Handle** — your username in URLs (lowercase letters, numbers, `_`, `-`; 1–30 chars)
+- **Password** — at least 8 characters
+- **Display name** — your full name (shown on your profile page)
+
+You're now logged in and ready to upload activities!
+
+## Uploading Activities
+
+Click **Upload** to add activities from files.
+
+### Supported formats
+
+- **GPX** — GPS Exchange format (most common)
+- **FIT** — Garmin's native format
+- **TCX** — Training Center XML
+- **Compressed files** — `.gz` variants of any format above
+
+### Using Strava export
+
+If you exported activities from Strava, you likely have a folder like:
+
+```
+activities/
+ ├── 2026-03-15_morning_run.gpx
+ ├── 2026-03-14_evening_ride.fit
+ └── ...
+```
+
+Just drag the whole `activities/` folder into the upload box, or select multiple files at once.
+
+### Upload options
+
+- **Store original files** — keep the source GPX/FIT/TCX file on the server (checked by default; you can uncheck per upload)
+- **Skip duplicates** — the system detects exact duplicates automatically
+
+After upload, the server extracts GPS tracks, calculates distance/elevation/time, and generates your activity feed. You can keep uploading — the system deduplicates by file hash.
+
+## Syncing from Strava
+
+If your instance supports Strava sync, click **Sync from Strava** in the upload modal.
+
+1. Authorize BincioActivity to read your Strava data
+2. Select which activities to import
+3. The server fetches GPS and metrics from Strava and stores them
+
+Your OAuth token is stored securely on the server. You can revoke access at any time in [Strava Settings](https://www.strava.com/settings/apps).
+
+## Editing Activities
+
+Click **Edit** on any activity to:
+
+- **Change the title** — rename the activity
+- **Add a description** — write notes or a story (supports markdown and embedded images)
+- **Upload photos** — add photos taken during the activity
+- **Choose sport type** — cycling, running, hiking, etc.
+- **Assign gear** — tag the bike/shoes/watch used
+- **Set privacy** — hide the activity from the public feed
+- **Highlight** — mark your favorite activities
+
+Changes save instantly. The site rebuilds in the background.
+
+### Photo gallery
+
+Upload photos for an activity. They appear in a lightbox on the activity detail page. The server stores them in your data directory.
+
+### Markdown in descriptions
+
+Descriptions support basic markdown:
+
+```markdown
+# Title
+**bold** _italic_ `code`
+
+- bullet list
+- another item
+
+[link](https://example.com)
+
+
+```
+
+Images are stored in `edits/images/{id}/` and paths are rewritten automatically.
+
+## Privacy Control
+
+Each activity has a privacy setting:
+
+- **Public** (`public: true`) — visible to all logged-in users in the feed
+- **Unlisted** (`private: true`) — not shown in the feed, but accessible by direct URL (for sharing)
+- **No GPS** (remove GPS track) — hides the map but keeps distance/time stats
+
+Your instance admin can also make the whole instance public or private.
+
+### Deleting an activity
+
+You can't delete an activity directly, but you can:
+
+- Mark it **private** to hide it from the feed
+- Edit the sidecar manually in `{data-root}/edits/{id}.md` and delete the file
+
+## Your Profile
+
+Click your name in the top-right to view your profile at `/u/{handle}/`. It shows:
+
+- Your display name
+- All your public activities (organized by year)
+- Summary stats (total distance, time, elevation)
+
+## Account Settings
+
+Click your name → **Settings** to:
+
+- **Change password** — update your account password
+- **View your handle** — the username used in URLs
+- **See your data** — information about what's stored on the server
+
+If you forget your password, ask your instance administrator to generate a reset code.
+
+## Feedback
+
+Found a bug or want to suggest a feature? Click **Feedback** at the bottom of any page to submit a message and optional screenshots. The admin team can see all feedback submissions.
+
+## Local Activity Conversion
+
+If your instance has the `/convert/` page enabled, you can:
+
+1. Upload a GPX/FIT/TCX file **locally in your browser** (no server upload)
+2. The file is processed in JavaScript (powered by Pyodide, Python in the browser)
+3. You see the activity preview immediately
+4. You can then save it to your local browser storage (IndexedDB) or upload it to the server
+
+This is useful for testing or converting files without uploading them first.
+
+## Offline Activity Storage (experimental)
+
+Activities converted locally are stored in your browser's **IndexedDB** (local storage). They:
+
+- Don't upload to the server
+- Persist across browser sessions
+- Can be deleted from settings
+
+This is useful for activities you don't want to publish yet, or for testing before uploading.
+
+## Frequently Asked Questions
+
+**Can I download my data?**
+Your instance's complete activity feed is at `/u/{handle}/index.json` (the BAS format). You can also ask the admin to copy your data directory directly.
+
+**Can I transfer activities between instances?**
+Yes! Copy the `{handle}/activities/` and `{handle}/edits/` directories to another instance. The system uses content hashing, so you can merge multiple instances.
+
+**What formats does my activity support?**
+BincioActivity extracts GPS tracks, distance, elevation, moving time, average speed, heart rate, power, cadence, and temperature (if available in the source file).
+
+**Can I share my activities with someone outside my instance?**
+Mark activities as **unlisted** (`private: true`). Anyone with the direct URL can view them, even if they're not logged in.
+
+**How do I delete my account?**
+Ask your instance administrator. They can delete your user record from `instance.db`, which removes you from the login system. Your activity data remains for audit, but can be deleted from disk if you request it.
+
+## See also
+
+- [Getting Started](getting-started.md) — initial setup
+- [API Reference](reference/api.md) — technical details about how data flows
+- [BAS Schema](schema.md) — the activity JSON format
+- [Admin Guide](admin-guide.md) — for instance admins
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..b443f55
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,66 @@
+site_name: BincioActivity
+site_description: Federated, open-source, self-hosted activity stats platform
+site_author: Davide Brugali
+repo_url: https://github.com/brutsalvadi/bincio-activity
+repo_name: brutsalvadi/bincio-activity
+edit_uri: edit/main/docs/
+
+docs_dir: docs
+site_dir: mkdocs-site
+
+theme:
+ name: material
+ palette:
+ - scheme: light
+ primary: blue
+ accent: blue
+ toggle:
+ icon: material/lightbulb-outline
+ name: Switch to dark mode
+ - scheme: slate
+ primary: blue
+ accent: blue
+ toggle:
+ icon: material/lightbulb
+ name: Switch to light mode
+ features:
+ - navigation.tabs
+ - navigation.tabs.sticky
+ - navigation.top
+ - search.suggest
+ - search.highlight
+ - content.code.copy
+ - content.code.annotate
+
+plugins:
+ - search
+
+markdown_extensions:
+ - admonition
+ - pymdownx.details
+ - pymdownx.superfences
+ - pymdownx.highlight:
+ use_pygments: true
+ - pymdownx.inlinehilite
+ - pymdownx.tabbed:
+ alternate_style: true
+ - toc:
+ permalink: true
+
+nav:
+ - Home: index.md
+ - Getting Started: getting-started.md
+ - User Guide: user-guide.md
+ - Admin Guide: admin-guide.md
+ - Deployment:
+ - Single-user: deployment/single-user.md
+ - Multi-user: deployment/multi-user.md
+ - VPS: deployment/vps.md
+ - Developer:
+ - Developer Guide: developer-guide.md
+ - Architecture: architecture.md
+ - Reference:
+ - API: reference/api.md
+ - CLI: reference/cli.md
+ - Schema: schema.md
+ - Garmin Disclaimer: garmin_connect_disclaimer.md
diff --git a/pyproject.toml b/pyproject.toml
index 2e1bb5b..e251829 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,6 +56,9 @@ dev = [
"types-pyyaml",
"types-jsonschema",
]
+docs = [
+ "mkdocs-material>=9.5",
+]
[project.scripts]
bincio = "bincio.cli:main"
@@ -68,6 +71,7 @@ dev = [
"mypy>=1.11",
"types-pyyaml",
"types-jsonschema",
+ "mkdocs-material>=9.5",
]
[tool.ruff]
From cfdd8d27443f467c7b0dd1606911e95247f666cb Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 10:19:32 +0200
Subject: [PATCH 095/124] fix: image refs in description and broken gallery
URLs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- EditDrawer: stop auto-inserting  into description on
upload — images are tracked via custom.images; the refs only cluttered
the textarea. Strip any pre-existing refs on load so old sidecars are
also cleaned up when the drawer is opened.
- ActivityDetail: imageBase now treats detail_url that starts with '/'
as already-absolute (same fix pattern as track_url / detail_url);
was prepending ${base}data/ on top of /data/... → double path.
---
site/src/components/ActivityDetail.svelte | 5 +++--
site/src/components/EditDrawer.svelte | 9 ++-------
2 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte
index c9bf3ed..a0bb7a6 100644
--- a/site/src/components/ActivityDetail.svelte
+++ b/site/src/components/ActivityDetail.svelte
@@ -82,10 +82,11 @@
})();
// Derive image dir from detail_url so multi-user paths resolve correctly.
- // "dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
+ // Relative: "dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
+ // Absolute: "/data/dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
$: imageBase = (() => {
const du = activity.detail_url ?? '';
- const dir = du.startsWith('http')
+ const dir = du.startsWith('http') || du.startsWith('/')
? du.substring(0, du.lastIndexOf('/') + 1)
: du.includes('/')
? `${base}data/${du.substring(0, du.lastIndexOf('/') + 1)}`
diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte
index 47b18fc..5246953 100644
--- a/site/src/components/EditDrawer.svelte
+++ b/site/src/components/EditDrawer.svelte
@@ -51,7 +51,8 @@
title = d.title ?? '';
sport = d.sport ?? 'cycling';
gear = d.gear ?? '';
- description = d.description ?? '';
+ // Strip any auto-inserted image markdown refs — images are tracked via custom.images
+ description = (d.description ?? '').replace(/!\[[^\]]*\]\([^)]+\)\n?/g, '').trim();
highlight = d.highlight ?? false;
isPrivate = d.private ?? false;
hideStats = d.hide_stats ?? [];
@@ -95,9 +96,6 @@
if (res.ok) {
const d = await res.json();
if (!images.includes(d.filename)) images = [...images, d.filename];
- // Insert markdown reference at cursor or end
- const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`;
- description = description.trimEnd() + ref;
}
}
} catch (e: any) {
@@ -111,9 +109,6 @@
async function deleteImage(filename: string) {
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
images = images.filter(f => f !== filename);
- // Remove the markdown reference — escape filename before using in regex
- const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${escaped}\\)`, 'g'), '').trim();
}
function toggleStat(key: string) {
From a78f6ee3bddeb6b4ae56fe1d57cddb01920101be Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 10:29:13 +0200
Subject: [PATCH 096/124] fix: strip local image refs with spaces/parens in
filenames before markdown render
---
site/src/components/ActivityDetail.svelte | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte
index a0bb7a6..38c98b8 100644
--- a/site/src/components/ActivityDetail.svelte
+++ b/site/src/components/ActivityDetail.svelte
@@ -70,15 +70,23 @@
$: rawDescription = localDescription || detail?.description || '';
$: descriptionHtml = (() => {
if (!rawDescription) return '';
+ // Strip local image refs before marked sees them. marked only parses  as an
+ // image when the URL has no spaces — filenames like "WhatsApp Image 2026.jpg" are left
+ // as literal text instead. The lazy .*? anchored to the image extension handles filenames
+ // with spaces and nested parens (e.g. "file(2).jpg") correctly.
+ const stripped = rawDescription
+ .replace(/!\[[^\]]*\]\((?!https?:\/\/|\/|data:).*?\.(?:jpe?g|png|gif|webp|bmp|avif|heic)\)/gi, '')
+ .trim();
+ if (!stripped) return '';
const renderer = new marked.Renderer();
- // Local relative images are always shown in the gallery — suppress inline rendering
+ // Any remaining remote images render inline; local ones (shouldn't exist after strip) are suppressed
renderer.image = ({ href, title, text }) => {
const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
if (isLocal) return '';
const titleAttr = title ? ` title="${title}"` : '';
return ` `;
};
- return DOMPurify.sanitize(marked(rawDescription, { renderer }) as string);
+ return DOMPurify.sanitize(marked(stripped, { renderer }) as string);
})();
// Derive image dir from detail_url so multi-user paths resolve correctly.
From c68dfa90573ea7b3b3d15ae1f120f87abf0c38bb Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 18:09:32 +0200
Subject: [PATCH 097/124] chore: update changelog, remove stale files, scrub
VPS IP
- CHANGELOG.md: add [Unreleased] 2026-04-16 section covering settings
page, admin tools, password reset, re-extract, community page, SSE
upload progress, and all bug fixes since 2026-04-10
- Remove docs-proposal.md (internal planning doc, not user-facing)
- Remove publish/ directory (leftover artefacts from publish.sh, not
meant to be tracked)
- scripts/pull_feedback.sh: replace hardcoded default VPS IP with a
required positional argument to avoid leaking server address
- docs/squash-for-github.md: document the squash-for-github commit
strategy for future reference
---
CHANGELOG.md | 95 ++++++++++++
docs-proposal.md | 113 --------------
docs/squash-for-github.md | 78 ++++++++++
publish/CLAUDE.md | 223 ----------------------------
publish/extract_config.example.yaml | 31 ----
publish/manifest | 72 ---------
scripts/pull_feedback.sh | 4 +-
7 files changed, 175 insertions(+), 441 deletions(-)
delete mode 100644 docs-proposal.md
create mode 100644 docs/squash-for-github.md
delete mode 100644 publish/CLAUDE.md
delete mode 100644 publish/extract_config.example.yaml
delete mode 100644 publish/manifest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16ce436..d608c04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,100 @@
# Changelog
+## [Unreleased] — 2026-04-16
+
+### New feature — Self-service user settings page
+
+- **`site/src/pages/settings/index.astro`** — new `/settings/` page with three sections:
+ - **Account** — display name editor, storage quota view (uploaded activities + originals size)
+ - **Integrations** — per-user Strava client ID/secret (replaces instance-level credentials for
+ multi-user deployments); saved to `settings` table via `PATCH /api/me`
+ - **Danger zone** — two separate destructive actions:
+ - **Delete originals** — removes `{user_dir}/originals/` without touching activities
+ - **Delete all activities** — wipes all activities, edits, GeoJSON, and `_merged/`; triggers rebuild
+ - Nav visibility toggles — user can hide any combination of Feed / Stats / Athlete tabs from
+ their navigation; preference saved to `settings` table and applied in `Base.astro`
+
+### New feature — Upload overwrite option
+
+- **`POST /api/upload`** — new `overwrite: bool` form field; when true, an existing activity
+ with the same ID is replaced rather than returning 409. UI checkbox added to the upload modal.
+
+### New feature — Admin tools
+
+- **Ghost user detection** — `/admin/` now marks users whose handle has a data directory but
+ no entry in the `users` table (e.g. manually created dirs, or users deleted from DB); shown
+ with a "ghost" badge
+- **Delete directory button** — admin can delete a user's entire data directory without
+ touching the DB entry; useful for cleaning up ghost dirs or corrupted accounts
+- **Delete all activities** (`DELETE /api/admin/users/{handle}/activities`) — wipes
+ `activities/`, `edits/`, `_merged/`, and `index.json` for a handle, then triggers a rebuild;
+ admin page shows a confirmation `` before firing
+- **"Admin" nav link** — visible in the top-right for admins only
+
+### New feature — Password reset (admin-generated one-time code)
+
+No email infrastructure required. Flow:
+
+1. Admin visits `/admin/` → clicks "Reset pwd" → a 24-hour code appears inline (click to copy)
+2. Admin sends it out-of-band (Signal, Telegram, etc.)
+3. User goes to `/reset-password/`, enters handle + code + new password
+
+- `POST /api/admin/users/{handle}/reset-password-code` (admin) → `{code, expires_in_hours: 24}`
+- `POST /api/auth/reset-password` (public) → body `{handle, code, password}`
+- `reset_codes` table in `instance.db`; generating a new code invalidates prior unused codes;
+ used codes kept for audit
+
+### New feature — Re-extract from Strava originals
+
+- **`POST /api/admin/reextract`** — re-runs the extract pipeline over all
+ `{user_dir}/originals/strava/*.json` files without hitting the Strava API again;
+ streams progress via SSE; useful after pipeline improvements
+- Runs as a subprocess to avoid OOM (`malloc_trim` + `gc.collect` every 50 activities);
+ processes in batches of 100 to bound peak RSS
+
+### New feature — Community page
+
+- **`/community/` tab** — sortable table of all registered users: display name, handle,
+ member since, invited by; replaces the earlier inline community section on the about page
+
+### New feature — Streaming upload progress
+
+- **`POST /api/upload`** now returns `text/event-stream` instead of JSON
+- Per-file progress events: `↓ 3/47 (6%) — morning_ride.fit`
+- Final `done` event: `"12 added, 35 duplicates"`
+- Vite proxy configured to not buffer the stream
+
+### Bug fixes
+
+- **`elevation_gain_m` null for modern Garmin FIT files** — session message `total_ascent`
+ field now read as fallback when per-point elevation gain is zero
+- **Map flash on activity detail** — map container height set before `fitBounds` to prevent
+ a zero-height frame during load
+- **Absolute `track_url` / `detail_url` paths** — `ActivityDetail` and `loadActivity` now
+ handle both relative and absolute paths in BAS JSON
+- **Corrupted time streams causing OOM** — `metrics.py` guards against non-monotonic or
+ pathologically large time arrays before allocating the 1 Hz dense array
+- **Merge race condition** — `merge_all` wipe + rewrite is now guarded; concurrent upload
+ triggers can no longer interleave a `shutil.rmtree` with a write from another request
+- **Temp ZIP leak** — upload temp files now written to `/tmp/` and always deleted in a
+ `finally` block; a startup hook auto-cleans any leftovers
+- **`bincio init` always overwrites `private`** — fixed to preserve existing value when
+ `index.json` already exists
+- **Auth wall flash** — `Base.astro` now sets the auth state synchronously from a cookie
+ hint before the `fetch('/api/me')` resolves, eliminating the visible flash
+- **Single-user redirect loop** — `index.astro` no longer redirects to `/u/{handle}/` on
+ private (multi-user) instances
+- **Theme-aware Plot tooltips** — forced black text on white background; was rendering
+ grey-on-white (unreadable in light mode) and white-on-dark (unreadable in dark mode)
+- **Theme-aware chart axis colors** — axis labels and tick marks now use the correct
+ foreground color in both light and dark themes
+- **TS type annotation in `define:vars` script** — removed; Astro injects `define:vars`
+ blocks as plain JS, not TypeScript
+- **Image refs with spaces/parens in filenames** — local image references in markdown
+ descriptions are now stripped before rendering to avoid broken inline ` ` tags
+
+---
+
## [Unreleased] — 2026-04-10
### New feature — Per-instance user limit
diff --git a/docs-proposal.md b/docs-proposal.md
deleted file mode 100644
index bcfe977..0000000
--- a/docs-proposal.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# Documentation proposal
-
-## Problem
-
-The project has no user-facing or developer-facing docs. Knowledge lives in `CLAUDE.md`
-(written for AI context, not humans), scattered inline comments, and the code itself.
-As the feature surface grows and more users join, we need:
-
-- A guide for **users** (how to upload, sync, edit, manage privacy)
-- A guide for **admins** (how to run an instance, manage users, reset passwords)
-- An **API reference** (what endpoints exist, what they expect, what they return)
-- A **developer guide** (how to run locally, architecture, how to contribute)
-
----
-
-## Proposed structure
-
-```
-docs/
- index.md Overview and quick links
- user-guide.md End-user: upload, sync, edit, privacy, settings
- admin-guide.md Admin: deploy, init, invite users, reset passwords, rebuild
- api.md API reference (hand-written, augmented by OpenAPI)
- architecture.md BAS schema, data flow, shard model, federation
- developer-guide.md Local setup, how to run tests, how to contribute
-```
-
-`CLAUDE.md` stays as-is — it is AI context, not user docs. The two serve different
-audiences and should not be merged.
-
----
-
-## API documentation strategy
-
-FastAPI auto-generates an OpenAPI 3.1 spec from the route decorators. It is already
-served at `/api/docs` (Swagger UI) and `/api/redoc` (ReDoc) when the server is running.
-Right now the auto-docs are sparse because:
-
-- Most endpoints return bare `JSONResponse` instead of typed Pydantic response models
-- Endpoint docstrings are minimal or absent
-- Request bodies are raw `request.json()` instead of Pydantic models
-
-### Recommended approach: two-layer docs
-
-**Layer 1 — machine-readable (OpenAPI, auto-generated)**
-
-Incrementally add Pydantic request/response models to the endpoints that matter most
-(auth, activity CRUD, admin actions). FastAPI will pick them up automatically and the
-Swagger UI becomes usable. No extra tooling needed.
-
-Priority endpoints to type first:
-- `POST /api/auth/login` / `logout` / `reset-password`
-- `POST /api/register`
-- `GET /api/me`
-- `GET|POST /api/activity/{id}`
-- `DELETE /api/activity/{id}`
-- `POST /api/admin/users/{handle}/reset-password-code`
-- `GET|POST /api/me/preferences` (once built)
-
-**Layer 2 — human-readable (`docs/api.md`)**
-
-A hand-written reference that groups endpoints by domain (auth, activities, admin,
-sync), explains the overall auth model (cookie-based, httpOnly), rate limiting, and
-covers things OpenAPI can't express well (SSE streams, error semantics, side effects
-like rebuild triggers).
-
-The OpenAPI spec and the hand-written doc are complementary, not duplicates:
-OpenAPI is precise and machine-readable; `api.md` gives context and explains *why*.
-
----
-
-## Tooling options
-
-| Option | Pros | Cons |
-|--------|------|------|
-| Plain markdown in `docs/` | Zero tooling, lives in repo, renders on GitHub | No search, no versioning, no sidebar nav |
-| MkDocs + Material theme | Beautiful, search, auto-nav from folder structure, can embed OpenAPI via plugins | Needs Python dep + build step; another thing to deploy |
-| Docusaurus | Great for open-source projects, versioning, i18n | Node toolchain, heavier |
-| VitePress | Fast, Vite-based (already in the stack), markdown + Vue | Still a separate site to host |
-| Just the Swagger UI at `/api/docs` | Auto-generated, always up-to-date | Only covers the API, not user/admin/architecture |
-
-**Recommendation:** Start with plain markdown in `docs/` — no build step, always
-available, no new infrastructure. If the project goes public or the user base grows,
-migrate to MkDocs Material (one `mkdocs.yml` + `pip install mkdocs-material`).
-
-For the API specifically: enable the Swagger UI on the live server (currently it may
-be disabled in production) so admins can explore it directly at `/api/docs`.
-
----
-
-## Enabling Swagger UI in production
-
-By default FastAPI serves `/docs` and `/redoc`. In `bincio serve`, the FastAPI app is
-created with:
-
-```python
-app = FastAPI(docs_url=None, redoc_url=None) # check current value
-```
-
-For a private instance (auth-walled), it is safe to expose `/api/docs` — add a note
-in `admin-guide.md` that it exists. Alternatively, serve it only when an env var is set.
-
----
-
-## Suggested first milestone
-
-1. Create `docs/` with `index.md`, `admin-guide.md`, `api.md`
-2. `admin-guide.md`: deploy, init, invite, password reset, rebuild, reset data
-3. `api.md`: auth endpoints + activity endpoints, hand-written
-4. Enable Swagger UI on the server (or at least document that it exists at `/api/docs`)
-5. Add Pydantic models to the 8 priority endpoints above
-
-Everything else (user guide, architecture, developer guide, MkDocs) is second milestone.
diff --git a/docs/squash-for-github.md b/docs/squash-for-github.md
new file mode 100644
index 0000000..53a6460
--- /dev/null
+++ b/docs/squash-for-github.md
@@ -0,0 +1,78 @@
+# squash-for-github branch strategy
+
+`squash-for-github` is a curated public-facing branch. It has its own orphan
+history (unrelated to `main`) and grows by appending one large squash commit
+each time you want to publish a batch of work.
+
+## When to use
+
+Whenever `main` has accumulated enough work worth publishing — typically after a
+meaningful feature set or before tagging a release.
+
+## How it works
+
+`squash-for-github` and `main` have completely unrelated histories (different
+root commits). Because of this, `git merge --squash` won't work. Instead, use
+`git commit-tree` to create a new commit that carries **main's file tree** but
+is **parented to the current squash-for-github tip**.
+
+## Steps
+
+1. **Collect commit messages** to write the summary:
+
+ ```bash
+ git log --oneline main ^squash-for-github
+ ```
+
+2. **Switch to the branch:**
+
+ ```bash
+ git checkout squash-for-github
+ ```
+
+3. **Create the squash commit** (replace the message with your summary):
+
+ ```bash
+ NEW=$(git commit-tree main^{tree} -p HEAD -m "feat: your summary here")
+ git reset --hard $NEW
+ ```
+
+ Or as a one-liner with a heredoc for a multi-line message:
+
+ ```bash
+ git reset --hard $(git commit-tree main^{tree} -p HEAD -m "$(cat <<'EOF'
+ feat: short title
+
+ - bullet one
+ - bullet two
+ EOF
+ )")
+ ```
+
+4. **Verify:**
+
+ ```bash
+ git log --oneline squash-for-github | head -5
+ ```
+
+5. **Push** when ready:
+
+ ```bash
+ git push origin squash-for-github
+ # or force-push if you've rewritten history on the remote:
+ git push --force origin squash-for-github
+ ```
+
+6. **Return to main:**
+
+ ```bash
+ git checkout main
+ ```
+
+## Why not `git merge --squash`?
+
+The two branches share no common ancestor, so git refuses with
+`fatal: refusing to merge unrelated histories`. `git commit-tree` bypasses this
+by directly constructing the commit object: it takes the tree (file snapshot)
+from `main`, sets the parent to the current `squash-for-github` tip, and
+attaches your custom message — no merge machinery needed.
diff --git a/publish/CLAUDE.md b/publish/CLAUDE.md
deleted file mode 100644
index 683fec3..0000000
--- a/publish/CLAUDE.md
+++ /dev/null
@@ -1,223 +0,0 @@
-# BincioActivity — Context for Claude
-
-## What this project is
-
-BincioActivity is a federated, open-source, self-hosted activity stats platform
-(think personal Strava). Two-stage pipeline:
-
-1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store
-2. **`bincio render`** (Astro/Node): BAS data store → static website
-
-The BAS (BincioActivity Schema) JSON files are the federation protocol.
-Anyone can publish their data as BAS JSON and others can include it.
-
-## Key design decisions
-
-- **No database, no server** — everything is static files
-- **Python with uv** for the extract stage
-- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
-- **Haversine** (not geopy) for distance calculations (10x faster)
-- **Worker initializer pattern** for ProcessPoolExecutor — large shared data
- (strava_lookup dict, known_hashes frozenset) is sent once per worker via
- `initializer=`, not once per task
-- **BAS activity IDs** always use UTC with Z suffix for URL safety
-- **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
- parser handles both
-
-## Your data
-
-- Source: `~/your-activity-data/`
- - `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants)
- - Any subdirectories with FIT files from Garmin/Karoo devices
- - `activities.csv` — Strava metadata (names, descriptions, gear)
-- Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing)
-
-Configure input paths in `extract_config.yaml`.
-
-## Project structure
-
-```
-bincio/ Python package
- extract/
- models.py DataPoint, ParsedActivity, LapData
- parsers/ GPX, FIT, TCX parsers + factory
- sport.py sport name normalisation
- metrics.py haversine-based stats computation (single pass)
- timeseries.py downsample to 1Hz, build BAS timeseries object
- simplify.py RDP track simplification → GeoJSON
- dedup.py exact (hash) + near-duplicate detection
- strava_csv.py Strava activities.csv importer
- writer.py BAS JSON + GeoJSON writer
- config.py extract_config.yaml loader
- cli.py `bincio extract` CLI
- render/
- cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
- merge.py sidecar edit overlay (produces _merged/)
- edit/
- cli.py `bincio edit` CLI
- server.py FastAPI write API for the edit drawer
-schema/
- bas-v1.schema.json JSON Schema for BAS
-SCHEMA.md Human-readable BAS spec
-site/ Astro project
- src/
- layouts/Base.astro
- pages/
- index.astro Activity feed (loads index.json client-side)
- activity/[id].astro Single activity (SSG, loads detail JSON client-side)
- stats/index.astro Heatmap + year totals
- components/
- ActivityFeed.svelte Card grid, sport filter, pagination
- ActivityDetail.svelte Map + stats + charts + photo gallery
- ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
- ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
- StatsView.svelte Yearly heatmap + totals
- EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set)
- lib/
- types.ts BAS TypeScript types
- format.ts formatDistance, formatDuration, sportIcon, etc.
-```
-
-## How to run
-
-```bash
-# Extract
-cd ~/src/bincio_activity
-uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test
-
-# Site dev server
-cd site
-ln -sf /tmp/bincio_test/_merged public/data # point at merged output
-cp .env.example .env && $EDITOR .env # set BINCIO_DATA_DIR
-npm run dev
-
-# Edit server (optional — enables edit drawer in the site)
-uv run bincio edit --data-dir /tmp/bincio_test
-# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env
-
-# Tests
-uv run pytest
-```
-
-## MapLibre GL + Vite/Astro — known gotchas
-
-Learnt the hard way during debugging (March 2026):
-
-- **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.**
- The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a
- normal Vite environment — MapLibre handles the blob worker automatically.
-
-- **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.**
- It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle
- uses AMD `define()` internally; served raw, the tile worker blob fails silently →
- black map, no tiles. The correct setting is `include: ['maplibre-gl']`.
-
-- **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.**
- MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it,
- helpers like `__publicField` aren't available inside the serialised worker blob
- scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680).
-
-- **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.**
- With `client:only="svelte"` in Astro, SSR never runs for the component so there is
- no `window is not defined` risk. Static import lets Vite pre-bundle correctly.
-
-- **Use `client:only="svelte"` (not `client:load`) for the activity detail page.**
- `client:load` does SSR + hydration; complex interactive components with MapLibre
- can hit hydration mismatch issues. `client:only` mounts fresh on the client only.
-
-- **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.**
- v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection
- state undefined → `Cannot read properties of undefined (reading 'lng')` crashes
- on any operation that touches coordinates (markers, resize, render). Always pass
- `center` and `zoom` even if you plan to `fitBounds` later.
-
-- **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.**
- v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside
- `addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]`
- if the real position arrives later (e.g. hover markers).
-
-## Observable Plot — known gotchas
-
-- **Curve names are hyphenated, not camelCase.**
- Use `"monotone-x"`, not `"monotoneX"`. Plot uses its own curve name registry
- (not raw d3 identifiers). Wrong names throw `unknown curve` at runtime.
-
-The working `astro.config.mjs` Vite section:
-```js
-vite: {
- optimizeDeps: {
- include: ['maplibre-gl'],
- esbuildOptions: { target: 'es2022' },
- },
- build: { target: 'es2022' },
-},
-```
-
-## Activity sidecar edits — design spec
-
-Users edit activities via **sidecar markdown files** in the data dir.
-No database, no server — consistent with the project's static-files-only philosophy.
-
-### File naming
-
-```
-~/bincio_data/
- activities/{id}.json ← immutable extract output
- edits/{id}.md ← user edits (sidecar)
- edits/images/{id}/ ← uploaded photos
- _merged/ ← render-time merge output (gitignored-style)
-```
-
-### Sidecar format
-
-```markdown
----
-title: "Epic climb up Monte Grappa"
-sport: cycling
-hide_stats: [cadence]
-highlight: true
-private: false
-gear: "Trek Domane"
----
-
-Rode with friends. Legs felt great after the rest week...
-```
-
-### Editing UX: drawer in Astro + `bincio edit` write API
-
-- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041
-- Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the edit button
-- Clicking Edit on any activity detail page opens a slide-in drawer
-- Saving writes the sidecar and triggers `merge_all()` automatically
-- `bincio render` always runs `merge_all()` before build/serve and symlinks `public/data` → `_merged/`
-
-### `PUBLIC_EDIT_URL` as feature flag
-
-- **Unset** → no Edit button, normal static site
-- **Set** → edit drawer enabled; lives in `site/.env` (gitignored)
-
-## Known issues / next steps
-
-- `bincio render` Python CLI is functional but `--watch` mode not yet implemented
-- 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)
-- Federation (remote data sources) not yet implemented in site
-- Friends pages (`/friends/{handle}/`) not yet implemented
-- The `site/.env` file is gitignored — copy from `site/.env.example`
-
-## What "good" looks like (not yet done)
-
-- [ ] `bincio render` Python CLI wraps `astro build` properly
-- [ ] Friends/federation pages in site
-- [ ] Personal records page
-- [ ] Activity search / full-text filter in feed
-- [ ] GitHub Actions template for auto-publish
-- [ ] Karoo/Garmin Connect importers beyond Strava
-- [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
-- [x] `PUBLIC_EDIT_URL` feature flag
-- [x] Markdown rendering in activity description with image path rewriting
-- [x] Photo gallery with lightbox on activity detail page
-- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
-- [ ] Highlight badge in activity feed cards
diff --git a/publish/extract_config.example.yaml b/publish/extract_config.example.yaml
deleted file mode 100644
index a02d6f2..0000000
--- a/publish/extract_config.example.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-owner:
- handle: yourname
- display_name: Your Name
-
-input:
- dirs:
- - ~/Activities/gpx
- - ~/Activities/fit
- # Strava bulk export metadata — provides names, descriptions, gear
- # metadata_csv: ~/strava_export/activities.csv
-
-output:
- dir: ~/bincio_data
-
-default_privacy: public
-
-sensors:
- heart_rate: true
- cadence: true
- temperature: true
- power: true
-
-track:
- simplify: rdp
- rdp_epsilon: 0.0001 # ~11m at equator
- timeseries_hz: 1 # 1 sample/second max
-
-classifier:
- enabled: false # ML activity type classifier (requires scikit-learn extra)
-
-incremental: true # skip files whose hash hasn't changed since last run
diff --git a/publish/manifest b/publish/manifest
deleted file mode 100644
index 9e127b4..0000000
--- a/publish/manifest
+++ /dev/null
@@ -1,72 +0,0 @@
-# BincioActivity — public release manifest
-# One relative path per line.
-# If publish/ exists, that sanitized version is used instead of the original.
-
-.gitignore
-.python-version
-CHANGELOG.md
-CHEATSHEET.md
-CLAUDE.md
-README.md
-SCHEMA.md
-pyproject.toml
-extract_config.example.yaml
-schema/bas-v1.schema.json
-bincio/__init__.py
-bincio/cli.py
-bincio/edit/__init__.py
-bincio/edit/cli.py
-bincio/edit/server.py
-bincio/extract/__init__.py
-bincio/extract/cli.py
-bincio/extract/config.py
-bincio/extract/dedup.py
-bincio/extract/metrics.py
-bincio/extract/models.py
-bincio/extract/parsers/__init__.py
-bincio/extract/parsers/base.py
-bincio/extract/parsers/factory.py
-bincio/extract/parsers/fit.py
-bincio/extract/parsers/gpx.py
-bincio/extract/parsers/tcx.py
-bincio/extract/simplify.py
-bincio/extract/sport.py
-bincio/extract/strava_csv.py
-bincio/extract/timeseries.py
-bincio/extract/writer.py
-bincio/import_/__init__.py
-bincio/import_/cli.py
-bincio/import_/strava.py
-bincio/render/__init__.py
-bincio/render/cli.py
-bincio/render/merge.py
-publish.sh
-publish/CLAUDE.md
-publish/extract_config.example.yaml
-publish/manifest
-site/.env.example
-site/astro.config.mjs
-site/package.json
-site/tailwind.config.mjs
-site/tsconfig.json
-site/src/components/ActivityCharts.svelte
-site/src/components/ActivityDetail.svelte
-site/src/components/ActivityFeed.svelte
-site/src/components/ActivityMap.svelte
-site/src/components/AthleteDrawer.svelte
-site/src/components/AthleteView.svelte
-site/src/components/EditDrawer.svelte
-site/src/components/MmpChart.svelte
-site/src/components/RecordsView.svelte
-site/src/components/StatsView.svelte
-site/src/layouts/Base.astro
-site/src/lib/format.ts
-site/src/lib/types.ts
-site/src/pages/activity/[id].astro
-site/src/pages/athlete/index.astro
-site/src/pages/index.astro
-site/src/pages/stats/index.astro
-tests/__init__.py
-tests/test_merge.py
-tests/test_sport.py
-tests/test_writer.py
diff --git a/scripts/pull_feedback.sh b/scripts/pull_feedback.sh
index c0866fb..346ec8e 100755
--- a/scripts/pull_feedback.sh
+++ b/scripts/pull_feedback.sh
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# Pull user feedback from the VPS into ./feedback/ locally.
-# Usage: bash scripts/pull_feedback.sh [vps-host] (default: root@95.216.55.151)
+# Usage: bash scripts/pull_feedback.sh
set -e
-VPS=${1:-root@95.216.55.151}
+VPS=${1:?Usage: $0 user@host}
REMOTE=/var/bincio/data/_feedback
LOCAL=$(dirname "$0")/../feedback
From 219308bdb5b2f1fd4929bf379fa9a57eba8c0d26 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 18:14:00 +0200
Subject: [PATCH 098/124] ci: install serve/edit/strava extras so bcrypt and
fastapi are available for tests
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff36fca..8bb4095 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
- - run: uv sync
+ - run: uv sync --extra serve --extra edit --extra strava
- run: uv run pytest
frontend:
From b22b5deb9e819e043ab47a364908ceb78e72da0a Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 18:16:46 +0200
Subject: [PATCH 099/124] =?UTF-8?q?fix=20tests:=20update=20merge=20tests?=
=?UTF-8?q?=20for=20private=E2=86=92unlisted=20rename=20and=20client-side?=
=?UTF-8?q?=20filtering?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/test_merge.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/tests/test_merge.py b/tests/test_merge.py
index e53e2cf..e3dbd4c 100644
--- a/tests/test_merge.py
+++ b/tests/test_merge.py
@@ -88,7 +88,7 @@ def test_apply_sidecar_body_takes_precedence_over_fm_description():
def test_apply_sidecar_private_flag():
result = apply_sidecar(BASE_DETAIL, {"private": True}, "")
- assert result["privacy"] == "private"
+ assert result["privacy"] == "unlisted"
def test_apply_sidecar_highlight():
@@ -177,9 +177,11 @@ def test_merge_all_private_filtered_from_index(data_dir):
merge_all(data_dir)
index = json.loads((data_dir / "_merged" / "index.json").read_text())
- ids = [a["id"] for a in index["activities"]]
- assert "2024-01-01T080000Z-morning-ride" not in ids
- assert "2024-01-02T090000Z-easy-run" in ids
+ activities = {a["id"]: a for a in index["activities"]}
+ # 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"
+ assert "2024-01-02T090000Z-easy-run" in activities
def test_merge_all_highlight_sorts_first(data_dir):
From cd1cdca33b036146c7399d4e480a9dca15ff9bf9 Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 18:49:01 +0200
Subject: [PATCH 100/124] extract: auto-detect gzip by magic bytes, not just
.gz extension
Files compressed with gzip but named without .gz (e.g. activity.gpx
containing gzip data) now decompress transparently.
---
bincio/extract/parsers/base.py | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/bincio/extract/parsers/base.py b/bincio/extract/parsers/base.py
index c68f424..543b95e 100644
--- a/bincio/extract/parsers/base.py
+++ b/bincio/extract/parsers/base.py
@@ -27,8 +27,14 @@ class BaseParser(ABC):
raw_bytes is the original file content (used for hashing).
decompressed_bytes is what parsers should actually parse.
+
+ Gzip is handled both by extension (.gz) and by magic bytes (0x1f 0x8b),
+ so files that are gzip-compressed but named without .gz still parse correctly.
"""
raw = path.read_bytes()
- if path.suffix == ".gz":
- return raw, gzip.decompress(raw)
+ if path.suffix == ".gz" or raw[:2] == b'\x1f\x8b':
+ try:
+ return raw, gzip.decompress(raw)
+ except Exception:
+ pass # not actually gzip despite the magic bytes — fall through
return raw, raw
From bb253cc2c12dbe966d47f9fd4278711e443089ee Mon Sep 17 00:00:00 2001
From: Davide Scaini
Date: Thu, 16 Apr 2026 18:54:18 +0200
Subject: [PATCH 101/124] site: accept gzip MIME types in upload file picker
Add application/gzip, application/x-gzip, and .gz to the accept
attribute so browsers show .gpx.gz / .fit.gz / .tcx.gz in the picker.
Browsers often ignore double-extension filters (.gpx.gz) without the
matching MIME type.
---
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 ee18dae..d3e81b9 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -311,7 +311,7 @@ try {
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, TCX, or activities.csv or click to browse
-
+
Date: Sun, 19 Apr 2026 22:21:10 +0200
Subject: [PATCH 102/124] perf: year-shard index.json to cut initial load from
MBs to ~1 year
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
merge_all/_merged/index.json is now a shard manifest; activities are
split into index-{year}.json files. The feed loads only the most-recent
year on first paint (~200 activities instead of all of them). Older
years are fetched lazily when the user clicks "Load older activities".
Also strips best_efforts / best_climb_m / source from shard files —
these fields are aggregation inputs only, never read by the feed UI.
---
bincio/render/merge.py | 61 +++++++++++--
site/src/components/ActivityFeed.svelte | 52 ++++++++---
site/src/lib/dataloader.ts | 115 ++++++++++++++++++++++--
tests/test_merge.py | 22 ++++-
tests/test_pipeline.py | 13 ++-
5 files changed, 230 insertions(+), 33 deletions(-)
diff --git a/bincio/render/merge.py b/bincio/render/merge.py
index 7c82308..bdc8fc6 100644
--- a/bincio/render/merge.py
+++ b/bincio/render/merge.py
@@ -155,8 +155,7 @@ def merge_one(data_dir: Path, activity_id: str) -> None:
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)
- index["activities"] = activities
- (merged_dir / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False))
+ _write_year_shards(merged_dir, activities, index)
def merge_all(data_dir: Path) -> int:
@@ -267,11 +266,57 @@ def merge_all(data_dir: Path) -> int:
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)
- index["activities"] = activities
- (merged_dir / "index.json").write_text(
- json.dumps(index, indent=2, ensure_ascii=False)
- )
- elif (merged_dir / "index.json").exists():
- (merged_dir / "index.json").unlink()
+ _write_year_shards(merged_dir, activities, index)
+ else:
+ # Remove any stale year shard files if the source index disappeared
+ for f in merged_dir.glob("index-*.json"):
+ f.unlink()
+ if (merged_dir / "index.json").exists():
+ (merged_dir / "index.json").unlink()
return len(sidecars)
+
+
+# Fields only needed for athlete.json aggregation at extract time — they add
+# bulk to every summary entry but are never read by the feed UI.
+_FEED_STRIP = {"best_efforts", "best_climb_m", "source"}
+
+
+def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dict) -> None:
+ """Split activities by year and write index-{year}.json shards.
+
+ Replaces merged_dir/index.json with a shard manifest so the feed can
+ load only the most-recent year on first paint and fetch older years lazily.
+ """
+ from collections import defaultdict
+
+ # Remove stale year shard files from previous runs
+ for f in merged_dir.glob("index-*.json"):
+ f.unlink()
+
+ by_year: dict[str, list[dict]] = defaultdict(list)
+ for a in activities:
+ year = (a.get("started_at") or "")[:4] or "unknown"
+ # Strip aggregation-only fields to keep shard files small
+ slim = {k: v for k, v in a.items() if k not in _FEED_STRIP}
+ by_year[year].append(slim)
+
+ years = sorted(by_year.keys(), reverse=True) # newest first
+ shards = []
+ for year in years:
+ shard_doc = {
+ **{k: v for k, v in index_meta.items() if k not in ("activities", "shards")},
+ "shards": [],
+ "activities": by_year[year],
+ }
+ fname = f"index-{year}.json"
+ (merged_dir / fname).write_text(json.dumps(shard_doc, indent=2, ensure_ascii=False))
+ shards.append({"url": fname, "year": int(year) if year.isdigit() else 0,
+ "count": len(by_year[year])})
+
+ root_doc = {
+ **{k: v for k, v in index_meta.items() if k not in ("activities", "shards")},
+ "shards": shards,
+ "activities": [],
+ }
+ (merged_dir / "index.json").write_text(json.dumps(root_doc, indent=2, ensure_ascii=False))
diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte
index e486e33..f292dbf 100644
--- a/site/src/components/ActivityFeed.svelte
+++ b/site/src/components/ActivityFeed.svelte
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
- import { loadIndex } from '../lib/dataloader';
+ import { loadIndexPaged, loadShardActivities } from '../lib/dataloader';
/** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -41,8 +41,10 @@
let sport: Sport | 'all' = 'all';
let shown = PAGE_SIZE;
let loading = true;
+ let loadingMore = false;
let error = '';
let mounted = false;
+ let pendingShards: string[] = [];
/** Logged-in handle — resolved async via bincio:me event. */
let me: string = '';
@@ -58,7 +60,33 @@
});
$: filtered = sport === 'all' ? withPrivacy : withPrivacy.filter(a => a.sport === sport);
$: visible = filtered.slice(0, shown);
- $: hasMore = shown < filtered.length;
+ $: canShowMore = shown < filtered.length;
+ $: hasMore = canShowMore || pendingShards.length > 0;
+
+ async function loadMore() {
+ if (canShowMore) {
+ shown += PAGE_SIZE;
+ return;
+ }
+ if (!pendingShards.length) return;
+ loadingMore = true;
+ const url = pendingShards[0];
+ pendingShards = pendingShards.slice(1);
+ try {
+ const fresh = await loadShardActivities(url);
+ // Merge avoiding duplicates (IDB activities may already be present)
+ const existing = new Map(all.map(a => [a.id, a]));
+ for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
+ all = [...existing.values()].sort((a, b) =>
+ (b.started_at ?? '').localeCompare(a.started_at ?? ''),
+ );
+ shown += PAGE_SIZE;
+ } catch {
+ // shard load failed — don't block the user
+ } finally {
+ loadingMore = false;
+ }
+ }
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
@@ -84,12 +112,9 @@
const indexUrl = profileIndexUrl
? `${base}data/${profileIndexUrl}`
: `${base}data/index.json`;
- const index = await loadIndex(base, indexUrl);
+ const { index, pendingShards: pending } = await loadIndexPaged(base, indexUrl);
+ pendingShards = pending;
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 as any).handle === filterHandle);
}
@@ -230,10 +255,17 @@
{#if hasMore}
shown += PAGE_SIZE}
+ class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white disabled:opacity-40 transition-colors text-sm"
+ disabled={loadingMore}
+ on:click={loadMore}
>
- Load more ({filtered.length - shown} remaining)
+ {#if loadingMore}
+ Loading…
+ {:else if canShowMore}
+ Load more ({filtered.length - shown} remaining)
+ {:else}
+ Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'year' : 'years'})
+ {/if}
{/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 @@