import csv import hashlib import io import re import secrets from datetime import UTC, datetime from pathlib import Path from zoneinfo import available_timezones from flask import ( Blueprint, Response, 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 pace_text from .display import stored_analysis from .extensions import db, limiter from .history import activity_page, latest_mapped_run, overview, records_for from .models import Activity, Friendship, GarminConnection, User from .services import activity_data, import_cache, import_upload, route_data from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS from .stats_dsl import UNITS 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 ) @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(): filters = [Activity.owner_id == current_user.id] stats = overview(filters, current_user.timezone) pagination = activity_page(filters, total=stats["count"]) return render_template( "dashboard.html", rows=pagination.items, pagination=pagination, stats=stats, profile=current_user, personal=True, record_book=records_for(current_user.id, {row.id for row in pagination.items}), mapped_run=latest_mapped_run(filters), pace_text=pace_text, ) @web.get("/stats") @login_required def stats(): error = None results = None try: query = parse_query(request.args) page = int(request.args.get("page", "1")) results = explore(current_user.id, current_user.timezone, query, page) except (ValueError, OverflowError) as exc: error = str(exc) query = dict(DEFAULT) return render_template( "stats.html", query=query, query_text=query_text(query), metrics=METRICS, results=results, error=error, submitted_query=request.args.get("query", ""), language={ "functions": sorted(FUNCTIONS), "groups": sorted(GROUPS), "fields": { key: { "label": info[0], "unit": info[1], "description": info[2], "units": list(({**UNITS, **EXTRA_UNITS}).get(key, {})), } for key, info in METRICS.items() }, }, ), 400 if error else 200 @web.get("/stats/runs") @login_required def stats_runs(): try: query = parse_query(request.args) bucket = request.args.get("bucket", "") results = explore( current_user.id, current_user.timezone, query, int(request.args.get("page", "1")), selected_bucket=bucket, raw_group=request.args.get("scope") == "group", ) except (ValueError, OverflowError) as exc: return jsonify(error=str(exc)), 400 return jsonify( rows=results["rows"], count=results["evidence_count"], page=results["page"], pages=results["pages"], ) @web.post("/stats/validate") @login_required @limiter.limit("120 per minute") def stats_validate(): payload = request.get_json(silent=True) if not isinstance(payload, dict) or not isinstance(payload.get("query"), str): return jsonify(valid=False, diagnostics=[{"line": 1, "message": "Supply query text."}]), 400 try: query = parse_query({"query": payload["query"]}) period_bounds(query, current_user.timezone) return jsonify( valid=True, canonical=query_text(query), diagnostics=[], derived=query["derived"] ) except (ValueError, OverflowError) as exc: return jsonify( valid=False, diagnostics=[{"line": getattr(exc, "line", 1), "message": str(exc)}] ) @web.get("/stats/export") @login_required @limiter.limit("20 per minute") def stats_export(): try: query = parse_query(request.args) result = explore(current_user.id, current_user.timezone, query, export=True) kind = request.args.get("kind", "groups") if kind not in {"runs", "groups"}: raise ValueError("Export runs or groups.") except (ValueError, OverflowError) as exc: return jsonify(error=str(exc)), 400 output = io.StringIO(newline="") writer = csv.writer(output) def safe(value): # Keep user-authored run titles inert when opened in a spreadsheet. text = str(value) return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text columns = result["cards"] if kind == "groups" else result["evidence_columns"] headers = [ f"{card['key']} ({'sec/km' if card['unit'] == '/km' else card['unit']})" for card in columns ] if kind == "groups": writer.writerow(["Group", "Contributing runs", *headers]) for group in result["groups"]: writer.writerow([safe(group["label"]), group["count"], *group["raw_values"]]) else: writer.writerow( ["Run", "Local date", "Distance (km)", "Previous run", "Previous run date", *headers] ) for row in result["rows"]: previous = row["previous_run"] or {} writer.writerow( [ safe(row["title"]), row["date"], row["distance"], safe(previous.get("title", "")), previous.get("date", ""), *row["raw_values"], ] ) return Response( output.getvalue(), mimetype="text/csv", headers={ "Content-Disposition": f'attachment; filename="stride-{kind}.csv"', "Cache-Control": "private, no-store", }, ) @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"] pagination = activity_page( [Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends"] ) return render_template( "friends.html", people=people, rows=pagination.items, pagination=pagination ) @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) if user.id == current_user.id: return redirect(url_for("web.dashboard")) filters = [Activity.owner_id == user.id, Activity.visibility == "friends"] stats = overview(filters, user.timezone) pagination = activity_page(filters, total=stats["count"]) return render_template( "dashboard.html", rows=pagination.items, pagination=pagination, stats=stats, profile=user, personal=False, mapped_run=latest_mapped_run(filters), record_book=records_for(user.id, {row.id for row in pagination.items}, shared_only=True), pace_text=pace_text, ) @web.get("/activities/") def activity(activity_id): row, owner = get_activity(activity_id) analysis = stored_analysis(row) efforts = [] if owner: efforts = records_for(row.owner_id, {row.id})["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.get("/activities//route") def route(activity_id): row, _ = get_activity(activity_id) return jsonify(route_data(row)) @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=stored_analysis(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"))