Commit Graph

44 Commits

Author SHA1 Message Date
Davide Scaini b01b00698c rewrite reextract with queue-based thread/async bridge
The per-call run_in_executor pattern caused network errors.
New approach: one thread runs the entire extraction loop and puts
SSE strings into an asyncio.Queue via call_soon_threadsafe; the
async generator drains the queue. This is the correct pattern for
background-thread + SSE streaming in FastAPI.
2026-04-15 09:14:18 +02:00
Davide Scaini 10dd1185b9 fix reextract: async generator + run_in_executor, imports at endpoint level
The sync generator was failing with a network error because Starlette's
iterate_in_threadpool doesn't properly propagate exceptions from sync
generators — the connection resets with no body.

Fix: convert event_stream to an async generator (Starlette handles these
natively without thread wrapping), move imports to the endpoint function
scope so failures raise HTTPException before the stream starts, and run
CPU-intensive work (parse + write) via loop.run_in_executor so the
async generator can actually yield between activities.
2026-04-15 09:05:29 +02:00
Davide Scaini 378cba85ad fix re-extract: add heartbeat yield, batch index writes, handle HTTP errors in UI
- Generator now yields a 'status' event immediately so the client can
  distinguish 'working' from 'failed silently before first event'
- Batch mode: call write_activity per file but write index.json and
  athlete.json only once at the end (was O(n²) — 2015 rewrites)
- JS: check r.ok before reading the body stream; show HTTP error detail
  instead of staying stuck at 'Starting…'
