fix mid level issues. updated changelog
This commit is contained in:
+33
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user