fixing issues
This commit is contained in:
+25
-8
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -17,14 +18,23 @@ site_url: str = "http://localhost:4321"
|
|||||||
|
|
||||||
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
|
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
# Allow the Astro dev server (and any local origin) to call the write API
|
# Allow localhost origins only — this server is never meant to be public
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origin_regex=r"https?://localhost(:\d+)?",
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_id(activity_id: str) -> str:
|
||||||
|
"""Reject activity IDs that contain path traversal sequences."""
|
||||||
|
if not _VALID_ACTIVITY_ID.match(activity_id):
|
||||||
|
raise HTTPException(400, "Invalid activity ID")
|
||||||
|
return activity_id
|
||||||
|
|
||||||
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
|
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
|
||||||
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
|
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
|
||||||
|
|
||||||
@@ -348,6 +358,7 @@ async def edit_page(activity_id: str) -> str:
|
|||||||
@app.get("/api/activity/{activity_id}")
|
@app.get("/api/activity/{activity_id}")
|
||||||
async def get_activity(activity_id: str) -> JSONResponse:
|
async def get_activity(activity_id: str) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
_check_id(activity_id)
|
||||||
json_path = dd / "activities" / f"{activity_id}.json"
|
json_path = dd / "activities" / f"{activity_id}.json"
|
||||||
if not json_path.exists():
|
if not json_path.exists():
|
||||||
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
@@ -383,6 +394,7 @@ async def get_activity(activity_id: str) -> JSONResponse:
|
|||||||
@app.post("/api/activity/{activity_id}")
|
@app.post("/api/activity/{activity_id}")
|
||||||
async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONResponse:
|
async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
_check_id(activity_id)
|
||||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
|
|
||||||
@@ -401,9 +413,9 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
|
|||||||
lines.append("highlight: true")
|
lines.append("highlight: true")
|
||||||
if payload.get("private"):
|
if payload.get("private"):
|
||||||
lines.append("private: true")
|
lines.append("private: true")
|
||||||
hide = payload.get("hide_stats") or []
|
hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
|
||||||
if hide:
|
if hide:
|
||||||
lines.append(f"hide_stats: [{', '.join(str(s) for s in hide)}]")
|
lines.append(f"hide_stats: [{', '.join(hide)}]")
|
||||||
|
|
||||||
description = (payload.get("description") or "").strip()
|
description = (payload.get("description") or "").strip()
|
||||||
|
|
||||||
@@ -423,6 +435,7 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
|
|||||||
@app.post("/api/activity/{activity_id}/images")
|
@app.post("/api/activity/{activity_id}/images")
|
||||||
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
_check_id(activity_id)
|
||||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
@@ -532,7 +545,7 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
|
|||||||
"""Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge."""
|
"""Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge."""
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
|
||||||
name = file.filename or "upload.fit"
|
name = Path(file.filename or "upload.fit").name # strip any path components
|
||||||
suffix = _file_suffix(name)
|
suffix = _file_suffix(name)
|
||||||
if suffix not in _SUPPORTED_SUFFIXES:
|
if suffix not in _SUPPORTED_SUFFIXES:
|
||||||
raise HTTPException(400, f"Unsupported file type '{Path(name).suffix}'. Expected FIT, GPX, or TCX.")
|
raise HTTPException(400, f"Unsupported file type '{Path(name).suffix}'. Expected FIT, GPX, or TCX.")
|
||||||
@@ -585,7 +598,11 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
|
|||||||
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
||||||
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
target = dd / "edits" / "images" / activity_id / filename
|
_check_id(activity_id)
|
||||||
|
safe_name = Path(filename).name # strip any path traversal
|
||||||
|
if not safe_name:
|
||||||
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
target = dd / "edits" / "images" / activity_id / safe_name
|
||||||
if target.exists() and target.is_file():
|
if target.exists() and target.is_file():
|
||||||
target.unlink()
|
target.unlink()
|
||||||
# Remove empty parent dir
|
# Remove empty parent dir
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
"@astrojs/svelte": "^7.0.0",
|
"@astrojs/svelte": "^7.0.0",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@observablehq/plot": "^0.6.0",
|
"@observablehq/plot": "^0.6.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"maplibre-gl": "^5.0.0",
|
"maplibre-gl": "^5.0.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import type { ActivitySummary, ActivityDetail, AthleteZones } from '../lib/types';
|
import type { ActivitySummary, ActivityDetail, AthleteZones } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
||||||
import ActivityMap from './ActivityMap.svelte';
|
import ActivityMap from './ActivityMap.svelte';
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
const titleAttr = title ? ` title="${title}"` : '';
|
const titleAttr = title ? ` title="${title}"` : '';
|
||||||
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
|
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
|
||||||
};
|
};
|
||||||
return marked(rawDescription, { renderer }) as string;
|
return DOMPurify.sanitize(marked(rawDescription, { renderer }) as string);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: imageBase = `${base}data/activities/images/${activity.id}/`;
|
$: imageBase = `${base}data/activities/images/${activity.id}/`;
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
<svelte:window on:keydown={onKeydown} />
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
{#if editOpen && editUrl}
|
{#if editOpen && editUrl}
|
||||||
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} />
|
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} on:close={() => editOpen = false} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Lightbox -->
|
<!-- Lightbox -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export let activityId: string;
|
export let activityId: string;
|
||||||
export let editUrl: string;
|
export let editUrl: string;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ saved: { title: string; description: string } }>();
|
const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void }>();
|
||||||
|
|
||||||
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
|
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
|
||||||
const STAT_PANELS = [
|
const STAT_PANELS = [
|
||||||
@@ -102,8 +102,9 @@
|
|||||||
async function deleteImage(filename: string) {
|
async function deleteImage(filename: string) {
|
||||||
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
images = images.filter(f => f !== filename);
|
images = images.filter(f => f !== filename);
|
||||||
// Remove the markdown reference too
|
// Remove the markdown reference — escape filename before using in regex
|
||||||
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${filename}\\)`, 'g'), '').trim();
|
const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${escaped}\\)`, 'g'), '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStat(key: string) {
|
function toggleStat(key: string) {
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
|
class="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
|
||||||
on:click={() => dispatch('saved', { title, description })}
|
on:click={() => dispatch('close')}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
<h2 class="font-semibold text-white text-sm">Edit activity</h2>
|
<h2 class="font-semibold text-white text-sm">Edit activity</h2>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-white transition-colors text-xl leading-none"
|
class="text-zinc-500 hover:text-white transition-colors text-xl leading-none"
|
||||||
on:click={() => dispatch('saved', { title, description })}
|
on:click={() => dispatch('close')}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user