diff --git a/bincio/edit/ops.py b/bincio/edit/ops.py index 0f6d2aa..81766ef 100644 --- a/bincio/edit/ops.py +++ b/bincio/edit/ops.py @@ -50,6 +50,8 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS] if hide: lines.append(f"hide_stats: [{', '.join(hide)}]") + if payload.get("download_disabled"): + lines.append("download_disabled: true") description = (payload.get("description") or "").strip() diff --git a/bincio/render/merge.py b/bincio/render/merge.py index 270b445..6f3ec9e 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -97,6 +97,8 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict: d["privacy"] = "unlisted" if fm["private"] else detail.get("privacy", "public") if "hide_stats" in fm: d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])] + if "download_disabled" in fm: + d["download_disabled"] = True return d diff --git a/bincio/serve/models.py b/bincio/serve/models.py index 1d6ea13..dd166f0 100644 --- a/bincio/serve/models.py +++ b/bincio/serve/models.py @@ -56,6 +56,7 @@ class ActivityEditRequest(BaseModel): private: bool | None = Field(default=None, description="Hide from public feed") highlight: bool | None = Field(default=None, description="Mark as favorite") gear: str | None = Field(default=None, description="Gear used") + download_disabled: bool | None = Field(default=None, description="Prevent others from downloading files") class ActivityEditResponse(BaseModel): diff --git a/bincio/serve/routers/download.py b/bincio/serve/routers/download.py new file mode 100644 index 0000000..04f1f0c --- /dev/null +++ b/bincio/serve/routers/download.py @@ -0,0 +1,173 @@ +"""Activity file download endpoint. + +GET /api/activity/{activity_id}/download/{fmt} + fmt: bas | original | gpx + +Permission: + - If activity.download_disabled is true: only the owner (authenticated) may download. + - Otherwise: no auth required — anyone who can see the activity can download. +""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Cookie, HTTPException +from fastapi.responses import FileResponse, Response + +from bincio.serve import deps + +router = APIRouter() + + +def _find_activity(activity_id: str) -> tuple[str, Path] | None: + """Return (handle, detail_path) for whichever user owns this activity.""" + data_dir = deps._get_data_dir() + for user_dir in sorted(data_dir.iterdir()): + if not user_dir.is_dir() or user_dir.name.startswith(("_", ".")): + continue + for base in (user_dir / "_merged" / "activities", user_dir / "activities"): + p = base / f"{activity_id}.json" + if p.exists(): + return user_dir.name, p + return None + + +def _check_download_permission( + detail: dict, handle: str, bincio_session: Optional[str] +) -> None: + if not detail.get("download_disabled"): + return + try: + user = deps._require_user(bincio_session) + except HTTPException: + raise HTTPException(403, "Downloads are disabled for this activity") + if user.handle != handle: + raise HTTPException(403, "Downloads are disabled for this activity") + + +def _generate_gpx(detail: dict, ts: dict) -> str: + t_vals = ts.get("t") or [] + lat_vals = ts.get("lat") or [] + lon_vals = ts.get("lon") or [] + ele_vals = ts.get("elevation_m") or [] + hr_vals = ts.get("hr_bpm") or [] + + title = (detail.get("title") or "Activity").replace("&", "&").replace("<", "<").replace(">", ">") + started = detail.get("started_at") or "1970-01-01T00:00:00+00:00" + try: + t0 = datetime.fromisoformat(started) + except ValueError: + t0 = datetime(1970, 1, 1, tzinfo=timezone.utc) + + lines = [ + '', + '', + f' {title}', + ] + + for i, t in enumerate(t_vals): + lat = lat_vals[i] if i < len(lat_vals) else None + lon = lon_vals[i] if i < len(lon_vals) else None + if lat is None or lon is None: + continue + ele = ele_vals[i] if i < len(ele_vals) else None + hr = hr_vals[i] if i < len(hr_vals) else None + ts_str = (t0 + timedelta(seconds=t)).strftime("%Y-%m-%dT%H:%M:%SZ") + trkpt = f' ' + if ele is not None: + trkpt += f"{ele}" + trkpt += f"" + if hr is not None: + trkpt += ( + f"" + f"{hr}" + f"" + ) + trkpt += "" + lines.append(trkpt) + + lines += [" ", ""] + return "\n".join(lines) + + +@router.get("/api/activity/{activity_id}/download/{fmt}") +async def download_activity( + activity_id: str, + fmt: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> Response: + deps._check_id(activity_id) + if fmt not in ("bas", "original", "gpx"): + raise HTTPException(400, "fmt must be bas, original, or gpx") + + result = _find_activity(activity_id) + if result is None: + raise HTTPException(404, "Activity not found") + handle, detail_path = result + + detail = json.loads(detail_path.read_text(encoding="utf-8")) + _check_download_permission(detail, handle, bincio_session) + + if fmt == "bas": + return FileResponse( + detail_path, + media_type="application/json", + filename=f"{activity_id}.json", + headers={"Content-Disposition": f'attachment; filename="{activity_id}.json"'}, + ) + + if fmt == "original": + source = detail.get("source") or "" + source_file = detail.get("source_file") or "" + if source not in ("fit_file", "gpx_file") or not source_file: + raise HTTPException(404, "No original file available for this activity") + safe_name = Path(source_file).name # strip any directory traversal + orig_path = deps._get_data_dir() / handle / "originals" / safe_name + if not orig_path.exists(): + raise HTTPException(404, "Original file not found on disk") + media_type = "application/octet-stream" + if safe_name.endswith(".fit"): + media_type = "application/vnd.ant.fit" + elif safe_name.endswith(".gpx"): + media_type = "application/gpx+xml" + return FileResponse( + orig_path, + media_type=media_type, + filename=safe_name, + headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, + ) + + # fmt == "gpx" + data_dir = deps._get_data_dir() + ts_path: Path | None = None + for base in ( + data_dir / handle / "_merged" / "activities", + data_dir / handle / "activities", + ): + p = base / f"{activity_id}.timeseries.json" + if p.exists(): + ts_path = p + break + + if ts_path is None: + raise HTTPException(404, "No GPS data available for this activity") + + ts = json.loads(ts_path.read_text(encoding="utf-8")) + lat_vals = ts.get("lat") or [] + if not any(v is not None for v in lat_vals): + raise HTTPException(404, "No GPS data available for this activity") + + gpx_content = _generate_gpx(detail, ts) + raw_title = detail.get("title") or activity_id + safe_title = "".join(c for c in raw_title if c.isalnum() or c in " -_")[:50].strip() + filename = f"{safe_title or activity_id}.gpx" + return Response( + content=gpx_content, + media_type="application/gpx+xml", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 56094f5..b52c39e 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -20,6 +20,7 @@ from bincio.serve.routers import ( activities, admin, auth, + download, feed, garmin, ideas, @@ -60,6 +61,7 @@ for _router in [ me.router, admin.router, activities.router, + download.router, uploads.router, segments.router, strava.router, diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 9cf8ff1..472ff07 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -403,6 +403,34 @@ {/if} + +{#if detail && (!detail.download_disabled || editEnabled)} +
+

Download

+
+ ⬇ BAS JSON + {#if detail.timeseries_url} + ⬇ GPX + {/if} + {#if detail.source === 'fit_file' || detail.source === 'gpx_file'} + ⬇ Original {detail.source === 'fit_file' ? 'FIT' : 'GPX'} + {/if} +
+
+{/if} + {#if segmentEfforts.length > 0}
diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte index 38f5363..9571e94 100644 --- a/site/src/components/EditDrawer.svelte +++ b/site/src/components/EditDrawer.svelte @@ -44,6 +44,7 @@ let description = ''; let highlight = false; let isPrivate = false; + let downloadDisabled = false; let hideStats: string[] = []; let images: string[] = []; @@ -70,8 +71,9 @@ highlight = d.highlight ?? false; // d.private is a bool (from the API); d.privacy is the raw field on older // endpoints. Accept either so the drawer works with both serve and edit servers. - isPrivate = d.private ?? (d.privacy === 'unlisted' || d.privacy === 'private') ?? false; - hideStats = d.hide_stats ?? []; + isPrivate = d.private ?? (d.privacy === 'unlisted' || d.privacy === 'private') ?? false; + downloadDisabled = d.download_disabled ?? false; + hideStats = d.hide_stats ?? []; images = d.images ?? []; } catch (e: any) { loadError = e.message; @@ -88,7 +90,7 @@ const res = await fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, sport, sub_sport: subSport || null, gear, description, highlight, private: isPrivate, hide_stats: hideStats }), + body: JSON.stringify({ title, sport, sub_sport: subSport || null, gear, description, highlight, private: isPrivate, download_disabled: downloadDisabled, hide_stats: hideStats }), }); if (!res.ok) throw new Error(await res.text()); saveStatus = 'Saved'; @@ -373,6 +375,18 @@ > ⊘ Unlisted +
{/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 7570830..212e456 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -124,6 +124,8 @@ export interface ActivityDetail extends Omit; }