feat: build Stride local app with Garmin imports and friends-only sharing

This commit is contained in:
Peter Stockings
2026-10-01 14:55:59 +10:00
parent 16e71a69d5
commit e37569ad09
45 changed files with 3735 additions and 40 deletions
+375
View File
@@ -0,0 +1,375 @@
import hashlib
import re
import secrets
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo, available_timezones
from flask import (
Blueprint,
abort,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.exc import IntegrityError
from werkzeug.security import check_password_hash, generate_password_hash
from garmin_sync.service import SyncError, atomic_write
from .extensions import db, limiter
from .models import Activity, Friendship, User
from .services import activity_data, import_cache, import_upload
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
def setup_available():
return (
current_app.config["LOCAL_SETUP"]
and request.remote_addr in {"127.0.0.1", "::1"}
and db.session.scalar(db.select(db.func.count(User.id))) == 0
)
def bootstrap_directory():
directories = [
p
for p in Path(current_app.config["DATA_ROOT"]).glob("*")
if p.is_dir() and re.fullmatch(r"[a-f0-9]{24}", p.name)
]
return directories[0] if len(directories) == 1 else None
def get_activity(activity_id, require_owner=False):
row = db.get_or_404(Activity, activity_id)
owner = current_user.is_authenticated and row.owner_id == current_user.id
allowed = (
current_user.is_authenticated
and row.visibility == "friends"
and are_friends(current_user.id, row.owner_id)
)
if (require_owner and not owner) or (not owner and not allowed):
abort(404)
return row, owner
def are_friends(first, second):
low, high = sorted((first, second))
return (
db.session.scalar(
db.select(Friendship.id).filter_by(low_id=low, high_id=high, status="accepted")
)
is not 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)
latest = max((r.started_at for r in rows), default=datetime.now(UTC))
latest_day = latest.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
monday = latest_day - timedelta(days=latest_day.weekday())
weeks = [monday - timedelta(weeks=n) for n in reversed(range(8))]
distances = defaultdict(float)
for row in rows:
day = row.started_at.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
distances[day - timedelta(days=day.weekday())] += row.distance_km
seconds = round(total_time / (total_distance / 1000)) if total_distance else None
return {
"distance": total_distance / 1000,
"duration": total_time,
"count": len(rows),
"elevation": sum(r.elevation_m for r in rows),
"pace": f"{seconds // 60}:{seconds % 60:02d}" if seconds is not None else "—",
"weeks": [w.strftime("%d %b") for w in weeks],
"weekly_km": [round(distances[w], 2) for w in weeks],
}
@web.get("/health")
def health():
db.session.execute(db.text("SELECT 1"))
return {"status": "ok"}
@web.get("/")
def index():
if setup_available():
return redirect(url_for("web.setup"))
return redirect(url_for("web.dashboard" if current_user.is_authenticated else "web.login"))
def register_user(is_setup=False):
username = secrets.token_hex(10)
email = request.form.get("email", "").strip().lower()
display_name = request.form.get("display_name", "").strip()
password = request.form.get("password", "")
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) or len(email) > 254:
return "Enter a valid email address."
if not 1 <= len(display_name) <= 80:
return "Enter a display name of up to 80 characters."
if not 12 <= len(password) <= 128:
return "Use a password between 12 and 128 characters."
user = User(username=username, email=email, display_name=display_name)
user.set_password(password)
directory = bootstrap_directory() if is_setup else None
if directory:
user.local_account_key = directory.name
try:
db.session.add(user)
db.session.flush()
count = import_cache(user, current_app.config["DATA_ROOT"]) if directory else 0
db.session.commit()
except IntegrityError:
db.session.rollback()
return "That email is already registered."
session.clear()
login_user(user)
flash(
f"Welcome to Stride. {count} activities imported privately."
if count
else "Welcome to Stride."
)
return None
@web.route("/setup", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def setup():
if not setup_available():
abort(404)
directory = bootstrap_directory()
count = len(list(directory.glob("*/manifest.json"))) if directory else 0
error = register_user(True) if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="setup", error=error, import_count=count)
@web.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def register():
if setup_available():
return redirect(url_for("web.setup"))
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = register_user() if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="register", error=error)
@web.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = db.session.scalar(db.select(User).filter_by(email=email))
valid = check_password_hash(user.password_hash if user else DUMMY_HASH, password[:129])
if user and valid and len(password) <= 128:
session.clear()
login_user(user)
return redirect(url_for("web.dashboard"))
error = "Email or password was not recognised."
return render_template("auth.html", mode="login", error=error)
@web.post("/logout")
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for("web.login"))
@web.get("/dashboard")
@login_required
def dashboard():
rows = db.session.scalars(
db.select(Activity).filter_by(owner_id=current_user.id).order_by(Activity.started_at.desc())
).all()
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, current_user.timezone),
profile=current_user,
personal=True,
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
def friends():
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
other = db.session.scalar(db.select(User).filter_by(email=email))
if other and other.id != current_user.id:
low, high = sorted((current_user.id, other.id))
existing = db.session.scalar(db.select(Friendship).filter_by(low_id=low, high_id=high))
if not existing:
db.session.add(Friendship(low_id=low, high_id=high, requested_by=current_user.id))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
flash(
"If that person has a Stride account, your request is available in their Friends page."
)
return redirect(url_for("web.friends"))
links = db.session.scalars(
db.select(Friendship).where(
db.or_(Friendship.low_id == current_user.id, Friendship.high_id == current_user.id)
)
).all()
people = [
{
"link": link,
"person": db.session.get(
User, link.high_id if link.low_id == current_user.id else link.low_id
),
}
for link in links
]
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
rows = db.session.scalars(
db.select(Activity)
.where(Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends")
.order_by(Activity.started_at.desc())
.limit(50)
).all()
return render_template("friends.html", people=people, rows=rows)
@web.post("/friends/<int:friendship_id>/<action>")
@login_required
def friendship_action(friendship_id, action):
link = db.get_or_404(Friendship, friendship_id)
if current_user.id not in (link.low_id, link.high_id):
abort(404)
if action == "accept" and link.status == "pending" and link.requested_by != current_user.id:
link.status = "accepted"
flash("You’re now friends. Activities shared with friends are visible to both of you.")
elif action == "remove":
db.session.delete(link)
flash("Connection removed. Shared activities are no longer accessible between you.")
else:
abort(400)
db.session.commit()
return redirect(url_for("web.friends"))
@web.get("/u/<username>")
@login_required
def profile(username):
user = db.first_or_404(db.select(User).filter_by(username=username))
if user.id != current_user.id and not are_friends(current_user.id, user.id):
abort(404)
rows = db.session.scalars(
db.select(Activity)
.filter_by(owner_id=user.id, visibility="friends")
.order_by(Activity.started_at.desc())
).all()
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, user.timezone),
profile=user,
personal=False,
)
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
return render_template("activity.html", activity=row, owner_view=owner)
@web.get("/activities/<int:activity_id>/data")
def data(activity_id):
row, owner = get_activity(activity_id)
return jsonify(activity_data(row, owner))
@web.post("/activities/<int:activity_id>/edit")
@login_required
def edit_activity(activity_id):
row, _ = get_activity(activity_id, True)
title = request.form.get("title", "").strip()
visibility = request.form.get("visibility", "private")
if not title or len(title) > 160 or visibility not in {"friends", "private"}:
abort(400)
row.title, row.visibility = title, visibility
db.session.commit()
flash("Activity updated.")
return redirect(url_for("web.activity", activity_id=row.id))
@web.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
if request.method == "POST":
name = request.form.get("display_name", "").strip()
timezone = request.form.get("timezone", "")
if not 1 <= len(name) <= 80 or timezone not in available_timezones():
abort(400)
current_user.display_name, current_user.timezone = name, timezone
db.session.commit()
flash("Profile saved.")
return redirect(url_for("web.settings"))
return render_template("settings.html")
@web.post("/imports/cache")
@login_required
def import_local():
if not current_user.local_account_key:
abort(404)
try:
count = import_cache(current_user, current_app.config["DATA_ROOT"])
db.session.commit()
flash(f"Imported {count} new activities from your local Garmin sync.")
except (SyncError, ValueError, OSError):
db.session.rollback()
flash("An import could not be read. Check the local sync output and try again.", "error")
return redirect(url_for("web.dashboard"))
@web.post("/imports/fit")
@login_required
def upload():
file = request.files.get("file")
if not file or not file.filename.lower().endswith(".fit"):
flash("Choose a FIT activity file.", "error")
return redirect(url_for("web.settings"))
payload = file.read(16 * 1024 * 1024 + 1)
if len(payload) > 16 * 1024 * 1024:
abort(413)
try:
row = import_upload(current_user, payload)
if row:
destination = Path(current_app.instance_path) / "uploads" / str(current_user.id)
atomic_write(destination / f"{hashlib.sha256(payload).hexdigest()}.fit", payload)
db.session.commit()
flash("Activity imported privately.")
return redirect(url_for("web.activity", activity_id=row.id))
flash("You have already imported that file.")
except (SyncError, ValueError, TypeError, OSError):
db.session.rollback()
flash(
"The FIT file could not be imported. Check it contains a completed activity.", "error"
)
return redirect(url_for("web.settings"))