501 lines
17 KiB
Python
501 lines
17 KiB
Python
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 pace_text
|
||
from .display import stored_analysis
|
||
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, route_data
|
||
|
||
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 row.has_route),
|
||
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/<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 = stored_analysis(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/<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"}:
|
||
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"))
|