fix mid level issues. updated changelog

This commit is contained in:
Davide Scaini
2026-03-31 23:00:39 +02:00
parent f8abab2c23
commit 8f91503cf7
7 changed files with 119 additions and 33 deletions
+33 -1
View File
@@ -1,6 +1,38 @@
# Changelog # 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 ### Navigation
+8 -8
View File
@@ -82,7 +82,7 @@ needed to render an activity card in a feed — no timeseries, no full track.
```json ```json
{ {
"id": "2024-06-01T073012+0200-morning-ride", "id": "2024-06-01T073012Z-morning-ride",
"title": "Morning Ride", "title": "Morning Ride",
"sport": "cycling", "sport": "cycling",
"sub_sport": "road", "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, "avg_power_w": null,
"source": "strava_export", "source": "strava_export",
"privacy": "public", "privacy": "public",
"detail_url": "activities/2024-06-01T073012+0200-morning-ride.json", "detail_url": "activities/2024-06-01T073012Z-morning-ride.json",
"track_url": "activities/2024-06-01T073012+0200-morning-ride.geojson" "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 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). 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 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 ### Source values
@@ -177,7 +177,7 @@ Full activity record. Extends the Summary with timeseries and metadata.
```json ```json
{ {
"bas_version": "1.0", "bas_version": "1.0",
"id": "2024-06-01T073012+0200-morning-ride", "id": "2024-06-01T073012Z-morning-ride",
"title": "Morning Ride", "title": "Morning Ride",
"description": "Easy morning spin before work.", "description": "Easy morning spin before work.",
"sport": "cycling", "sport": "cycling",
@@ -297,7 +297,7 @@ Simplified GPS track for map rendering. Omitted entirely when
] ]
}, },
"properties": { "properties": {
"id": "2024-06-01T073012+0200-morning-ride", "id": "2024-06-01T073012Z-morning-ride",
"speeds": [0.0, 15.2], "speeds": [0.0, 15.2],
"simplification": "rdp", "simplification": "rdp",
"rdp_epsilon": 0.0001, "rdp_epsilon": 0.0001,
+2 -2
View File
@@ -86,8 +86,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
moving_time_s=moving_time_s, moving_time_s=moving_time_s,
elevation_gain_m=round(gain, 1) if gain is not None else None, 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, 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, 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 else None, max_speed_kmh=round(max_speed_kmh, 2) if max_speed_kmh is not None else None,
avg_hr_bpm=avg_hr, avg_hr_bpm=avg_hr,
max_hr_bpm=max_hr, max_hr_bpm=max_hr,
avg_cadence_rpm=avg_cad, avg_cadence_rpm=avg_cad,
+1 -1
View File
@@ -86,7 +86,7 @@ class FitParser:
duration_s=int(elapsed) if elapsed else None, duration_s=int(elapsed) if elapsed else None,
distance_m=_get(frame, "total_distance"), distance_m=_get(frame, "total_distance"),
elevation_gain_m=_get(frame, "total_ascent"), 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_hr_bpm=_get(frame, "avg_heart_rate"),
avg_power_w=_get(frame, "avg_power"), avg_power_w=_get(frame, "avg_power"),
) )
+20 -7
View File
@@ -87,11 +87,24 @@ class TcxParser:
def _parse_ts(s: str) -> datetime: def _parse_ts(s: str) -> datetime:
# ISO 8601 with or without fractional seconds # ISO 8601 with or without fractional seconds, with Z or numeric offset (+02:00)
s = s.rstrip("Z") import re as _re
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): # Strip trailing Z → assume UTC
try: if s.endswith("Z"):
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) s = s[:-1]
except ValueError: for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
continue 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}") raise ValueError(f"Cannot parse timestamp: {s!r}")
+39 -3
View File
@@ -6,11 +6,11 @@
"$defs": { "$defs": {
"sport": { "sport": {
"type": "string", "type": "string",
"enum": ["cycling", "running", "hiking", "walking", "swimming", "other"] "enum": ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
}, },
"sub_sport": { "sub_sport": {
"type": ["string", "null"], "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": { "privacy": {
"type": "string", "type": "string",
@@ -36,6 +36,26 @@
"maxItems": 4, "maxItems": 4,
"description": "[min_lon, min_lat, max_lon, max_lat]" "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": { "lap": {
"type": "object", "type": "object",
"required": ["index", "started_at", "duration_s"], "required": ["index", "started_at", "duration_s"],
@@ -86,10 +106,23 @@
"max_hr_bpm": { "type": ["integer", "null"] }, "max_hr_bpm": { "type": ["integer", "null"] },
"avg_cadence_rpm": { "type": ["integer", "null"] }, "avg_cadence_rpm": { "type": ["integer", "null"] },
"avg_power_w": { "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" }, "source": { "$ref": "#/$defs/source" },
"privacy": { "$ref": "#/$defs/privacy" }, "privacy": { "$ref": "#/$defs/privacy" },
"detail_url": { "type": ["string", "null"] }, "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 "additionalProperties": false
} }
@@ -164,6 +197,9 @@
"bbox": { "$ref": "#/$defs/bbox" }, "bbox": { "$ref": "#/$defs/bbox" },
"start_latlng": { "$ref": "#/$defs/latlng" }, "start_latlng": { "$ref": "#/$defs/latlng" },
"end_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" } }, "laps": { "type": "array", "items": { "$ref": "#/$defs/lap" } },
"timeseries": { "$ref": "#/$defs/timeseries" }, "timeseries": { "$ref": "#/$defs/timeseries" },
"source": { "$ref": "#/$defs/source" }, "source": { "$ref": "#/$defs/source" },
+16 -11
View File
@@ -84,19 +84,24 @@
async function uploadImages(files: FileList) { async function uploadImages(files: FileList) {
uploading = true; uploading = true;
for (const file of Array.from(files)) { try {
const fd = new FormData(); for (const file of Array.from(files)) {
fd.append('file', file); const fd = new FormData();
const res = await fetch(`${api}/images`, { method: 'POST', body: fd }); fd.append('file', file);
if (res.ok) { const res = await fetch(`${api}/images`, { method: 'POST', body: fd });
const d = await res.json(); if (res.ok) {
if (!images.includes(d.filename)) images = [...images, d.filename]; const d = await res.json();
// Insert markdown reference at cursor or end if (!images.includes(d.filename)) images = [...images, d.filename];
const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`; // Insert markdown reference at cursor or end
description = description.trimEnd() + ref; 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) { async function deleteImage(filename: string) {