Deploy Stride with encrypted manual and scheduled Garmin sync

This commit is contained in:
Peter Stockings
2026-10-01 20:42:28 +10:00
parent 24c7fc2c6a
commit 8555695c5e
28 changed files with 900 additions and 56 deletions
+109 -2
View File
@@ -26,7 +26,7 @@ from garmin_sync.service import SyncError, atomic_write
from .analytics import analyse, pace_text
from .extensions import db, limiter
from .models import Activity, Friendship, User
from .models import Activity, Friendship, GarminConnection, User
from .services import activity_data, import_cache, import_upload
web = Blueprint("web", __name__)
@@ -73,6 +73,17 @@ def are_friends(first, second):
)
def latest_mapped_run(rows):
return next(
(
row
for row in rows
if row.sport == "running" and any(sample.get("point") for sample in row.samples)
),
None,
)
def overview(rows, timezone="Australia/Sydney"):
total_distance = sum(r.distance_m for r in rows)
total_time = sum(r.duration_s for r in rows)
@@ -208,6 +219,7 @@ def dashboard():
stats=overview(rows, current_user.timezone),
profile=current_user,
personal=True,
mapped_run=latest_mapped_run(rows),
)
@@ -290,6 +302,7 @@ def profile(username):
stats=overview(rows, user.timezone),
profile=user,
personal=False,
mapped_run=latest_mapped_run(rows),
)
@@ -308,6 +321,52 @@ def data(activity_id):
return jsonify(activity_data(row, owner))
@web.post("/activities/<int:activity_id>/share")
@login_required
def share_activity(activity_id):
row, _ = get_activity(activity_id, True)
if not row.share_token:
# Conditional update keeps simultaneous copy requests on the same link.
db.session.execute(
db.update(Activity)
.where(Activity.id == row.id, Activity.share_token.is_(None))
.values(share_token=secrets.token_urlsafe(32))
)
db.session.commit()
db.session.refresh(row)
return jsonify(url=url_for("web.shared_activity", token=row.share_token, _external=True))
@web.post("/activities/<int:activity_id>/share/revoke")
@login_required
def revoke_activity_share(activity_id):
row, _ = get_activity(activity_id, True)
row.share_token = None
db.session.commit()
flash("Public link disabled. Previously copied links no longer work.")
return redirect(url_for("web.activity", activity_id=row.id))
@web.get("/shared/runs/<token>")
def shared_activity(token):
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
return render_template(
"activity.html",
activity=row,
owner_view=False,
public_view=True,
analysis=analyse(row),
pace_text=pace_text,
data_url=url_for("web.shared_activity_data", token=token),
)
@web.get("/shared/runs/<token>/data")
def shared_activity_data(token):
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
return jsonify(activity_data(row, False))
@web.post("/activities/<int:activity_id>/edit")
@login_required
def edit_activity(activity_id):
@@ -334,7 +393,55 @@ def settings():
db.session.commit()
flash("Profile saved.")
return redirect(url_for("web.settings"))
return render_template("settings.html")
connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=current_user.id))
return render_template("settings.html", connection=connection)
@web.post("/garmin/<action>")
@login_required
@limiter.limit("10 per hour")
def garmin_action(action):
from filelock import Timeout
from .sync import connect, session_lock
if action not in {"connect", "sync", "schedule", "disconnect"}:
abort(404)
try:
if action == "connect":
file = request.files.get("tokens")
if not file:
raise SyncError("Choose your exported Garmin session JSON file.")
connect(current_user, file.read(65537))
flash("Garmin connected. Your first sync is queued.")
else:
with session_lock(current_user.id):
connection = db.session.scalar(
db.select(GarminConnection).filter_by(user_id=current_user.id)
)
if not connection:
raise SyncError("Connect Garmin first.")
if action == "sync":
connection.pending = True
connection.message = "Manual sync queued."
elif action == "schedule":
connection.scheduled = request.form.get("enabled") == "yes"
connection.next_sync = datetime.now(UTC)
else:
db.session.delete(connection)
current_user.local_account_key = None
db.session.commit()
flash("Garmin sync settings updated.")
except Timeout:
db.session.rollback()
flash("A sync is running. Please retry shortly.", "error")
except SyncError as error:
db.session.rollback()
flash(str(error), "error")
except Exception: # noqa: BLE001 -- suppress provider credentials at boundary
db.session.rollback()
flash("Garmin connection failed. Check your session file and try again.", "error")
return redirect(url_for("web.settings"))
@web.post("/imports/cache")