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 .analytics import analyse, pace_text from .extensions import db, limiter from .models import Activity, Friendship, GarminConnection, User from .records import record_book 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 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) 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, record_book=record_book(rows), mapped_run=latest_mapped_run(rows), ) @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//") @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/") @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, mapped_run=latest_mapped_run(rows), ) @web.get("/activities/") def activity(activity_id): row, owner = get_activity(activity_id) analysis = analyse(row) efforts = [] if owner: history = db.session.scalars(db.select(Activity).filter_by(owner_id=row.owner_id)).all() efforts = record_book(history)["runs"].get(row.id, []) return render_template( "activity.html", activity=row, owner_view=owner, analysis=analysis, pace_text=pace_text, efforts=efforts, ) @web.get("/activities//data") def data(activity_id): row, owner = get_activity(activity_id) return jsonify(activity_data(row, owner)) @web.post("/activities//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//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/") 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//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//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")) connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=current_user.id)) return render_template("settings.html", connection=connection) @web.post("/garmin/") @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") @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"))