641 lines
22 KiB
Python
641 lines
22 KiB
Python
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, SavedAnalysis, 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
|
||
from .stats_expressions import FUNCTIONS as METRIC_FUNCTIONS
|
||
|
||
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)
|
||
session.permanent = True
|
||
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)
|
||
session.permanent = True
|
||
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,
|
||
analyses=db.session.scalars(
|
||
db.select(SavedAnalysis)
|
||
.filter_by(owner_id=current_user.id, pinned=True)
|
||
.order_by(SavedAnalysis.position, SavedAnalysis.id)
|
||
).all(),
|
||
)
|
||
|
||
|
||
@web.get("/stats")
|
||
@login_required
|
||
def stats():
|
||
from .analysis_library import owned, serialize
|
||
from .stats_presets import PRESETS
|
||
|
||
saved = None
|
||
if request.args.get("analysis"):
|
||
try:
|
||
analysis_id = int(request.args["analysis"])
|
||
except ValueError:
|
||
abort(404)
|
||
saved = owned(analysis_id)
|
||
args = dict(request.args)
|
||
if saved and "query" not in args:
|
||
args["query"] = saved.query
|
||
error = None
|
||
results = None
|
||
try:
|
||
query = parse_query(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=args.get("query", ""),
|
||
selected_analysis=serialize(saved) if saved else None,
|
||
presets=PRESETS,
|
||
language={
|
||
"functions": sorted(FUNCTIONS),
|
||
"metric_functions": METRIC_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/<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)
|
||
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/<int:activity_id>")
|
||
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/<int:activity_id>/data")
|
||
def data(activity_id):
|
||
row, owner = get_activity(activity_id)
|
||
return jsonify(activity_data(row, owner))
|
||
|
||
|
||
@web.get("/activities/<int:activity_id>/route")
|
||
def route(activity_id):
|
||
row, _ = get_activity(activity_id)
|
||
return jsonify(route_data(row))
|
||
|
||
|
||
@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=stored_analysis(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):
|
||
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/<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", "health"}:
|
||
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)
|
||
elif action == "health":
|
||
connection.health_enabled = request.form.get("enabled") == "yes"
|
||
connection.health_message = "Daily recovery sync queued." if connection.health_enabled else "Daily recovery sync is off."
|
||
if connection.health_enabled:
|
||
connection.pending = True
|
||
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"))
|