New endpoint: GET /api/activity/{id}/download/{bas|original|gpx}
- bas: streams the BAS detail JSON as an attachment
- original: streams the original FIT or GPX file from originals/
- gpx: generates a GPX from the timeseries (always available when GPS exists)
download_disabled flag stored in sidecar (edits/{id}.md), propagated to
the merged BAS detail JSON. When set, only the owner can download.
Backend: ops.py writes flag to sidecar; merge.py propagates it to detail
JSON; download.py implements the endpoint; server.py registers the router.
Frontend: EditDrawer gets a "No download" toggle button; ActivityDetail
shows a Download section (hidden when disabled and viewer is not the owner).
The 285-line _HTML string literal in edit/server.py is replaced by a
template file loaded at request time. The route handler is unchanged in
behaviour — it still substitutes __SITE_URL__, __SPORT_OPTIONS__, and
__STAT_CHECKBOXES__ before returning the response.
Five new tests cover: 200 response, form presence, site_url injection,
no unresolved placeholders, and template file existence on disk.
ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES, and unique_image_name() were
duplicated identically in both the edit and serve servers. Centralising
them means a single change point for any future extension (e.g. adding
image/avif support).
Tests added in tests/test_shared_images.py cover no-collision, single
and chained collisions, no-suffix filenames, and constant values.
- Add GET /api/wheel/download to serve/server.py and edit/server.py:
serves dist/bincio-*.whl via FileResponse; in production nginx takes
the request before FastAPI, so this is a no-op there but works locally
- wheel_version response now includes api_url: "/api/wheel/download"
alongside the nginx-served url field
- Bundle mobile/assets/bincio.whl (built from dist/) as an offline
fallback for Pyodide testing before the first instance sync
- docs/mobile-app.md: document dev setup — bundled asset, local server
endpoint, and how to refresh the bundle with uv build + cp
- dem.py: apply 45s median filter before hysteresis to suppress SRTM
tile-boundary steps that were accumulating through the 5m threshold;
raise DEM hysteresis threshold from 5m to 10m
- dem.py: back up elevation_m as elevation_m_original in timeseries
before the first DEM overwrite, so original sensor data is preserved
- dem.py: add recalculate_elevation_hysteresis() — recomputes gain/loss
from original recorded elevation (reads elevation_m_original if a DEM
run already replaced elevation_m) using source-aware thresholds
(5m barometric, 10m GPS/unknown); does not touch the elevation array
- edit/server.py, serve/server.py: split /recalculate-elevation into
two endpoints: /recalculate-elevation/dem and
/recalculate-elevation/hysteresis
- EditDrawer.svelte: replace single DEM button with two side-by-side
buttons — "Recalculate (hysteresis)" (fast, offline) and
"Recalculate (DEM)" (SRTM lookup)
Adds a "Recalculate from terrain map (DEM)" button to the activity edit
drawer. On click it queries an Open-Elevation-compatible API to replace
GPS altitude with SRTM terrain data, applies 5m hysteresis, and updates
the activity's elevation stats and timeseries chart in place.
- bincio/extract/dem.py: lookup_elevations() (batched HTTP POST) +
recalculate_elevation() (subsample → DEM → interpolate → hysteresis →
patch activity JSON, timeseries JSON, index.json)
- POST /api/activity/{id}/recalculate-elevation on both serve and edit
servers; serve endpoint is auth-gated and triggers merge + rebuild
- --dem-url flag (also DEM_URL env var) on bincio serve and bincio edit;
logged at startup; missing URL returns a clear 503 with setup instructions
- /api/me response gains dem_configured bool
- EditDrawer: button with loading state, shows new ↑/↓ values on success
- serve/server.py GET adds private:bool to the response (true when
privacy is "unlisted" or legacy "private") so EditDrawer can read it
- edit/server.py GET: same fix for the single-user edit server
- EditDrawer: fall back to d.privacy if d.private is absent; rename
"Private" toggle label to "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"
- 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
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
- 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).
- 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
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>.
- Fix first sync finding 0 activities: remove last_sync_at stamp at
connect time so the first sync checks all Strava history (existence
check skips already-extracted files without fetching streams)
- Add POST /api/strava/reset with soft/hard modes: soft sets last_sync_at
to the most recent activity already on disk; hard clears it entirely
- Surface error_count in sync response and status message
- Add Reset / Hard reset buttons below Sync now in the upload modal
- Reload on bfcache restore so client:only components re-mount after
back navigation
- bincio/render/merge.py: parse sidecar .md files (YAML frontmatter +
markdown body), produce data/_merged/ with symlinks for unmodified
activities and real merged files for overridden ones; filters private
activities from index.json; sorts highlighted activities first.
Keeps extracted data pristine — re-running extract never clobbers edits.
- bincio/edit/: FastAPI edit server (port 4041) with embedded HTML/JS
edit UI; GET/POST /api/activity/{id} reads/writes sidecars; multipart
image upload to edits/images/{id}/; DELETE for image cleanup.
- bincio render now calls merge_all() before build/serve and symlinks
public/data → data/_merged/ instead of data/ directly.
- ActivityDetail.svelte: edit button (links to edit server) when
PUBLIC_EDIT_URL env var is set; respects custom.hide_stats to suppress
stat panels; description supports whitespace-preserving rendering.
- 15 unit tests covering parse_sidecar, apply_sidecar, and merge_all.