Files
Stride/app/routes.py
T

487 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 .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,
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/<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,
mapped_run=latest_mapped_run(rows),
)
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
analysis = analyse(row)
return render_template(
"activity.html", activity=row, owner_view=owner, analysis=analysis, pace_text=pace_text
)
@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>/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):
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"}:
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"))