diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a3fa2..77e56ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ # Changelog -## [Unreleased] — 2026-03-30 +## [Unreleased] — 2026-03-31 + +### Security fixes + +- **Path traversal prevention** (`edit/server.py`) — all routes now validate `activity_id` against `[a-zA-Z0-9\-]+` regex via `_check_id()`; invalid IDs return 400 +- **Path traversal in `delete_image`** — `filename` parameter now stripped to basename via `Path(filename).name` before use in filesystem paths +- **Path traversal in `upload_activity`** — uploaded `file.filename` stripped to basename via `Path(file.filename).name` +- **XSS in activity description** (`ActivityDetail.svelte`) — `marked()` output now wrapped in `DOMPurify.sanitize()` before `{@html}` rendering +- **CORS restricted** (`edit/server.py`) — `allow_origins=["*"]` replaced with `allow_origin_regex` matching `localhost` origins only +- **YAML injection in `hide_stats`** — values filtered against a `STAT_PANELS` allowlist before writing to YAML frontmatter +- **Regex injection in `deleteImage`** (`EditDrawer.svelte`) — filename special characters escaped before `RegExp` construction + +### Bug fixes — data + +- **MMP sliding window on non-contiguous data** (`metrics.py`) — power series now built as a dense 1 Hz array with gaps zero-filled (standard GoldenCheetah/WKO approach); recording pauses no longer inflate MMP values +- **Best-effort times on non-contiguous data** (`metrics.py`) — speed series uses same zero-fill; pauses count as 0 km/h so windows cannot span them silently +- **Activity ID collision** (`writer.py`) — when two activities share the same start-time + title, the second is disambiguated with a 6-character source hash suffix; re-extracting the same file is idempotent +- **Misaligned lat/lon arrays** (`ActivityMap.svelte`) — lat and lon were filtered for nulls independently; now filtered as pairs so indices always stay aligned +- **Falsy `0.0` speed check** (`metrics.py:89-90`, `parsers/fit.py:89`) — `if avg_speed_kmh` / `if speed_raw` replaced with `is not None`; 0.0 is no longer silently dropped +- **TCX timestamps with numeric timezone offsets** (`parsers/tcx.py`) — `+02:00`-style offsets now parsed correctly and converted to UTC; previously crashed with `ValueError` + +### Bug fixes — frontend + +- **Backdrop dismiss fires `saved` event** (`EditDrawer.svelte`) — backdrop click and ×-button now dispatch `close` instead of `saved`, preventing unsaved data from overwriting the displayed title/description +- **No error handling in `uploadImages`** (`EditDrawer.svelte`) — wrapped upload loop in try/catch/finally so a network error clears the `uploading` spinner and surfaces an error message instead of locking the UI +- **Stats page pagination** (`StatsView.svelte`) — heatmap now shows 4 years per page with ← Newer / Older → controls; `?page=` persisted in URL + +### Schema + +- **Writer output now matches schema** (`bas-v1.schema.json`) — `mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, and `custom` are all declared in the schema; previously `additionalProperties: false` caused validation failures +- **`skiing` added to sport enum** — was produced by the extractor but missing from the schema definition +- **Sub-sport enum extended** — `nordic`, `alpine`, `open_water`, `pool` added to schema +- **Activity ID format corrected in SCHEMA.md** — examples updated from `+0200` offset to `Z` UTC suffix (matching actual code behaviour since v0.1.0) ### Navigation diff --git a/SCHEMA.md b/SCHEMA.md index cb0f3b8..d9bcc12 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -82,7 +82,7 @@ needed to render an activity card in a feed — no timeseries, no full track. ```json { - "id": "2024-06-01T073012+0200-morning-ride", + "id": "2024-06-01T073012Z-morning-ride", "title": "Morning Ride", "sport": "cycling", "sub_sport": "road", @@ -99,8 +99,8 @@ needed to render an activity card in a feed — no timeseries, no full track. "avg_power_w": null, "source": "strava_export", "privacy": "public", - "detail_url": "activities/2024-06-01T073012+0200-morning-ride.json", - "track_url": "activities/2024-06-01T073012+0200-morning-ride.geojson" + "detail_url": "activities/2024-06-01T073012Z-morning-ride.json", + "track_url": "activities/2024-06-01T073012Z-morning-ride.geojson" } ``` @@ -137,13 +137,13 @@ The canonical ID format is: ``` Where `started_at_compact` is the start timestamp with special characters -removed: `2024-06-01T073012+0200`, and `slug` is an optional URL-safe +removed: `2024-06-01T073012Z`, and `slug` is an optional URL-safe lowercase title (spaces → hyphens, non-ASCII stripped). -Example: `2024-06-01T073012+0200-morning-ride` +Example: `2024-06-01T073012Z-morning-ride` IDs must be unique within a data store. When a title is unavailable, the -timestamp alone is sufficient: `2024-06-01T073012+0200`. +timestamp alone is sufficient: `2024-06-01T073012Z`. ### Source values @@ -177,7 +177,7 @@ Full activity record. Extends the Summary with timeseries and metadata. ```json { "bas_version": "1.0", - "id": "2024-06-01T073012+0200-morning-ride", + "id": "2024-06-01T073012Z-morning-ride", "title": "Morning Ride", "description": "Easy morning spin before work.", "sport": "cycling", @@ -297,7 +297,7 @@ Simplified GPS track for map rendering. Omitted entirely when ] }, "properties": { - "id": "2024-06-01T073012+0200-morning-ride", + "id": "2024-06-01T073012Z-morning-ride", "speeds": [0.0, 15.2], "simplification": "rdp", "rdp_epsilon": 0.0001, diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 5c51415..426c623 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -86,8 +86,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: moving_time_s=moving_time_s, elevation_gain_m=round(gain, 1) if gain is not None else None, elevation_loss_m=round(abs(loss), 1) if loss is not None else None, - avg_speed_kmh=round(avg_speed_kmh, 2) if avg_speed_kmh else None, - max_speed_kmh=round(max_speed_kmh, 2) if max_speed_kmh else None, + avg_speed_kmh=round(avg_speed_kmh, 2) if avg_speed_kmh is not None else None, + max_speed_kmh=round(max_speed_kmh, 2) if max_speed_kmh is not None else None, avg_hr_bpm=avg_hr, max_hr_bpm=max_hr, avg_cadence_rpm=avg_cad, diff --git a/bincio/extract/parsers/fit.py b/bincio/extract/parsers/fit.py index 6fec5db..6fa596a 100644 --- a/bincio/extract/parsers/fit.py +++ b/bincio/extract/parsers/fit.py @@ -86,7 +86,7 @@ class FitParser: duration_s=int(elapsed) if elapsed else None, distance_m=_get(frame, "total_distance"), elevation_gain_m=_get(frame, "total_ascent"), - avg_speed_kmh=speed_raw * 3.6 if speed_raw else None, + avg_speed_kmh=speed_raw * 3.6 if speed_raw is not None else None, avg_hr_bpm=_get(frame, "avg_heart_rate"), avg_power_w=_get(frame, "avg_power"), ) diff --git a/bincio/extract/parsers/tcx.py b/bincio/extract/parsers/tcx.py index 1d1387f..ddd60e4 100644 --- a/bincio/extract/parsers/tcx.py +++ b/bincio/extract/parsers/tcx.py @@ -87,11 +87,24 @@ class TcxParser: def _parse_ts(s: str) -> datetime: - # ISO 8601 with or without fractional seconds - s = s.rstrip("Z") - for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): - try: - return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) - except ValueError: - continue + # ISO 8601 with or without fractional seconds, with Z or numeric offset (+02:00) + import re as _re + # Strip trailing Z → assume UTC + if s.endswith("Z"): + s = s[:-1] + for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + # Numeric offset like +02:00 or -05:30 — parse with %z then convert to UTC + m = _re.match(r"^(.+)([+-]\d{2}:\d{2})$", s) + if m: + body, off = m.group(1), m.group(2).replace(":", "") + for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + dt = datetime.strptime(body + off, fmt + "%z") + return dt.astimezone(timezone.utc).replace(tzinfo=timezone.utc) + except ValueError: + continue raise ValueError(f"Cannot parse timestamp: {s!r}") diff --git a/schema/bas-v1.schema.json b/schema/bas-v1.schema.json index add0b8c..43c38d8 100644 --- a/schema/bas-v1.schema.json +++ b/schema/bas-v1.schema.json @@ -6,11 +6,11 @@ "$defs": { "sport": { "type": "string", - "enum": ["cycling", "running", "hiking", "walking", "swimming", "other"] + "enum": ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"] }, "sub_sport": { "type": ["string", "null"], - "enum": ["road", "mountain", "gravel", "indoor", "trail", "track", null] + "enum": ["road", "mountain", "gravel", "indoor", "trail", "track", "nordic", "alpine", "open_water", "pool", null] }, "privacy": { "type": "string", @@ -36,6 +36,26 @@ "maxItems": 4, "description": "[min_lon, min_lat, max_lon, max_lat]" }, + "mmp": { + "type": ["array", "null"], + "description": "Mean Maximal Power curve — [[duration_s, avg_watts], ...]", + "items": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 2, + "maxItems": 2 + } + }, + "best_efforts": { + "type": ["array", "null"], + "description": "Best efforts by distance — [[distance_km, time_s], ...]", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + }, "lap": { "type": "object", "required": ["index", "started_at", "duration_s"], @@ -86,10 +106,23 @@ "max_hr_bpm": { "type": ["integer", "null"] }, "avg_cadence_rpm": { "type": ["integer", "null"] }, "avg_power_w": { "type": ["integer", "null"] }, + "mmp": { "$ref": "#/$defs/mmp" }, + "best_efforts": { "$ref": "#/$defs/best_efforts" }, + "best_climb_m": { "type": ["number", "null"] }, "source": { "$ref": "#/$defs/source" }, "privacy": { "$ref": "#/$defs/privacy" }, "detail_url": { "type": ["string", "null"] }, - "track_url": { "type": ["string", "null"] } + "track_url": { "type": ["string", "null"] }, + "preview_coords": { + "type": ["array", "null"], + "description": "Simplified track preview — [[lon, lat], ...] for card thumbnails", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + } }, "additionalProperties": false } @@ -164,6 +197,9 @@ "bbox": { "$ref": "#/$defs/bbox" }, "start_latlng": { "$ref": "#/$defs/latlng" }, "end_latlng": { "$ref": "#/$defs/latlng" }, + "mmp": { "$ref": "#/$defs/mmp" }, + "best_efforts": { "$ref": "#/$defs/best_efforts" }, + "best_climb_m": { "type": ["number", "null"] }, "laps": { "type": "array", "items": { "$ref": "#/$defs/lap" } }, "timeseries": { "$ref": "#/$defs/timeseries" }, "source": { "$ref": "#/$defs/source" }, diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte index 44884a5..56e1961 100644 --- a/site/src/components/EditDrawer.svelte +++ b/site/src/components/EditDrawer.svelte @@ -84,19 +84,24 @@ async function uploadImages(files: FileList) { uploading = true; - for (const file of Array.from(files)) { - const fd = new FormData(); - fd.append('file', file); - const res = await fetch(`${api}/images`, { method: 'POST', body: fd }); - 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; + try { + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch(`${api}/images`, { method: 'POST', body: fd }); + 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) { + error = `Upload failed: ${e.message}`; + } finally { + uploading = false; } - uploading = false; } async function deleteImage(filename: string) {