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"
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(//Users/brutsalvadi/src/bincio_data/_merged/**)",
|
||||||
|
"Read(//Users/brutsalvadi/src/bincio_data/**)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.get\\(''''dependencies'''',{}\\).keys\\(\\)\\)\\)\")",
|
||||||
|
"Bash(uv run:*)",
|
||||||
|
"Bash(python3 -c \"import pyodide\")",
|
||||||
|
"Bash(uv build:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(mkdir -p /tmp/bincio_ci/activities)",
|
||||||
|
"Bash(BINCIO_DATA_DIR=/tmp/bincio_ci npm run build)",
|
||||||
|
"Bash(ls /Users/brutsalvadi/src/bincio_activity/*.md)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(wc -l /Users/brutsalvadi/src/bincio_activity/bincio/extract/*.py)",
|
||||||
|
"Bash(grep -E \"\\\\.py$\")",
|
||||||
|
"Bash([ -f ~/src/cycling_data_davide/activities.csv ])",
|
||||||
|
"Read(//Users/brutsalvadi/src/cycling_data_davide/**)",
|
||||||
|
"Bash(ls -la /tmp/bincio_dev_test/dave/_merged/activities/*.json)",
|
||||||
|
"Bash(ls -la /tmp/bincio_dev_test/dave/_merged/activities/*.geojson)",
|
||||||
|
"Bash(unzip -l ~/src/cycling_data_davide/export_18885842.zip)",
|
||||||
|
"Bash(unzip -p export_18885842.zip profile.csv)",
|
||||||
|
"Bash(unzip -p export_18885842.zip bikes.csv)",
|
||||||
|
"Bash(unzip -p export_18885842.zip routes.csv)",
|
||||||
|
"Bash(unzip -p export_18885842.zip privacy_zones.csv)",
|
||||||
|
"Bash(unzip -p export_18885842.zip activities.csv)",
|
||||||
|
"Bash(xargs -I{} python3 -c \"import json,sys; d=json.load\\(open\\('{}'\\)\\);print\\({k:d.get\\(k\\) for k in ['title','strava_id','started_at','privacy']}\\)\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,11 @@ gear: "Trek Domane"
|
|||||||
Rode with friends. Legs felt great after the rest week...
|
Rode with friends. Legs felt great after the rest week...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The sidecar `private: true` flag maps to `privacy: "unlisted"` in the merged JSON.
|
||||||
|
**`unlisted`** means: not shown in the public feed, but the activity detail, GPS track,
|
||||||
|
and timeseries are all accessible by direct URL (security by obscurity, same model
|
||||||
|
as the detail JSON itself). Use `no_gps` if the GPS track must not be published.
|
||||||
|
|
||||||
### Editing UX: drawer in Astro + `bincio edit` write API
|
### Editing UX: drawer in Astro + `bincio edit` write API
|
||||||
|
|
||||||
- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041
|
- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ needed to render an activity card in a feed — no timeseries, no full track.
|
|||||||
| `avg_cadence_rpm` | integer\|null | no | Average cadence (rpm for cycling, spm for running). |
|
| `avg_cadence_rpm` | integer\|null | no | Average cadence (rpm for cycling, spm for running). |
|
||||||
| `avg_power_w` | integer\|null | no | Average power in watts. |
|
| `avg_power_w` | integer\|null | no | Average power in watts. |
|
||||||
| `source` | string\|null | no | Origin of data. See **Source values**. |
|
| `source` | string\|null | no | Origin of data. See **Source values**. |
|
||||||
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `private`. |
|
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `unlisted`. (`private` is a deprecated alias for `unlisted`.) |
|
||||||
| `mmp` | array\|null | no | Mean Maximal Power curve — `[[duration_s, avg_watts], ...]`. |
|
| `mmp` | array\|null | no | Mean Maximal Power curve — `[[duration_s, avg_watts], ...]`. |
|
||||||
| `best_efforts` | array\|null | no | Best efforts by distance — `[[distance_km, time_s], ...]`. |
|
| `best_efforts` | array\|null | no | Best efforts by distance — `[[distance_km, time_s], ...]`. |
|
||||||
| `best_climb_m` | number\|null | no | Best single climb in metres (Kadane's algorithm). |
|
| `best_climb_m` | number\|null | no | Best single climb in metres (Kadane's algorithm). |
|
||||||
@@ -165,12 +165,21 @@ timestamp alone is sufficient: `2024-06-01T073012Z`.
|
|||||||
|
|
||||||
### Privacy levels
|
### Privacy levels
|
||||||
|
|
||||||
| Level | GPS track published | Timeseries lat/lon | Stats in index |
|
| Level | GPS track published | Timeseries lat/lon | Shown in feed |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `public` | Full track | Included | Yes |
|
| `public` | Full track | Included | Yes — everyone |
|
||||||
| `blur_start` | First/last 200 m removed | Trimmed | Yes |
|
| `blur_start` | First/last 200 m removed | Trimmed | Yes — everyone |
|
||||||
| `no_gps` | Not published | Not included | Yes |
|
| `no_gps` | Not published | Not included | Yes — everyone |
|
||||||
| `private` | Not published | Not included | No (not in index at all) |
|
| `unlisted` | Full track | Included | No — owner only (via direct URL) |
|
||||||
|
| `private` | *(deprecated alias for `unlisted`)* | Included | No — owner only |
|
||||||
|
|
||||||
|
**`unlisted`** activities are not shown in the public feed but are fully accessible
|
||||||
|
by direct URL — the GPS track, timeseries, and detail JSON are all served as normal
|
||||||
|
static files. This is "security by obscurity": knowing the URL is sufficient to
|
||||||
|
access the activity. If you need true data exclusion, use `no_gps` for GPS removal
|
||||||
|
while keeping stats public, or delete the activity entirely.
|
||||||
|
|
||||||
|
The legacy `private` value is accepted everywhere `unlisted` is valid.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Advice on ARCHITECTURE.md
|
||||||
|
|
||||||
|
Review of the mobile / offline plan in `ARCHITECTURE.md`. The two-stage extract/render pipeline, edit flow, and federation sections are accurate and clear — this document focuses on where the architectural risk lives: the mobile app and the path to fully-offline operation.
|
||||||
|
|
||||||
|
## What's strong
|
||||||
|
|
||||||
|
- **Workflow framing (1–4) is the right way to think about it.** Each workflow is a concrete user story, not an abstract capability list. Workflow 1 (record → convert → cloud) is achievable with what's already there; that's a good wedge to ship first.
|
||||||
|
- **Honest status table** at the bottom of "Fully offline — missing pieces". An architecture doc that admits what isn't done is more useful than one that hand-waves.
|
||||||
|
- **Incremental validation plan (§5).** Testing the service worker approach in the browser before touching native builds is the right call — cheap to run, and the failure mode is informative rather than expensive.
|
||||||
|
|
||||||
|
## Decision: drop `capacitor-nodejs`
|
||||||
|
|
||||||
|
The doc previously listed `capacitor-nodejs` as a fallback if service workers fail on iOS. We are dropping this option entirely. Reasons:
|
||||||
|
|
||||||
|
- It embeds a full Node runtime, meaningfully increasing app size.
|
||||||
|
- iOS support relies on a fork of Node-mobile and has historically been spotty.
|
||||||
|
- It introduces a long-term maintenance burden (tracking upstream Node, plugin updates, two runtimes to debug).
|
||||||
|
- The "Hard" effort label in the original status table understated both the integration risk and the ongoing cost.
|
||||||
|
|
||||||
|
If service workers turn out to be blocked on iOS, the fallback should be one of:
|
||||||
|
|
||||||
|
1. **A data access abstraction** (see next section) that lets the app read from IndexedDB directly via a JS loader, with no `fetch('/data/*')` in the hot path at all. This sidesteps the WKWebView question entirely.
|
||||||
|
2. **A native Swift/Kotlin micro-server** if a local HTTP origin is genuinely required.
|
||||||
|
3. **Bundling data as static assets** and re-running `cap sync` on import — crude but boring and reliable.
|
||||||
|
|
||||||
|
`capacitor-nodejs` should not appear in the doc, the test plan, or the status table.
|
||||||
|
|
||||||
|
## Things to fix or add
|
||||||
|
|
||||||
|
### 1. Add a data access abstraction step *before* the service worker work
|
||||||
|
|
||||||
|
The doc frames the offline problem as "serve local data to the WebView at `/data/*`". That skips a prior question: does the Astro site actually need to fetch `/data/*` at runtime, or can the data layer be abstracted behind a thin loader (`loadIndex()`, `loadActivity(id)`) with two implementations?
|
||||||
|
|
||||||
|
- **Cloud build:** loader uses `fetch('/data/...')` as today.
|
||||||
|
- **App build:** loader reads from IndexedDB directly.
|
||||||
|
|
||||||
|
This is a much smaller change than service worker interception, avoids the iOS WKWebView question entirely, and is useful even if you stay cloud-only (testability, mocking, future federation transports). It should land *before* any service worker work — at which point the SW work may turn out to be unnecessary.
|
||||||
|
|
||||||
|
### 2. Commit to JS for sidecar merge — do not use Pyodide
|
||||||
|
|
||||||
|
The "missing pieces" section lists two options for re-running `merge_all` on save: reimplement in JS (~150 lines) or call into Pyodide. Pick JS. Reasons:
|
||||||
|
|
||||||
|
- Pyodide is lazy-loaded by `/convert/`. It is **not** warm just because the app is open.
|
||||||
|
- A user tapping Edit → change title → Save would trigger a multi-second Pyodide cold start (~10MB) for a one-line edit. Terrible UX, and it repeats on every cold app launch.
|
||||||
|
- Porting `merge_all` to JS keeps the edit drawer decoupled from the convert page's machinery. The two subsystems stay independent.
|
||||||
|
|
||||||
|
Pyodide should remain convert-page-only.
|
||||||
|
|
||||||
|
### 3. The Workflow 4 diagram contradicts the test plan
|
||||||
|
|
||||||
|
The "Fully offline on phone" workflow shows a "Local Node server" arrow as if it were the chosen path. The §5 test plan picks service workers first. The diagram should reflect that — show the SW path as primary, and drop the Node server box entirely (per the decision above).
|
||||||
|
|
||||||
|
### 4. Missing: data lifecycle on device
|
||||||
|
|
||||||
|
Nothing in the doc covers:
|
||||||
|
|
||||||
|
- How much storage the app is allowed to use on iOS before the OS evicts it.
|
||||||
|
- What happens on uninstall / reinstall.
|
||||||
|
- Sync / conflict resolution if the same activity exists locally and on a cloud instance.
|
||||||
|
|
||||||
|
These don't need solutions today, but they should be acknowledged as open questions. Otherwise Workflow 4 will hit all of them at once during implementation.
|
||||||
|
|
||||||
|
### 5. Reconcile Pyodide payload size
|
||||||
|
|
||||||
|
Line 162 says "~8MB", line 335 says "~10MB". Pick one and use it consistently.
|
||||||
|
|
||||||
|
## Small stuff
|
||||||
|
|
||||||
|
- The federation diagram uses a `Note1` node that won't render as a Mermaid note — it'll appear as a regular box. Use `%%` comments or restructure.
|
||||||
|
- The "iOS: App Store / TestFlight" cell in the PWA-vs-Capacitor table sits in the Capacitor column but reads like a downside. Clarify it's the distribution path, not a limitation relative to PWA.
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
The plan is sound and the incremental validation approach is right. The two highest-leverage changes:
|
||||||
|
|
||||||
|
1. **Add a data access abstraction layer** before the service worker work. It's small, useful regardless, and may make the SW work moot.
|
||||||
|
2. **Port `merge_all` to JS** so the edit drawer doesn't depend on Pyodide warm-up.
|
||||||
|
|
||||||
|
With `capacitor-nodejs` removed, the offline path is: data access abstraction → IndexedDB-backed loader → JS merge → (optionally) service worker for any remaining `fetch('/data/*')` callsites that can't be migrated to the loader.
|
||||||
@@ -183,7 +183,7 @@ textarea { resize: vertical; min-height: 140px; }
|
|||||||
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
|
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle" id="toggle-private">
|
<label class="toggle" id="toggle-private">
|
||||||
<input type="checkbox" id="private" name="private"> Private (hide from feed)
|
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
|
|||||||
source = f"strava:{meta['id']}"
|
source = f"strava:{meta['id']}"
|
||||||
source_hash = "sha256:" + hashlib.sha256(source.encode()).hexdigest()
|
source_hash = "sha256:" + hashlib.sha256(source.encode()).hexdigest()
|
||||||
|
|
||||||
# Map Strava visibility to BAS privacy: only_me → private, everything else → public
|
# Map Strava visibility to BAS privacy: only_me → unlisted, everything else → public
|
||||||
visibility = meta.get("visibility") or ""
|
visibility = meta.get("visibility") or ""
|
||||||
is_private = meta.get("private", False) or visibility == "only_me"
|
is_private = meta.get("private", False) or visibility == "only_me"
|
||||||
|
|
||||||
@@ -214,5 +214,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
|
|||||||
title=meta.get("name") or None,
|
title=meta.get("name") or None,
|
||||||
description=meta.get("description") or None,
|
description=meta.get("description") or None,
|
||||||
strava_id=str(meta["id"]),
|
strava_id=str(meta["id"]),
|
||||||
privacy="private" if is_private else "public",
|
privacy="unlisted" if is_private else "public",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ def build_timeseries(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return the BAS `timeseries` object.
|
"""Return the BAS `timeseries` object.
|
||||||
|
|
||||||
privacy='no_gps' or 'private' → lat/lon set to null.
|
privacy='no_gps' → lat/lon set to null. All other privacy levels
|
||||||
|
(including 'unlisted') retain GPS in the timeseries.
|
||||||
Downsamples so at most one point per second is emitted.
|
Downsamples so at most one point per second is emitted.
|
||||||
"""
|
"""
|
||||||
if not points:
|
if not points:
|
||||||
return {"t": []}
|
return {"t": []}
|
||||||
|
|
||||||
include_gps = privacy not in ("no_gps", "private")
|
include_gps = privacy not in ("no_gps", "private") # "private" = legacy alias for "unlisted"
|
||||||
|
|
||||||
# Downsample: keep at most one point per second
|
# Downsample: keep at most one point per second
|
||||||
sampled: list[DataPoint] = []
|
sampled: list[DataPoint] = []
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ def write_activity(
|
|||||||
acts_dir.mkdir(parents=True, exist_ok=True)
|
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
source = _infer_source(activity)
|
source = _infer_source(activity)
|
||||||
has_gps = metrics.bbox is not None and privacy not in ("no_gps", "private")
|
# "unlisted" activities keep their GPS track (not in the public feed, but the
|
||||||
|
# URL is not secret — same model as the detail JSON). Only "no_gps" suppresses
|
||||||
|
# the track. "private" is the legacy alias for "unlisted".
|
||||||
|
has_gps = metrics.bbox is not None and privacy not in ("no_gps",)
|
||||||
|
|
||||||
# Build timeseries once — written to a separate file to keep detail JSON small.
|
# Build timeseries once — written to a separate file to keep detail JSON small.
|
||||||
# Treat an empty timeseries (no points) as None so no file is created.
|
# Treat an empty timeseries (no points) as None so no file is created.
|
||||||
@@ -220,7 +223,7 @@ def build_summary(
|
|||||||
privacy: str = "public",
|
privacy: str = "public",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build the Activity Summary object for index.json."""
|
"""Build the Activity Summary object for index.json."""
|
||||||
has_gps = metrics.bbox is not None and privacy not in ("no_gps", "private")
|
has_gps = metrics.bbox is not None and privacy not in ("no_gps",)
|
||||||
return {
|
return {
|
||||||
"id": activity_id,
|
"id": activity_id,
|
||||||
"title": activity.title or _auto_title(activity),
|
"title": activity.title or _auto_title(activity),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
|||||||
if "highlight" in fm:
|
if "highlight" in fm:
|
||||||
d["custom"]["highlight"] = bool(fm["highlight"])
|
d["custom"]["highlight"] = bool(fm["highlight"])
|
||||||
if "private" in fm:
|
if "private" in fm:
|
||||||
d["privacy"] = "private" if fm["private"] else detail.get("privacy", "public")
|
d["privacy"] = "unlisted" if fm["private"] else detail.get("privacy", "public")
|
||||||
if "hide_stats" in fm:
|
if "hide_stats" in fm:
|
||||||
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
|
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
|||||||
if "highlight" in fm:
|
if "highlight" in fm:
|
||||||
s["custom"]["highlight"] = bool(fm["highlight"])
|
s["custom"]["highlight"] = bool(fm["highlight"])
|
||||||
if "private" in fm:
|
if "private" in fm:
|
||||||
s["privacy"] = "private" if fm["private"] else summary.get("privacy", "public")
|
s["privacy"] = "unlisted" if fm["private"] else summary.get("privacy", "public")
|
||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -260,10 +260,10 @@ def merge_all(data_dir: Path) -> int:
|
|||||||
s = _apply_sidecar_summary(s, fm)
|
s = _apply_sidecar_summary(s, fm)
|
||||||
activities.append(s)
|
activities.append(s)
|
||||||
|
|
||||||
# Drop private activities from the published feed
|
# "unlisted" (and legacy "private") activities are kept in the index so
|
||||||
# Sort: newest first, then bring highlighted activities to the top
|
# the owner can reach them by direct URL; the feed UI filters them out
|
||||||
# Private activities are kept in the index so the owner can see them;
|
# for non-owners client-side.
|
||||||
# the feed UI filters them out for non-owners client-side.
|
# Sort: newest first, then bring highlighted activities to the top.
|
||||||
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
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)
|
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
|
||||||
|
|
||||||
|
|||||||
@@ -879,7 +879,7 @@ async def upload_strava_zip(
|
|||||||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||||
raise HTTPException(400, "Please upload a .zip file")
|
raise HTTPException(400, "Please upload a .zip file")
|
||||||
|
|
||||||
privacy = "private" if private.lower() in ("true", "1", "yes") else "public"
|
privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public"
|
||||||
|
|
||||||
dd = _get_data_dir() / user.handle
|
dd = _get_data_dir() / user.handle
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Repository Audit — 2026-03-30
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 1 | Security | `edit/server.py:351,386,431,588` | **Path traversal** — `activity_id` from URL is interpolated into filesystem paths without sanitization. Attacker can read/write/delete arbitrary files via `../../` | ✅ Fixed — `_check_id()` validates against `[a-zA-Z0-9\-]+` |
|
||||||
|
| 2 | Security | `edit/server.py:588` | **Path traversal in `delete_image`** — `filename` param is not `.name`-stripped (unlike `upload_image`), enabling deletion of arbitrary files | ✅ Fixed — `Path(filename).name` strip added |
|
||||||
|
| 3 | Security | `edit/server.py:542` | **Path traversal in `upload_activity`** — uploaded `file.filename` used directly for staged path | ✅ Fixed — `Path(file.filename).name` strip added |
|
||||||
|
| 4 | XSS | `ActivityDetail.svelte:67,189` | `marked()` output rendered with `{@html}` without sanitization. Sidecar markdown can inject `<script>` tags | ✅ Fixed — DOMPurify.sanitize() wraps marked() output |
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 5 | Data | `metrics.py:116-148` | **MMP sliding window on non-contiguous data** — gaps in recording are collapsed, inflating power values | ✅ Fixed — dense 1Hz array with gap zero-fill (standard GoldenCheetah/WKO approach) |
|
||||||
|
| 6 | Data | `metrics.py:170-178` | **Best-effort times on non-contiguous data** — pauses removed from speed array produce artificially fast times | ✅ Fixed — same zero-fill approach, gaps count as 0 km/h |
|
||||||
|
| 7 | Data | `writer.py:85-86` | **Activity ID collision** — two activities with same start second + title silently overwrite each other | ✅ Fixed — collision detected by source_hash comparison; disambiguated with 6-char hash suffix |
|
||||||
|
| 8 | Frontend | `ActivityMap.svelte:88-94` | **Misaligned lat/lon arrays** — independently filtered for nulls, so `lats[0]` may not correspond to `lons[0]`. Start/end markers placed at wrong coordinates | ✅ Fixed — lat/lon filtered as pairs |
|
||||||
|
| 9 | Frontend | `EditDrawer.svelte:121` | **Backdrop dismiss fires `saved` event** with unsaved data, overwriting displayed title/description | ✅ Fixed — backdrop/close now dispatch `close` event |
|
||||||
|
| 10 | Schema | `writer.py` vs `bas-v1.schema.json` | **Writer output fails own schema** — `mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, `custom` written but not declared; schema has `additionalProperties: false` | ✅ Fixed — all missing fields declared in schema |
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 11 | Data | `metrics.py:89-90` | **Falsy check drops `0.0` speed** — `if avg_speed_kmh` treats `0.0` as falsy; should be `is not None` | ✅ Fixed — `is not None` check |
|
||||||
|
| 12 | Data | `parsers/fit.py:89` | Same falsy `0.0` bug for FIT lap `speed_raw` | ✅ Fixed — `is not None` check |
|
||||||
|
| 13 | Data | `metrics.py:222-239` | `_best_climb` filters out `None` elevations, joining non-contiguous segments | ✅ Fixed — `None` samples reset Kadane's window instead of being skipped |
|
||||||
|
| 14 | Parser | `parsers/tcx.py:89-97` | **TCX timestamps with numeric timezone offsets** (`+02:00`) crash — only `Z` suffix handled | ✅ Fixed — numeric offsets parsed with `%z`, converted to UTC |
|
||||||
|
| 15 | Security | `edit/server.py:22` | **CORS `allow_origins=["*"]`** — any website can make requests to the edit server | ✅ Fixed — restricted to `localhost` origins only |
|
||||||
|
| 16 | Security | `edit/server.py:406` | **YAML injection** — `hide_stats` values written into YAML frontmatter without quoting | ✅ Fixed — values filtered against STAT_PANELS allowlist |
|
||||||
|
| 17 | Data | `edit/server.py:497-499` | `save_athlete` mutates immutable `athlete.json` in-place, violating extract immutability | ✅ Fixed — `merge_all()` now applies `edits/athlete.yaml` overlay; server only writes the sidecar |
|
||||||
|
| 18 | Schema | `sport.py` vs schema | **`skiing` sport** produced by code but missing from JSON schema and SCHEMA.md | ✅ Fixed — `skiing` added to schema sport enum |
|
||||||
|
| 19 | Schema | `sport.py` vs schema | **Sub-sport values** `nordic`, `alpine`, `open_water`, `pool` not in schema enum | ✅ Fixed — added to schema sub_sport enum |
|
||||||
|
| 20 | Schema | `SCHEMA.md:140` | **Activity ID format wrong** — docs say `+0200` offset, code uses `Z` UTC | ✅ Fixed — all examples updated to `Z` suffix |
|
||||||
|
| 21 | Frontend | `EditDrawer.svelte:106` | **Regex injection** — filename with metacharacters (e.g. `photo(1).jpg`) breaks `deleteImage` regex | ✅ Fixed — filename escaped before RegExp construction |
|
||||||
|
| 22 | Frontend | `EditDrawer.svelte:85-99` | No try/catch in `uploadImages` — network error leaves UI stuck in uploading state | ✅ Fixed — try/catch with `finally { uploading = false }` |
|
||||||
|
| 23 | Frontend | `RecordsView.svelte:99`, `Base.astro:220` | Hardcoded `/activity/` URLs ignore `BASE_URL`, breaking subdirectory deployments | ✅ Fixed — `base` prop threaded through `AthleteView` → `RecordsView`; `Base.astro` uses `baseUrl` from `import.meta.env.BASE_URL` |
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 24 | Data | `cli.py:341-350` | `_patch_duplicate_of` silently swallows all exceptions | ✅ Fixed — logs warning via `logging.warning` instead of bare `pass` |
|
||||||
|
| 25 | Data | `timeseries.py:26-35` | Non-monotonic `t` array if input points aren't sorted | ✅ Fixed — `t <= last_t` guard rejects both duplicate and backwards timestamps |
|
||||||
|
| 26 | Data | `simplify.py:49-50` | `preview_coords` returns `max_points + 1` elements (off-by-one) | ✅ Fixed — subsample to `max_points - 1` then append last point |
|
||||||
|
| 27 | Config | `render/cli.py:42`, `edit/cli.py:66` | `yaml.safe_load` returns `None` for empty config → `AttributeError` | ✅ Fixed — `or {}` fallback on both load sites |
|
||||||
|
| 28 | Frontend | `format.ts:18` | `formatDuration` doesn't floor seconds — fractional values display messily | ✅ Fixed — `Math.floor(s)` before any arithmetic |
|
||||||
|
| 29 | Frontend | `StatsView.svelte:31` | No error handling on `index.json` fetch | ✅ Fixed — try/catch with error display |
|
||||||
|
| 30 | Frontend | `ActivityMap.svelte` | No `map.resize()` on container size change | ✅ Fixed — `ResizeObserver` calls `map.resize()` |
|
||||||
|
| 31 | Test | `test_writer.py:21-30` | Weak assertions — only check substrings, would pass with malformed IDs | ✅ Fixed — exact equality assertions on full ID strings |
|
||||||
|
| 32 | Test | `test_sport.py` | Zero test coverage for `normalise_sub_sport` | ✅ Fixed — 3 new tests covering Strava CamelCase, ski variants, and unknown→None |
|
||||||
|
| 33 | Test | `test_merge.py:70` | Test uses `sport: "gravel"` which isn't a valid BAS sport | ✅ Fixed — changed to `"running"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Second-pass audit — 2026-04-01
|
||||||
|
|
||||||
|
### CRITICAL / HIGH (new)
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 34 | Data | `writer.py:88-93` | **Disambiguated ID not written into JSON body** — collision adds hash suffix to filename but `detail["id"]` still holds original ID | ✅ Fixed — `detail["id"] = activity_id` after disambiguation |
|
||||||
|
| 35 | Data | `cli.py:79-85` | **`write_activity` return value ignored** — caller uses pre-collision ID for `build_summary`, so index links are broken for disambiguated activities | ✅ Fixed — `activity_id = write_activity(...)` captures the canonical ID |
|
||||||
|
| 36 | Data | `writer.py:88-92` | **TOCTOU race in collision guard** — two workers processing same-timestamp activities can both see no existing file and overwrite each other | ✅ Fixed — hybrid pending-file approach: workers write to unique `.pending.json` files, main process arbitrates by quality score and does atomic rename via `finalize_pending()` |
|
||||||
|
| 37 | Schema | `bas-v1.schema.json:90-128` | **`activity_summary` missing `custom` property** — `merge.py` always adds `custom` but schema has `additionalProperties: false` | ✅ Fixed — `custom` added to `activity_summary` |
|
||||||
|
| 38 | Frontend | `EditDrawer.svelte:101` | **Undeclared `error` variable** in uploadImages catch — runtime `ReferenceError` crash on upload failure | ✅ Fixed — uses `saveStatus` with `saveOk = false` |
|
||||||
|
|
||||||
|
### MEDIUM (new)
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 39 | Security | `edit/server.py:409` | **Sport value not validated/quoted** before YAML write — YAML injection possible | ✅ Fixed — validated against `SPORTS` allowlist |
|
||||||
|
| 40 | Security | `edit/server.py:446` | **No image content-type validation** — arbitrary files accepted and served as images; HTML upload executes JS in site origin | ✅ Fixed — rejects non-`image/*` content types |
|
||||||
|
| 41 | Security | `edit/server.py:270-274` | **XSS via unescaped filename in `innerHTML`** — `renderImageList` interpolates filenames into HTML without escaping | ✅ Fixed — `escapeHtml()` applied to filenames |
|
||||||
|
| 42 | Frontend | `MmpChart.svelte:154` | **ResizeObserver captures stale closure** — after toggling range selections, resize renders old data | ✅ Fixed — reactive variables keep closure current |
|
||||||
|
| 43 | Schema | `SCHEMA.md:113` | **Sport enum missing "skiing"** — JSON schema has it, SCHEMA.md doesn't | ✅ Fixed — `skiing` added, sub_sport examples updated |
|
||||||
|
| 44 | Schema | `SCHEMA.md:109-129` | **Summary fields table incomplete** — missing `sub_sport`, `mmp`, `best_efforts`, `best_climb_m`, `preview_coords` | ✅ Fixed — all fields added to summary table |
|
||||||
|
| 45 | Config | `config.py:61` | **Empty config file still crashes** — `yaml.safe_load` returns `None`, no `or {}` fallback (round 1 fix applied to render/edit CLIs but not here) | ✅ Fixed — `or {}` fallback added |
|
||||||
|
| 46 | Frontend | `Base.astro:101-106` | **Nav links ignore `BASE_URL`** — hardcoded `/`, `/stats/`, `/athlete/` break subpath deployments | ✅ Fixed — nav uses `baseUrl` variable |
|
||||||
|
| 47 | Data | `render/merge.py:161` | **`athlete.yaml` merge has no field allowlist** — server restricts editable fields, but merge applies all keys | ✅ Fixed — merge uses `_ATHLETE_EDITABLE` allowlist matching server |
|
||||||
|
| 48 | Frontend | `types.ts:102` | **`ActivityDetail extends ActivitySummary`** inherits `detail_url`, `track_url`, `preview_coords` which detail objects never have | ✅ Fixed — `Omit<>` excludes summary-only fields |
|
||||||
|
|
||||||
|
### LOW (new)
|
||||||
|
|
||||||
|
| # | Area | File | Issue | Status |
|
||||||
|
|---|------|------|-------|--------|
|
||||||
|
| 49 | Parser | `parsers/tcx.py:101` | **Timezone offsets without colon** (`+0200`) not handled — only `+02:00` form matched | ✅ Fixed — regex accepts optional colon (`[+-]\d{2}:?\d{2}`) |
|
||||||
|
| 50 | Parser | `parsers/gpx.py:59-78` | **Power data in GPX extensions not parsed** — MMP always None for GPX files with power meters | ✅ Fixed — parses `pwr`, `power`, `watts` extension tags |
|
||||||
|
| 51 | Data | `simplify.py:63-71` | **`speeds` array not filtered same as `coordinates`** — could misalign if `simplify_track` changes | ✅ Fixed — speeds array uses same lat/lon null filter |
|
||||||
|
| 52 | Frontend | `ActivityCharts.svelte` | **No `onDestroy` cleanup** — chart SVG not removed on unmount, minor memory leak | ✅ Fixed — `onDestroy` removes chart SVG |
|
||||||
|
| 53 | Frontend | `ActivityCharts.svelte:83-86` | **`resetTrim` guard always true** — trim doesn't reset on tab change if min/max happen to match | ✅ Fixed — tracks `lastResetTab` to force reset on tab switch |
|
||||||
|
| 54 | Frontend | `AthleteView.svelte:30` | **No validation of URL `tab` parameter** — invalid value shows blank content | ✅ Fixed — validates against `TABS` array, falls back to `'power'` |
|
||||||
|
| 55 | Test | `test_writer.py` | **No tests for `write_activity`, `build_summary`, `write_index`, `write_athlete_json`** | ✅ Partial — added `test_build_summary_required_fields` and `test_id_utc_conversion` |
|
||||||
|
| 56 | Test | `test_sport.py` | **No test for `normalise_sport("skiing")` or swimming variants** | ✅ Fixed — added `test_skiing_variants` and `test_swimming_variants` |
|
||||||
|
| 57 | Test | `test_merge.py:58,122` | **Non-canonical IDs in test fixtures** — use colons and underscores, don't match real ID format | ✅ Fixed — IDs use canonical `2024-01-01T080000Z-morning-ride` format |
|
||||||
|
| 58 | Data | `edit/server.py:549-551` | **No upload size limit** — file written to disk before validation | ✅ Fixed — 50 MB limit enforced before writing to disk |
|
||||||
|
| 59 | Data | `edit/server.py:586` | **Exception message leaks internal paths** — `str(exc)` returned to client in 422 response | ✅ Fixed — returns `type(exc).__name__` only, no internal details |
|
||||||
Binary file not shown.
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/private/tmp/bincio_dev_test
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { loadIndex } from '../lib/dataloader';
|
import { loadIndex } from '../lib/dataloader';
|
||||||
import ActivityDetail from './ActivityDetail.svelte';
|
import ActivityDetail from './ActivityDetail.svelte';
|
||||||
|
import { isUnlisted } from '../lib/format';
|
||||||
import type { ActivitySummary, BASIndex } from '../lib/types';
|
import type { ActivitySummary, BASIndex } from '../lib/types';
|
||||||
|
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
source: d.source ?? null,
|
source: d.source ?? null,
|
||||||
privacy: d.privacy ?? 'public',
|
privacy: d.privacy ?? 'public',
|
||||||
detail_url: `${shard.handle}/_merged/activities/${id}.json`,
|
detail_url: `${shard.handle}/_merged/activities/${id}.json`,
|
||||||
track_url: d.bbox && d.privacy !== 'private' && d.privacy !== 'no_gps'
|
track_url: d.bbox && d.privacy !== 'no_gps'
|
||||||
? `${shard.handle}/_merged/activities/${id}.geojson`
|
? `${shard.handle}/_merged/activities/${id}.geojson`
|
||||||
: null,
|
: null,
|
||||||
preview_coords: null,
|
preview_coords: null,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
import { loadIndex } from '../lib/dataloader';
|
import { loadIndex } from '../lib/dataloader';
|
||||||
|
|
||||||
/** Render preview_coords as an SVG polyline path string. */
|
/** Render preview_coords as an SVG polyline path string. */
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
let me: string = '';
|
let me: string = '';
|
||||||
|
|
||||||
// Show private activities only to their owner.
|
// Show private activities only to their owner.
|
||||||
// On a profile page (filterHandle set): show private if me === filterHandle.
|
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
|
||||||
// On the global feed: show private only for the logged-in user's own activities.
|
// On the global feed: show unlisted only for the logged-in user's own activities.
|
||||||
$: isOwner = filterHandle !== '' && me === filterHandle;
|
$: isOwner = filterHandle !== '' && me === filterHandle;
|
||||||
$: withPrivacy = all.filter(a => {
|
$: withPrivacy = all.filter(a => {
|
||||||
if (a.privacy === 'private') {
|
if (isUnlisted(a.privacy)) {
|
||||||
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -161,8 +161,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<!-- stretched link covers the whole card; sits below the handle link -->
|
<!-- stretched link covers the whole card; sits below the handle link -->
|
||||||
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors flex items-center gap-1.5">
|
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors flex items-center gap-1.5">
|
||||||
{#if a.privacy === 'private'}
|
{#if isUnlisted(a.privacy)}
|
||||||
<span class="text-zinc-500 shrink-0" title="Private">🔒</span>
|
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
<a
|
||||||
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import MmpChart from './MmpChart.svelte';
|
import MmpChart from './MmpChart.svelte';
|
||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
|
import { isUnlisted } from '../lib/format';
|
||||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||||
|
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
athlete = resolvedAthlete;
|
athlete = resolvedAthlete;
|
||||||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
activities = index.activities.filter(a => a.mmp && !isUnlisted(a.privacy));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { BASIndex, ActivitySummary } from '../lib/types';
|
import type { BASIndex, ActivitySummary } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, sportIcon } from '../lib/format';
|
import { formatDistance, formatDuration, isUnlisted, sportIcon } from '../lib/format';
|
||||||
|
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
const tot: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
const tot: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
||||||
|
|
||||||
for (const u of rawUsers) {
|
for (const u of rawUsers) {
|
||||||
const pub = u.activities.filter(a => a.privacy !== 'private');
|
const pub = u.activities.filter(a => !isUnlisted(a.privacy));
|
||||||
const filtered = pub.filter(a => new Date(a.started_at) >= start);
|
const filtered = pub.filter(a => new Date(a.started_at) >= start);
|
||||||
|
|
||||||
const stat: UserStat = {
|
const stat: UserStat = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
import { formatDistance, formatDuration, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
import { loadIndex } from '../lib/dataloader';
|
import { loadIndex } from '../lib/dataloader';
|
||||||
|
|
||||||
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
|
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
try {
|
try {
|
||||||
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
|
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
|
||||||
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
all = index.activities.filter(a => !isUnlisted(a.privacy) && a.distance_m);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,8 +368,8 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 mt-3 text-xs text-zinc-400 cursor-pointer select-none">
|
<label class="flex items-center gap-2 mt-3 text-xs text-zinc-400 cursor-pointer select-none">
|
||||||
<input id="zip-private" type="checkbox" class="accent-blue-500" />
|
<input id="zip-private" type="checkbox" class="accent-blue-500" />
|
||||||
Mark all imported activities as private
|
Mark all imported activities as unlisted
|
||||||
<span class="text-zinc-600">(Strava export doesn't include privacy settings)</span>
|
<span class="text-zinc-600">(not shown in feed; GPS track still accessible by URL)</span>
|
||||||
</label>
|
</label>
|
||||||
<p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" style="min-height: 1.25rem"></p>
|
<p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" style="min-height: 1.25rem"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { Sport } from './types';
|
import type { Privacy, Sport } from './types';
|
||||||
|
|
||||||
|
/** True for "unlisted" activities (and the legacy "private" alias).
|
||||||
|
* Use this everywhere instead of comparing against 'private' directly. */
|
||||||
|
export function isUnlisted(privacy: Privacy | string | null | undefined): boolean {
|
||||||
|
return privacy === 'unlisted' || privacy === 'private';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = 'metric'): string {
|
export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = 'metric'): string {
|
||||||
if (m == null) return '—';
|
if (m == null) return '—';
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
export type Sport = "cycling" | "running" | "hiking" | "walking" | "swimming" | "skiing" | "other";
|
export type Sport = "cycling" | "running" | "hiking" | "walking" | "swimming" | "skiing" | "other";
|
||||||
export type SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | "alpine" | "open_water" | "pool" | null;
|
export type SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | "alpine" | "open_water" | "pool" | null;
|
||||||
export type Privacy = "public" | "blur_start" | "no_gps" | "private";
|
/** "unlisted" = not shown in the public feed; GPS track still published (security by obscurity).
|
||||||
|
* "private" is the legacy alias for "unlisted" — accepted when reading old data. */
|
||||||
|
export type Privacy = "public" | "blur_start" | "no_gps" | "unlisted" | "private";
|
||||||
|
|
||||||
/** [duration_s, avg_watts] pairs, sorted by duration ascending. */
|
/** [duration_s, avg_watts] pairs, sorted by duration ascending. */
|
||||||
export type MmpCurve = [number, number][];
|
export type MmpCurve = [number, number][];
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function getStaticPaths() {
|
|||||||
// Build the map from the index first
|
// Build the map from the index first
|
||||||
const byId = new Map(
|
const byId = new Map(
|
||||||
activities
|
activities
|
||||||
.filter(a => a.privacy !== 'private' && a.id)
|
.filter(a => a.privacy !== 'private' && a.privacy !== 'unlisted' && a.id)
|
||||||
.map(a => [a.id, { activity: a, athlete }])
|
.map(a => [a.id, { activity: a, athlete }])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export async function getStaticPaths() {
|
|||||||
if (byId.has(id)) continue; // already covered by the index
|
if (byId.has(id)) continue; // already covered by the index
|
||||||
try {
|
try {
|
||||||
const detail = JSON.parse(readFileSync(join(actsDir, file), 'utf-8'));
|
const detail = JSON.parse(readFileSync(join(actsDir, file), 'utf-8'));
|
||||||
if (detail.privacy === 'private') continue;
|
if (detail.privacy === 'private' || detail.privacy === 'unlisted') continue;
|
||||||
// Build a minimal ActivitySummary from the detail file
|
// Build a minimal ActivitySummary from the detail file
|
||||||
const a: ActivitySummary = {
|
const a: ActivitySummary = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# BincioActivity — Ideas & Roadmap
|
||||||
|
|
||||||
|
## 1. Strava import from UI ✅
|
||||||
|
|
||||||
|
**Goal:** Let users trigger a Strava sync directly from the web UI instead of running CLI commands.
|
||||||
|
|
||||||
|
**Current state:** `bincio extract` CLI handles Strava OAuth + sync. The edit server (FastAPI) runs locally.
|
||||||
|
|
||||||
|
**Ideas:**
|
||||||
|
- Repurpose the upload button popup to offer two paths: "Upload file" (existing) or "Connect Strava"
|
||||||
|
- OAuth flow: open a popup window → Strava auth → redirect back → edit server exchanges code for token → triggers extract
|
||||||
|
- Edit server would need new endpoints: `GET /strava/auth-url`, `GET /strava/callback`, `POST /strava/sync`
|
||||||
|
- Progress feedback: SSE (Server-Sent Events) or polling endpoint to stream sync progress back to the UI
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- Where to store the Strava token? Currently it's a CLI argument / env var. For server mode, probably a local file in the data dir.
|
||||||
|
- Should sync be incremental (only new activities) or full re-extract?
|
||||||
|
- How does this interact with multi-user mode (see #2)?
|
||||||
|
|
||||||
|
**Effort:** Medium. OAuth popup + SSE progress are the tricky parts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Multi-user VPS deployment with invite system
|
||||||
|
|
||||||
|
**Goal:** Deploy bincio on a VPS so multiple users can have accounts. Invite-only registration.
|
||||||
|
|
||||||
|
**Current state:** Entirely single-user, local files, no auth layer at all.
|
||||||
|
|
||||||
|
### Clarified model
|
||||||
|
|
||||||
|
- A **bincio instance** is a deployment (personal or group)
|
||||||
|
- **Personal instance:** 1 user, fully self-contained — current state
|
||||||
|
- **Group instance:** N users, invite-only. All users see each other's public activities by default.
|
||||||
|
The `private` flag (already in the schema) means "only me".
|
||||||
|
- **Federation** is between instances via BAS data URLs — no accounts needed to follow someone
|
||||||
|
- Data isolation = run your own instance. No per-user isolation within an instance.
|
||||||
|
|
||||||
|
### Data layout (multi-user)
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/
|
||||||
|
{handle}/ ← one dir per user (extract output)
|
||||||
|
activities/
|
||||||
|
index.json
|
||||||
|
athlete.json
|
||||||
|
edits/
|
||||||
|
_merged/
|
||||||
|
instance.json ← user registry + invite codes
|
||||||
|
```
|
||||||
|
|
||||||
|
The instance's combined feed merges all users' `_merged/index.json` files at render time.
|
||||||
|
|
||||||
|
### Auth layer
|
||||||
|
|
||||||
|
- Session-based auth added to the edit server (FastAPI handles login/logout)
|
||||||
|
- Unauthenticated reads stay fully public (static files, same as today)
|
||||||
|
- Write endpoints check: logged in + owns the activity
|
||||||
|
- Astro site detects logged-in user to show/hide the Edit button per activity
|
||||||
|
|
||||||
|
### Invite system
|
||||||
|
|
||||||
|
- Invites: short random codes (8 chars), stored in `instance.json`
|
||||||
|
- Invite record: `{code, created_by, used_by, created_at, used_at}`
|
||||||
|
- On registration: validate code → create user dir → mark invite used
|
||||||
|
- Limits: admin (unlimited), regular users (3 invites, configurable)
|
||||||
|
- UI: "Your invites" page — generate codes, see who used them
|
||||||
|
|
||||||
|
### Render pipeline (multi-user)
|
||||||
|
|
||||||
|
- `bincio render` runs `merge_all()` for each user, then builds one Astro site
|
||||||
|
- Combined `index.json` = all users' public summaries merged, sorted by date
|
||||||
|
- Per-user profile pages at `/{handle}/` showing only their activities
|
||||||
|
- Activity detail pages stay at `/activity/{id}/` (IDs are globally unique already)
|
||||||
|
- Activity cards in combined feed show "by {handle}" label
|
||||||
|
|
||||||
|
**Decided:**
|
||||||
|
- SQLite for user/invite storage (safe under concurrent writes, still no external DB dependency)
|
||||||
|
- Mastodon-style follow UX: paste a BAS URL into a "Follow" input → instance fetches and caches that feed → activities appear in combined feed with "from {url}" attribution
|
||||||
|
- Feed refresh: pull-on-demand with cache TTL (no background job — fetch on first stale pageload). [open: exact TTL, stale-while-revalidate vs blocking]
|
||||||
|
|
||||||
|
**Effort:** Large. Auth + multi-user render pipeline are the main work. Needs a design pass before coding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Federation explainer for non-technical users
|
||||||
|
|
||||||
|
**Goal:** Write a clear, friendly explanation of how bincio federation works for people who don't know what JSON or APIs are.
|
||||||
|
|
||||||
|
**Three ways to share your data:**
|
||||||
|
|
||||||
|
### a) Upload raw files
|
||||||
|
> "Export your GPX/FIT files from Garmin, Wahoo, or wherever, and upload them directly. Bincio converts them automatically."
|
||||||
|
|
||||||
|
### b) Upload BAS files
|
||||||
|
> "Already running bincio locally? Export your BAS data and host it anywhere — GitHub Pages, Netlify, a USB stick. Anyone with the link can include your activities in their feed."
|
||||||
|
|
||||||
|
### c) Link to another bincio instance
|
||||||
|
> "Self-hosting bincio? Just share your URL. Friends can follow you by adding your address to their feeds — no accounts needed, no central server."
|
||||||
|
|
||||||
|
**Promo angle ideas:**
|
||||||
|
- "Your data, your rules. No algorithms, no engagement metrics."
|
||||||
|
- "Like RSS for your training log."
|
||||||
|
- "Works offline. Export once, keep forever."
|
||||||
|
- "Follow friends without either of you needing an account on the same platform."
|
||||||
|
|
||||||
|
**Format:** Could be a `FEDERATION.md`, a landing page section, or a one-pager PDF.
|
||||||
|
|
||||||
|
**Effort:** Small (writing). Medium if we want a designed landing page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mobile data capture & processing
|
||||||
|
|
||||||
|
**Goal:** Record activities on a phone, convert to BAS, possibly run the full bincio stack on-device.
|
||||||
|
|
||||||
|
**Four sub-questions:**
|
||||||
|
|
||||||
|
### 4a. Record on phone
|
||||||
|
- Standard GPS recording → GPX export is already solved by apps like OsmAnd, Organic Maps, Strava, Komoot
|
||||||
|
- Could build a minimal PWA (Progressive Web App) that records GPS and exports GPX
|
||||||
|
- Web Geolocation API + background service worker has limitations (iOS kills background JS)
|
||||||
|
- React Native / Capacitor would give better background GPS access
|
||||||
|
- **Simplest path:** just recommend OsmAnd/Organic Maps, skip building a recorder
|
||||||
|
|
||||||
|
### 4b. Convert GPX/FIT → BAS on phone
|
||||||
|
- The extract pipeline is pure Python with no native deps (haversine math, standard library)
|
||||||
|
- Could run via **Pyodide** (Python in WebAssembly) in a browser/PWA
|
||||||
|
- Or package as a mobile app with BeeWare/Kivy (Python → iOS/Android)
|
||||||
|
- Performance: A typical 1-hour FIT file is ~1MB, should parse in <1s even in Pyodide
|
||||||
|
- **Interesting path:** PWA with Pyodide — no install needed, works in Safari/Chrome
|
||||||
|
|
||||||
|
### 4c. Serve the full bincio app on a phone
|
||||||
|
- The site is a static Astro build — it's already a collection of HTML/JS/CSS files
|
||||||
|
- A phone could serve these files locally via a small HTTP server
|
||||||
|
- iOS: no background servers, but a Capacitor wrapper could embed a local server
|
||||||
|
- Android: more permissive, could run a Python/Node micro-server as a service
|
||||||
|
- **Alternative:** just open the pre-built site via file:// protocol (may work for static assets)
|
||||||
|
|
||||||
|
### 4d. Fully offline mobile app
|
||||||
|
- Capacitor + the existing Svelte components + Pyodide for extract = plausible stack
|
||||||
|
- Would need to bundle MapLibre tile data locally (big) or use an offline tile server
|
||||||
|
- This is a significant project but technically feasible with existing code
|
||||||
|
|
||||||
|
**Decided path:**
|
||||||
|
1. Recommend OsmAnd / Organic Maps for GPS recording — also built a `/record/` page in the app for in-app GPS recording
|
||||||
|
2. Capacitor wraps the existing Astro/Svelte site as a native iOS/Android app
|
||||||
|
3. `/convert/` page — Pyodide runs the full extract pipeline in-browser (GPX/FIT/TCX → BAS JSON)
|
||||||
|
4. Fully offline on-device: needs more work (see below)
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- Capacitor setup in `site/` (`capacitor.config.ts`, scripts, packages)
|
||||||
|
- `/convert/` page with Pyodide (loads bincio wheel, converts files, download or save to cloud)
|
||||||
|
- `/record/` page with live GPS recording, exports GPX → hands off to `/convert/`
|
||||||
|
- `POST /api/import-bas` edit server endpoint (accepts pre-converted BAS JSON)
|
||||||
|
- `site/public/bincio.whl` — bincio Python wheel for Pyodide
|
||||||
|
|
||||||
|
**Fully offline — ordered plan:**
|
||||||
|
|
||||||
|
| # | Step | Effort | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | SW + IndexedDB write (`sw.js`, `localstore.ts`, "Save to device") | Medium | ✅ Done |
|
||||||
|
| 2 | `dataloader.ts` — runtime-merge server + IDB; update all Svelte components | Medium | 🔄 In progress |
|
||||||
|
| 3 | Test convert→save→feed loop in browser (`npm run dev`) | Low | Not started |
|
||||||
|
| 4 | Sidecar merge in JS (port `merge_all`, no Pyodide) | Medium | Not started |
|
||||||
|
| 5 | Pyodide SW cache (cache CDN assets on first visit) | Medium | Not started |
|
||||||
|
| 6 | `npx cap add android` + test in Capacitor WebView | Low | Not started |
|
||||||
|
| 7 | Test on iOS device/simulator | Low | Not started |
|
||||||
|
| 8 | Native micro-server (only if step 7 reveals SW blocked) | Hard | Contingency |
|
||||||
|
|
||||||
|
**`capacitor-nodejs` is not on the roadmap.** If iOS blocks service workers, fallback is a native micro-server or eliminating all remaining `fetch('/data/*')` callsites via step 2.
|
||||||
|
|
||||||
|
See `ARCHITECTURE.md` — "Fully offline — missing pieces" for design details and open questions (storage limits, uninstall, sync conflicts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Gear from Strava export
|
||||||
|
|
||||||
|
Import `bikes.csv` and `shoes.csv` from the Strava ZIP export to pre-populate a gear selector
|
||||||
|
in the edit drawer. The CSV has: Bike Name, Bike Brand, Bike Model, Bike Default Sport Types.
|
||||||
|
Could store as a `gear.json` in the user's data dir and surface it in the EditDrawer sport/gear fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User feedback backlog (April 2026)
|
||||||
|
|
||||||
|
Items reported via the in-app feedback form.
|
||||||
|
|
||||||
|
| # | Who | Area | Description | Notes |
|
||||||
|
|---|-----|------|-------------|-------|
|
||||||
|
| F1 | brut | Mobile / Nav | ~~Shorten "BincioActivity" logo to "BA" on narrow screens~~ | ✅ Done |
|
||||||
|
| F2 | brut | Mobile / Stats | Day-click tooltip in `StatsView` falls off-screen on mobile | Needs viewport-clamping on the tooltip position |
|
||||||
|
| F3 | brut | Data / Display | Cadence unit "rpm" is wrong for walking and hiking — should be "spm" or hidden | `ActivityCharts.svelte` — sport-aware label or hide tab |
|
||||||
|
| F4 | brut | Feature | No way to mark an activity as "virtual"; virtual activities should be excluded from records | `virtual: true` sidecar flag + `EditDrawer` checkbox; filter in `RecordsView` and `write_athlete_json` |
|
||||||
|
| F5 | brut | Charts / MMP | ~~Power curve Y axis should start at 0 W; X axis should extend to at least 2 hours~~ | ✅ Done |
|
||||||
|
| F6 | brut | Feature | Import gear (bikes, shoes) from Strava export; edit gear; maintenance log with cumulative km | See item 5 above for gear import. Maintenance log is new: date + km + notes per bike |
|
||||||
|
| F7 | brut | About page | Make user handles in the community tree clickable; show online user count | Handles link to `/u/{handle}/`; online count needs server-side tracking (non-trivial) |
|
||||||
|
| F8 | brut | Strava sync | ~~Syncing from Strava does not trigger a site rebuild~~ | ✅ Done — trigger was firing after `yield "done"`, client had already closed the SSE connection |
|
||||||
|
| F9 | diego_p | Data | Check elevation gain on activity `2026-04-11T051441Z` | Likely a GPS artefact or smoothing issue; needs manual inspection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritization (rough)
|
||||||
|
|
||||||
|
| # | Feature | Impact | Effort | Priority |
|
||||||
|
|---|---------|--------|--------|----------|
|
||||||
|
| 1 | Strava sync from UI | High | Medium | Soon |
|
||||||
|
| 3 | Federation explainer | Medium | Small | Soon |
|
||||||
|
| 4a/4b | Mobile record + convert | Medium | Medium | Later |
|
||||||
|
| 2 | Multi-user + invites | High | Large | Later (needs design) |
|
||||||
|
| 4c/4d | Full mobile app | Low | Large | Future |
|
||||||
Reference in New Issue
Block a user