Skip to content

Commit 081f914

Browse files
committed
API compliance
1 parent 0d423e0 commit 081f914

14 files changed

Lines changed: 293 additions & 39 deletions

File tree

PRODUCTION.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ No CLI step needed. Enter your email, password, and API key directly in the Sett
156156

157157
> Re-authenticate any time tokens expire by clicking the button again.
158158
159+
#### Strava write-only enforcement (API TOS)
160+
161+
Strava's API terms require that Strava data is never used as a source of truth for other providers, and that all Strava data is deleted immediately when a user disconnects. TraceKit enforces this by setting `"write_only": True` on the Strava provider entry in `tracekit/appconfig.py`. The sync engine reads this flag and:
162+
163+
- never selects Strava as the authoritative provider when resolving name/equipment conflicts
164+
- never propagates Strava-only activities outward to other providers
165+
- excludes Strava activity records older than 7 days from correlation (per API TOS data freshness requirements)
166+
- deletes all Strava activity data immediately on user disconnect (both via the Settings UI "Disconnect Strava" button and via the Strava deauthorization webhook)
167+
168+
If `"write_only"` is removed or set to `False` for the Strava provider in `tracekit/appconfig.py`, Strava will start behaving like any other provider (source-of-truth capable). Do not do this — it would violate the Strava API TOS.
169+
159170
### Garmin
160171

161172
Garmin uses garth for OAuth. Tokens are stored in the database and valid for roughly a year.

app/routes/auth_strava.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import os
44

55
from db_init import _init_db
6-
from flask import Blueprint, redirect, request
6+
from flask import Blueprint, jsonify, redirect, request
7+
from flask_login import current_user
78

89
strava_bp = Blueprint("auth_strava", __name__)
910

@@ -144,3 +145,56 @@ def api_auth_strava_callback():
144145
return _strava_callback_page(True, "Strava authentication successful!")
145146
except Exception as e:
146147
return _strava_callback_page(False, f"Token exchange failed: {e}")
148+
149+
150+
@strava_bp.route("/api/auth/strava/disconnect", methods=["POST"])
151+
def api_auth_strava_disconnect():
152+
"""Disconnect Strava: delete all local Strava activity data then clear credentials.
153+
154+
Required by Strava API TOS: when a user unlinks Strava, all data retrieved
155+
via the Strava API must be immediately deleted.
156+
"""
157+
_init_db()
158+
159+
from tracekit.user_context import set_user_id
160+
161+
if current_user.is_authenticated:
162+
set_user_id(current_user.id)
163+
164+
errors = []
165+
166+
# 1. Delete all Strava activity data for this user.
167+
try:
168+
from tracekit.appconfig import load_config
169+
from tracekit.core import Tracekit
170+
171+
config = load_config()
172+
tk = Tracekit(config)
173+
if tk.strava:
174+
tk.strava.reset_activities(None)
175+
except Exception as e:
176+
errors.append(f"activity deletion failed: {e}")
177+
178+
# 2. Clear OAuth tokens and disable the provider.
179+
try:
180+
import json
181+
182+
from tracekit.appconfig import AppConfig, clear_strava_tokens
183+
184+
clear_strava_tokens()
185+
user_id = current_user.id if current_user.is_authenticated else 0
186+
row = AppConfig.get_or_none((AppConfig.key == "providers") & (AppConfig.user_id == user_id))
187+
if row:
188+
providers = json.loads(row.value)
189+
strava_cfg = providers.get("strava", {}).copy()
190+
strava_cfg["enabled"] = False
191+
providers["strava"] = strava_cfg
192+
AppConfig.update({AppConfig.value: json.dumps(providers)}).where(
193+
(AppConfig.key == "providers") & (AppConfig.user_id == user_id)
194+
).execute()
195+
except Exception as e:
196+
errors.append(f"credential clearing failed: {e}")
197+
198+
if errors:
199+
return jsonify({"error": "; ".join(errors)}), 500
200+
return jsonify({"status": "disconnected"})

app/routes/strava_webhook.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ def _sync_local_activity(activity_id, user_id: int):
215215

216216

217217
def _handle_deauthorize(owner_id):
218-
"""Disable Strava and clear tokens for the user who revoked access."""
218+
"""Disable Strava and clear tokens for the user who revoked access.
219+
220+
Per Strava API TOS, all data retrieved via the Strava API must be deleted
221+
immediately when a user deauthorizes the application.
222+
"""
219223
import json
220224