- Handle 'status' event type in the progress log
2026-04-15 08:57:21 +02:00
Davide Scaini 89b92397cf add re-extract from Strava originals endpoint and improve diag
- POST /api/admin/users/{handle}/reextract-originals: reads stored
  originals/strava/*.json and re-runs strava_to_parsed + ingest_parsed
  without hitting the Strava API; streams SSE progress; calls merge_all
  and rebuild on completion
- GET /api/admin/users/{handle}/diag: now shows _merged/activities/
  file counts, a sample of filenames in activities/ (with symlink flag),
  and lists pending_files by name
- Admin page: Re-extract button per user with live SSE progress modal
2026-04-15 08:08:57 +02:00
Davide Scaini 1e30f85bdc add structured logging and admin diagnostics to serve
- 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
2026-04-14 22:53:31 +02:00
Davide Scaini 13643479ef add password reset via admin-generated one-time code
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.
2026-04-14 21:58:50 +02:00
Davide Scaini d2ba96c26a fix admin delete to wipe originals/edits/geojson; rename button to Reset data
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.
2026-04-13 20:10:15 +02:00
Davide Scaini a75dfa160b F14: add per-activity delete (DELETE /api/activity/{id} + drawer button)
Server endpoint removes the activity JSON, GeoJSON, timeseries, sidecar
edit, and images directory. Also purges the dedup cache entry so the
file can be re-uploaded if needed. Runs merge_all + rebuild afterwards.

EditDrawer: two-click delete button (click once → "Confirm delete?",
click again → deletes). On success, dispatches 'deleted' event.
ActivityDetail navigates back to the feed on delete.
2026-04-13 19:35:40 +02:00
Davide Scaini e7eefa345e F17: replace merge_all with merge_one in upload_image and delete_image
Single-activity writes now trigger a fast merge_one instead of a full
user rebuild. post_activity was fixed earlier; this completes the fix
for upload_image and delete_image endpoints.
2026-04-13 19:03:46 +02:00
Davide Scaini 5ad3aee8f6 rename privacy "private" → "unlisted"; enable GPS for unlisted
- "unlisted" = not shown in the public feed, but GPS track, timeseries
  and detail JSON are all accessible by direct URL (security by obscurity)
- "private" accepted as legacy alias everywhere (backward compat with
  existing data on disk)
- New writes from Strava sync / ZIP upload / sidecar use "unlisted"
- Only "no_gps" now suppresses the GPS track
- isUnlisted() helper in format.ts used by all Svelte/Astro components
- SCHEMA.md and CLAUDE.md document the privacy model and the distinction
  between "unlisted" and "no_gps"
2026-04-13 18:49:20 +02:00
Davide Scaini 1587d1cdf3 - brut: _merged/index.json has 586 activities — the count when merge_all last ran. The SSE rebuild bug (already fixed) meant it never re-ran after the full Strava sync
added 3256 more.
  - danilo: _merged/ is 8 KB — basically empty. merge_all likely ran concurrently (multiple file uploads trigger multiple rebuilds without a lock in --no-build mode),
  causing a race where shutil.rmtree(merged_acts) from one run wiped what another run was writing.

  Two fixes: serialize --no-build rebuilds with the same lock, and add a "Rebuild" button to the admin page.

 Root causes fixed:
  1. merge_all race condition — --no-build rebuilds now hold _rebuild_lock, same as full builds
  2. The SSE rebuild-trigger bug (already fixed earlier) was brut's original cause
2026-04-13 12:35:05 +02:00
Davide Scaini 7b37f45180 Bug fixed — temp ZIPs now go to /tmp/ (system temp) and are always deleted in a finally block, so they can't leak. A startup hook also auto-cleans any leftovers on
next server restart.

  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)
2026-04-13 12:24:59 +02:00
Davide Scaini 7e526c14e1 fix commit d659b90cd9 2026-04-12 19:55:13 +02:00
Davide Scaini d659b90cd9 - DELETE /api/admin/users/{handle}/activities — deletes all activities/*.json, wipes _merged/ and
index.json, then triggers a rebuild. Admin-only.
  - /admin/ page — lists all users, each with a "Delete activities" button. Clicking asks for
  confirmation in a <dialog> before firing the request. Button shows "Deleted (N)" or an error inline.
  - "Admin" nav link — appears in the top-right for admins only, hidden for everyone else.
2026-04-12 17:46:28 +02:00
Davide Scaini f003fdd89f garmin sync first attempt 2026-04-12 15:36:21 +02:00
Davide Scaini e80231b442 fix strava sync rebuild: trigger before yielding done event, not after 2026-04-12 14:55:33 +02:00
Davide Scaini 0b569b727c trigger site rebuild after new user registration so profile pages exist immediately 2026-04-11 14:19:40 +02:00
Davide Scaini 5de9967127 fix upload 500: add missing _file_suffix to serve server; fix iOS file picker accept types 2026-04-11 14:12:54 +02:00
Davide Scaini ef5b06c5b3 trigger rebuild after activities upload 2026-04-11 08:47:27 +02:00
Davide Scaini 82830222ba For users uploading:
- POST /api/upload now returns text/event-stream instead of JSON
  - Per-file progress events stream back as each file is processed: ↓ 3/47 (6%) — morning_ride.fit
  - Final done event shows the summary: "12 added, 35 duplicates"
  - The Vite proxy is configured to stream this properly (no buffering)

  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
2026-04-11 08:33:21 +02:00
Davide Scaini 01db4eb9ae ingest activities.csv 2026-04-11 08:13:27 +02:00
Davide Scaini bc30e0a2fc option to keep all activities private from strava zip, fix copy of register link 2026-04-10 22:51:29 +02:00
Davide Scaini 3b8bc159c5 upload strava zip 2026-04-10 22:01:44 +02:00
Davide Scaini 9fd088c693 - "Last sync: never": The old blocking sync was killed by nginx at 120s before save_token was reached. The activities made it to disk (ingestion happens per-activity as it goes), but the token's
last_sync_at timestamp was never written. After deploying, do a soft reset — it'll set last_sync_at to your most recent activity's timestamp so the next sync only fetches newer ones.
  - Reset 404: Added POST /api/strava/reset to serve/server.py. The soft reset now looks in _merged/index.json first (multi-user path), falling back to index.json.
2026-04-10 18:34:53 +02:00
Davide Scaini 816f103b4c fix: write empty index.json for new users at registration so shard resolves immediately 2026-04-10 18:20:35 +02:00
Davide Scaini cf414a08ad fix strava import? 2026-04-10 18:13:32 +02:00
Davide Scaini e2765ea012 fix for strava auth 2026-04-10 18:01:32 +02:00
Davide Scaini f67e7552fd fix to strava sync 2026-04-10 17:55:24 +02:00
Davide Scaini eeed3fe3b2 Root cause of the 404: _trigger_rebuild was firing bincio render (= full astro build), but:
1. The build took minutes → 404 during that window
  2. Even after the build, the output lands in site/dist/ — nginx serves from /var/www/bincio/ which is only updated by the rsync in the post-receive hook, not by the server process

  Fixes applied:

  1. bincio/render/cli.py: Added --no-build flag — merges sidecars and updates manifests but skips astro build. This is fast (~1 second).
  2. bincio/serve/server.py _trigger_rebuild: Now passes --no-build. After an upload, _merged/ and root index.json are updated immediately, so the feed reflects the new activity. The static Astro pages are
  only rebuilt on git push.
  3. site/src/components/ActivityDetailLoader.svelte (new): Svelte component that reads the activity ID from the URL, calls loadIndex to resolve the shard tree, then renders ActivityDetail dynamically — no
  pre-built page needed.
  4. site/src/pages/activity/index.astro (new): Generic Astro shell page that renders ActivityDetailLoader. Gets compiled to dist/activity/index.html.
  5. docs/deployment/vps.md: Added location /activity/ { try_files $uri $uri/ /activity/index.html; } to the nginx config. When a request arrives for /activity/2026-04-06T153345Z/ and no pre-built file
  exists, nginx serves the shell, which loads the data dynamically from /data/ (which nginx already serves live from disk).
2026-04-10 17:48:23 +02:00
Davide Scaini 96a3deee5d fix: serve data/ from disk via nginx alias; return full athlete data from API 2026-04-10 15:57:23 +02:00
Davide Scaini 6a8ef984cb fix: GET/POST /api/athlete work without a pre-existing athlete.json 2026-04-10 15:37:05 +02:00
Davide Scaini 8d8b009a78 fix: use shutil.which to find uv in _trigger_rebuild; never 500 on rebuild failure 2026-04-10 15:33:06 +02:00
Davide Scaini 6d3673b2f7 1. Image upload size limit — _MAX_IMAGE_BYTES = 10 MB in both serve/server.py and edit/server.py
2. Image MIME type whitelist — _ALLOWED_IMAGE_TYPES blocks SVG XSS in both servers
  3. Filename collision safety — _unique_image_name() helper in both servers
  4. OAuth CSRF — state token generated in edit/server.py auth-url, stored in _oauth_states, validated and discarded in callback; strava_api.auth_url() accepts optional state param
  5. Error message leak — upload processing errors now return generic "Processing failed" instead of exception type/message
  6. Handle injection in subprocess — _trigger_rebuild now asserts handle matches _VALID_HANDLE before passing to subprocess
2026-04-10 13:56:39 +02:00
Davide Scaini 053da10ab9 some basic statistics and invite tree, plus watch new data 2026-04-10 13:21:31 +02:00
Davide Scaini 469a5954cc "keep data on the server" opt-in/out 2026-04-10 13:01:21 +02:00
Davide Scaini 8ceb714765 bulk upload 2026-04-10 12:50:38 +02:00
Davide Scaini 683b7d9c1b limit max number of users 2026-04-10 12:38:17 +02:00
Davide Scaini cbac82a2ba Fix 1 — new user pages 404 (server.py:228):
After registration creates the user's directories, it now calls _write_root_manifest(dd). This rewrites index.json to include the new handle's shard immediately. Since Astro dev re-evaluates getStaticPaths() on every request (reading that file), /u/pres/, /u/pres/stats/, and /u/pres/athlete/ will resolve correctly as soon as the new user navigates there.

  Fix 2 — invites link (athlete/index.astro:33):
  Added an Invites button (top-right, same style as "Edit profile") that starts hidden. When bincio:me fires and me === handle (you're on your own page), the subnav tabs are removed as before AND the invites button is revealed. Other visitors see neither.
2026-04-09 21:44:38 +02:00
Davide Scaini 8118f6f316 1 — Timeseries split
- writer.py: timeseries is now written to {id}.timeseries.json as a separate file. The detail JSON gets a timeseries_url field instead. finalize_pending and cleanup_pending handle the extra file.
  - merge.py (merge_one): symlinks the .timeseries.json file alongside the detail JSON. merge_all already handles it transparently (the .timeseries.json stem doesn't match any activity
  ID in to_merge, so it falls through to the symlink branch).
  - types.ts: timeseries is now timeseries?: Timeseries | null, and timeseries_url?: string | null added.
  - dataloader.ts: new loadTimeseries(url, detailUrl, base) function that resolves paths correctly in both single- and multi-user modes (uses the fetched detail URL's directory as the base).
  - ActivityDetail.svelte: loads timeseries separately after detail loads; uses detail.timeseries for IDB activities (embedded) or fetches via detail.timeseries_url for server activities. Charts show a pulse placeholder while loading.

 2 — GZip

  - GZipMiddleware (min 1 KB) added to both bincio/serve/server.py and bincio/edit/server.py — all API JSON responses are now gzip-compressed.
  - For static files (the big timeseries JSONs), nginx should be configured with gzip on; gzip_types application/json application/geo+json; — no code change needed on the server side.

  Net effect: opening an activity page now fetches ~1.4 KB (detail) instead of ~586 KB. The timeseries fetches ~60–150 KB gzip-compressed shortly after (it loads concurrently with the map rendering).
2026-04-09 14:01:02 +02:00
Davide Scaini 5a29259259 fix edit profile 2026-04-09 13:21:47 +02:00
Davide Scaini 7dcb1e6dd0 refactor: extract/ingest facade, merge_one, deduplicate ops constants
- Add bincio/extract/ingest.py as a facade over the extract internals (ingest_parsed, strava_sync), reducing coupling from 6+ imports to one
  - Add merge_one() to merge.py — fast single-activity path for interactive edits (rewrites one file + index, skips full directory rebuild)
  - Rewrite edit/ops.py to delegate to the new facade; fix broken run_strava_sync return (was referencing undefined locals)
  - Remove duplicated SPORTS, STAT_PANELS, VALID_ACTIVITY_ID from edit/server.py — now imported from ops.py
2026-04-09 12:05:01 +02:00
Davide Scaini cf7c71b8a3 (opus assessment) Fix auth wall flash, broken multi-user write API, and single-user redirect loop
Auth wall (Base.astro): set data-auth-pending on <body> at SSG time and hide
  it with inline CSS before any JS runs; remove the attribute after /api/me
  resolves. Eliminates the flash of protected content on private instances.

  Multi-user write API (serve/server.py): the previous _apply_sidecar_edit and
  strava_sync imports from bincio.edit.server were broken (those names don't
  exist as module-level exports) and the Strava sync mutated a global data_dir,
  making concurrent requests from different users racy. Fix: extract both
  operations into bincio/edit/ops.py as pure functions that take data_dir
  explicitly. Both edit/server.py and serve/server.py now import from there.

  Security: add rate limiting to POST /api/register (5 attempts / 15 min / IP,
  separate bucket from login). Add _check_id() activity ID validation to both
  GET and POST /api/activity/{id} in serve/server.py.

  Single-user mode: _write_root_manifest now forces instance.private=false when
  no instance.db exists, even if a previous run wrote true. Prevents the auth
  wall from firing and redirecting to /login/ when bincio serve isn't running.

  ActivityFeed: skip filterHandle when profileIndexUrl is set (per-user profile
  pages load the right shard directly; activities have no handle tag at that
  point, so the filter was producing an empty feed). Fix handle links to point
  to /u/{handle}/ instead of /{handle}/. Fix <a>-inside-<a> Svelte warning by
  converting the inner handle link to a <button>.
2026-04-09 09:19:48 +02:00
Davide Scaini 98c42dc443 unify single user and multi user behaviour 2026-04-09 08:59:40 +02:00
Davide Scaini f76cc0ce7e towards multi-user 2026-04-08 19:37:10 +02:00