feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
+375
@@ -0,0 +1,375 @@
|
||||
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 .extensions import db, limiter
|
||||
from .models import Activity, Friendship, 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 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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@web.get("/activities/<int:activity_id>")
|
||||
def activity(activity_id):
|
||||
row, owner = get_activity(activity_id)
|
||||
return render_template("activity.html", activity=row, owner_view=owner)
|
||||
|
||||
|
||||
@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>/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"))
|
||||
return render_template("settings.html")
|
||||
|
||||
|
||||
@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"))
|
||||
Reference in New Issue
Block a user