221225
from tracekit.appconfig import AppConfig
@@ -228,6 +232,19 @@ def _handle_deauthorize(owner_id):
228232

229233
set_user_id(user_id)
230234

235+
# Delete all Strava activity data for this user (required by Strava API TOS).
236+
try:
237+
from tracekit.appconfig import load_config
238+
from tracekit.core import Tracekit
239+
240+
config = load_config()
241+
tk = Tracekit(config)
242+
if tk.strava:
243+
deleted = tk.strava.reset_activities(None)
244+
log.info("Strava deauth: deleted %d activity records for user_id=%d", deleted, user_id)
245+
except Exception as e:
246+
log.error("Strava deauth: error deleting activity data for user_id=%d: %s", user_id, e)
247+
231248
try:
232249
row = AppConfig.get_or_none((AppConfig.key == "providers") & (AppConfig.user_id == user_id))
233250
if row is None:
@@ -247,7 +264,10 @@ def _handle_deauthorize(owner_id):
247264
.execute()
248265
)
249266
log.info("Strava deauth: disabled provider for user_id=%d", user_id)
250-
_notify_admin(f"Strava webhook: user {user_id} (athlete {owner_id}) deauthorized — provider disabled", "error")
267+
_notify_admin(
268+
f"Strava webhook: user {user_id} (athlete {owner_id}) deauthorized — provider disabled and all data deleted",
269+
"error",
270+
)
251271
except Exception as e:
252272
log.error("Strava deauth: error disabling provider for user_id=%d: %s", user_id, e)
253273
_notify_admin(f"Strava webhook: deauth error for user {user_id}: {e}", "error")

