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