Add activity file downloads with per-activity download_disabled flag

New endpoint: GET /api/activity/{id}/download/{bas|original|gpx}
- bas: streams the BAS detail JSON as an attachment
- original: streams the original FIT or GPX file from originals/
- gpx: generates a GPX from the timeseries (always available when GPS exists)

download_disabled flag stored in sidecar (edits/{id}.md), propagated to
the merged BAS detail JSON. When set, only the owner can download.

Backend: ops.py writes flag to sidecar; merge.py propagates it to detail
JSON; download.py implements the endpoint; server.py registers the router.
Frontend: EditDrawer gets a "No download" toggle button; ActivityDetail
shows a Download section (hidden when disabled and viewer is not the owner).
This commit is contained in:
Davide Scaini
2026-05-15 18:35:40 +02:00
parent fe437626e6
commit c465e518e5
8 changed files with 227 additions and 3 deletions
+28
View File
@@ -403,6 +403,34 @@
</div>
{/if}
<!-- Download -->
{#if detail && (!detail.download_disabled || editEnabled)}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 px-4 py-3">
<p class="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">Download</p>
<div class="flex flex-wrap gap-2">
<a
href="/api/activity/{activity.id}/download/bas"
download
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
>⬇ BAS JSON</a>
{#if detail.timeseries_url}
<a
href="/api/activity/{activity.id}/download/gpx"
download
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
>⬇ GPX</a>
{/if}
{#if detail.source === 'fit_file' || detail.source === 'gpx_file'}
<a
href="/api/activity/{activity.id}/download/original"
download
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
>⬇ Original {detail.source === 'fit_file' ? 'FIT' : 'GPX'}</a>
{/if}
</div>
</div>
{/if}
<!-- Segment efforts -->
{#if segmentEfforts.length > 0}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
+17 -3
View File
@@ -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
</button>
<button
type="button"
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
class:border-zinc-700={!downloadDisabled}
class:text-zinc-400={!downloadDisabled}
class:border-orange-500={downloadDisabled}
class:text-orange-300={downloadDisabled}
style={downloadDisabled ? 'background:rgba(249,115,22,.1)' : ''}
on:click={() => downloadDisabled = !downloadDisabled}
>
⬇ No download
</button>
</div>
{/if}
</div>
+2
View File
@@ -124,6 +124,8 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
mmp: MmpCurve | null;
strava_id: string | null;
duplicate_of: string | null;
source_file?: string | null;
download_disabled?: boolean;
custom: Record<string, unknown>;
}