app/static/settings.js

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ function showStatus(text, type = 'ok') {
1515
const PROVIDER_META = {
1616
strava: {
1717
label: 'Strava', sync_equipment: true, sync_name: true,
18+
// Strava API TOS: Strava is a write-only target — it can never be the
19+
// authoritative source for other providers.
20+
write_only: true,
21+
sync_equipment_label: 'Write equipment',
22+
sync_name_label: 'Write name',
1823
// No text_fields by default — client_id/secret come from system credentials.
1924
// They are revealed in personal_credential_fields when the toggle is on.
2025
text_fields: [],
@@ -161,7 +166,8 @@ function makeProviderCard(name, data) {
161166

162167
const header = document.createElement('div');
163168
header.className = 'provider-header';
164-
header.innerHTML = `<span class="drag-handle">⠿</span><span class="provider-name">${escHtml(meta.label)}</span><span class="provider-activity-count"></span><span class="provider-inline-status"></span>`;
169+
const writeOnlyBadge = meta.write_only ? '<span class="write-only-badge">write only</span>' : '';
170+
header.innerHTML = `<span class="drag-handle">⠿</span><span class="provider-name">${escHtml(meta.label)}</span>${writeOnlyBadge}<span class="provider-activity-count"></span><span class="provider-inline-status"></span>`;
165171

166172
const enabledToggle = makeToggle(`en-${name}`, data.enabled, 'Enabled');
167173
enabledToggle.querySelector('input').addEventListener('change', e => {
@@ -181,15 +187,17 @@ function makeProviderCard(name, data) {
181187
controls.className = 'provider-controls';
182188

183189
if (meta.sync_equipment) {
184-
const t = makeToggle(`se-${name}`, data.sync_equipment ?? false, 'Sync equipment');
190+
const equipLabel = meta.sync_equipment_label || 'Sync equipment';
191+
const t = makeToggle(`se-${name}`, data.sync_equipment ?? false, equipLabel);
185192
const seInput = t.querySelector('input');
186193
seInput.disabled = !data.enabled || !!meta.sync_equipment_disabled;
187194
if (!meta.sync_equipment_disabled) seInput.addEventListener('change', autoSave);
188195
if (meta.sync_equipment_disabled) t.title = 'Not supported by this provider';
189196
controls.appendChild(t);
190197
}
191198
if (meta.sync_name) {
192-
const t = makeToggle(`sn-${name}`, data.sync_name ?? false, 'Sync name');
199+
const nameLabel = meta.sync_name_label || 'Sync name';
200+
const t = makeToggle(`sn-${name}`, data.sync_name ?? false, nameLabel);
193201
t.querySelector('input').disabled = !data.enabled;
194202
t.querySelector('input').addEventListener('change', autoSave);
195203
controls.appendChild(t);
@@ -223,6 +231,36 @@ function makeProviderCard(name, data) {
223231
authBtn.appendChild(stravaImg);
224232
authBtn.addEventListener('click', () => { window.location.href = '/api/auth/strava/authorize'; });
225233
card.appendChild(authBtn);
234+
235+
if (data.access_token) {
236+
const disconnectBtn = document.createElement('button');
237+
disconnectBtn.type = 'button';
238+
disconnectBtn.className = 'btn btn-danger strava-disconnect-btn';
239+
disconnectBtn.textContent = 'Disconnect Strava';
240+
disconnectBtn.title = 'Disconnecting will immediately and permanently delete all Strava activity data from TraceKit (required by Strava API terms).';
241+
disconnectBtn.addEventListener('click', async () => {
242+
if (!confirm('Disconnecting Strava will immediately and permanently delete ALL Strava activity data from TraceKit. This is required by Strava\'s API terms and cannot be undone.\n\nContinue?')) return;
243+
disconnectBtn.disabled = true;
244+
disconnectBtn.textContent = 'Disconnecting…';
245+
try {
246+
const resp = await fetch('/api/auth/strava/disconnect', { method: 'POST' });
247+
if (resp.ok) {
248+
showStatus('Strava disconnected. All Strava data deleted.');
249+
window.location.reload();
250+
} else {
251+
const d = await resp.json().catch(() => ({}));
252+
showStatus('Error: ' + (d.error || 'disconnect failed'), 'error');
253+
disconnectBtn.disabled = false;
254+
disconnectBtn.textContent = 'Disconnect Strava';
255+
}
256+
} catch (e) {
257+
showStatus('Network error: ' + e.message, 'error');
258+
disconnectBtn.disabled = false;
259+
disconnectBtn.textContent = 'Disconnect Strava';
260+
}
261+
});
262+
card.appendChild(disconnectBtn);
263+
}
226264
}
227265

228266
// RideWithGPS: OAuth button, same pattern as Strava.
@@ -404,11 +442,31 @@ function renderProviders(config) {
404442
return pa !== pb ? pa - pb : a[0].localeCompare(b[0]);
405443
});
406444

445+
const normalEntries = entries.filter(([name]) => !(PROVIDER_META[name] || {}).write_only);
446+
const writeOnlyEntries = entries.filter(([name]) => (PROVIDER_META[name] || {}).write_only);
447+
407448
const list = document.getElementById('provider-list');
408449
list.innerHTML = '';
409-
for (const [name, data] of entries) {
450+
451+
for (const [name, data] of normalEntries) {
410452
list.appendChild(makeProviderCard(name, data));
411453
}
454+
455+
if (writeOnlyEntries.length > 0) {
456+
const divider = document.createElement('div');
457+
divider.className = 'write-only-divider';
458+
divider.innerHTML = '<hr><span class="write-only-divider-label">Write-only providers — receive updates, never a source of truth</span>';
459+
list.appendChild(divider);
460+
461+
for (const [name, data] of writeOnlyEntries) {
462+
const card = makeProviderCard(name, data);
463+
// Write-only providers should not be dragged into the authoritative
464+
// provider ordering — remove drag capability.
465+
card.draggable = false;
466+
card.querySelector('.drag-handle')?.remove();
467+
list.appendChild(card);
468+
}
469+
}
412470
}
413471

414472
// ── Collect settings from DOM and save ───────────────────────────────────────

app/static/style.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,43 @@ select#timezone:focus {
10651065
.auth-status-chip.connected { background: #eaf6ef; color: #1e8449; border: 1px solid #a9dfbf; }
10661066
.auth-status-chip.not-connected { background: #f5f5f5; color: #888; border: 1px solid #ddd; }
10671067

1068+
/* Write-only provider badge — shown in provider card header */
1069+
.write-only-badge {
1070+
display: inline-block;
1071+
font-size: 0.7rem;
1072+
font-weight: 600;
1073+
text-transform: uppercase;
1074+
letter-spacing: 0.05em;
1075+
background: #fff3cd;
1076+
color: #856404;
1077+
border: 1px solid #ffc107;
1078+
border-radius: 4px;
1079+
padding: 1px 6px;
1080+
margin-left: 4px;
1081+
vertical-align: middle;
1082+
}
1083+
1084+
/* Divider between regular and write-only providers */
1085+
.write-only-divider {
1086+
margin: 12px 0 4px;
1087+
}
1088+
.write-only-divider hr {
1089+
border: none;
1090+
border-top: 1px solid #e0e0e0;
1091+
margin: 0 0 6px;
1092+
}
1093+
.write-only-divider-label {
1094+
font-size: 0.78rem;
1095+
color: #888;
1096+
font-style: italic;
1097+
}
1098+
1099+
/* Disconnect button for write-only providers */
1100+
.strava-disconnect-btn {
1101+
margin-top: 10px;
1102+
font-size: 0.85rem;
1103+
}
1104+
10681105
/* Strava-branded auth button — uses official connect_with_strava.svg */
10691106
.strava-auth-btn {
10701107
display: inline-block;

tracekit/appconfig.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"strava": {
4040
"enabled": False,
4141
"priority": 3,
42+
"write_only": True, # Strava API TOS: Strava is a write target only, never a source of truth
4243
"sync_equipment": True,
4344
"sync_name": True,
4445
"use_personal_credentials": False,

tracekit/calendar.py

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -191,26 +191,27 @@ def get_single_month_data(year_month: str, home_timezone: str = "UTC") -> dict[s
191191
except Exception as e:
192192
print(f"Error getting activity days for {provider}/{year_month}: {e}")
193193

194-
garmin_devices: list[str] = []
195-
try:
196-
rows = (
197-
GarminActivity.select(GarminActivity.device_name)
198-
.where(
199-
GarminActivity.start_time.is_null(False)
200-
& (GarminActivity.start_time >= start_ts)
201-
& (GarminActivity.start_time <= end_ts)
202-
& GarminActivity.device_name.is_null(False)
203-
& (GarminActivity.user_id == uid)
204-
)
205-
.distinct()
206-
)
207-
garmin_devices = sorted({r.device_name for r in rows if r.device_name})
208-
except Exception:
209-
pass
210-
211194
provider_metadata: dict[str, dict] = {}
212-
if garmin_devices:
213-
provider_metadata["garmin"] = {"devices": garmin_devices}
195+
for provider, model in provider_models.items():
196+
if provider not in activity_counts:
197+
continue
198+
try:
199+
rows = (
200+
model.select(model.device_name)
201+
.where(
202+
model.start_time.is_null(False)
203+
& (model.start_time >= start_ts)
204+
& (model.start_time <= end_ts)
205+
& model.device_name.is_null(False)
206+
& (model.user_id == uid)
207+
)
208+
.distinct()
209+
)
210+
devices = sorted({r.device_name for r in rows if r.device_name})
211+
if devices:
212+
provider_metadata[provider] = {"devices": devices}
213+
except Exception:
214+
pass
214215

215216
return {
216217
"year_month": year_month,

tracekit/database.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ def _run_schema_upgrades() -> None:
202202
is_sqlite,
203203
[
204204
("garmin_activities", "device_name", "VARCHAR(255)"),
205+
("strava_activities", "device_name", "VARCHAR(255)"),
206+
("ridewithgps_activities", "device_name", "VARCHAR(255)"),
207+
("intervalsicu_activities", "device_name", "VARCHAR(255)"),
208+
("file_activities", "device_name", "VARCHAR(255)"),
209+
("spreadsheet_activities", "device_name", "VARCHAR(255)"),
205210
("notification", "expires", "INTEGER"),
206211
("provider_status", "rate_limit_type", "VARCHAR(64)"),
207212
("provider_status", "rate_limit_reset_at", "INTEGER"),

tracekit/providers/base_provider_activity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class BaseProviderActivity(Model):
3939
activity_type = CharField(max_length=50, null=True)
4040
duration_hms = CharField(max_length=20, null=True)
4141
equipment = CharField(max_length=255, null=True)
42+
device_name = CharField(max_length=255, null=True)
4243

4344
# Location information
4445
location_name = CharField(max_length=255, null=True)

tracekit/providers/garmin/garmin_activity.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ class GarminActivity(BaseProviderActivity):
1515
# Garmin-specific ID field
1616
garmin_id = CharField(max_length=50, unique=True, index=True)
1717

18-
# Device that recorded this activity (e.g. "Forerunner 965")
19-
device_name = CharField(max_length=255, null=True)
20-
2118
class Meta: # type: ignore
2219
database = db
2320
table_name = "garmin_activities"

0 commit comments

Comments
 (0)