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)}
+
+{/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;
}