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
## [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
+8 -8
View File
@@ -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,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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"),
)
+20 -7
View File
@@ -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}")
+39 -3
View File
@@ -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" },
+16 -11
View File
@@ -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) {