Deploy Stride with encrypted manual and scheduled Garmin sync
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.venv
|
||||
instance
|
||||
data
|
||||
tests
|
||||
.env*
|
||||
__pycache__
|
||||
*.db
|
||||
*.fit
|
||||
*.json
|
||||
!app.json
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.10.6 AS uv
|
||||
FROM python:3.12-slim
|
||||
COPY --from=uv /uv /usr/local/bin/uv
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH UV_COMPILE_BYTECODE=1
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --locked --no-dev --no-install-project
|
||||
COPY app ./app
|
||||
COPY garmin_sync ./garmin_sync
|
||||
COPY migrations ./migrations
|
||||
COPY Procfile app.json ./
|
||||
RUN useradd --uid 1000 --create-home stride && mkdir /storage && chown stride:stride /storage
|
||||
USER stride
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:create_app()"]
|
||||
@@ -0,0 +1,2 @@
|
||||
web: gunicorn --bind 0.0.0.0:8000 --workers 2 --timeout 120 'app:create_app()'
|
||||
worker: flask --app app sync-worker
|
||||
@@ -27,7 +27,7 @@ account exists. Subsequent accounts start empty.
|
||||
The local app uses SQLite in `instance/dashboard.db`. Set `DATABASE_URL` to a
|
||||
PostgreSQL connection string to use PostgreSQL. Database changes use Alembic
|
||||
migrations. Local schema creation/upgrade has been verified on SQLite; PostgreSQL
|
||||
runtime validation remains pending because Docker was unavailable locally.
|
||||
runtime validation is recorded in docs/DEPLOYMENT.md.
|
||||
|
||||
### Accounts and sharing
|
||||
|
||||
@@ -38,7 +38,12 @@ runtime validation remains pending because Docker was unavailable locally.
|
||||
the in-app request. No invitation email is sent by this local version.
|
||||
- Accepted friends see each other's shared activities and full recorded routes.
|
||||
- Removing a friend immediately revokes access to shared profiles, pages and JSON.
|
||||
- There is no Explore page, public feed, or anonymous access to activity data.
|
||||
- Use the link icon on your run page to copy a public link. Anyone with that link
|
||||
can view the run, full route and charts without signing in. Public links are
|
||||
separate from friend visibility; disable a link in the run's sharing settings
|
||||
to revoke access. There is no Explore page or public feed.
|
||||
- Share links use the address you opened the app with. A localhost link only works
|
||||
on your own computer; sharing with others requires a reachable app address.
|
||||
- Routes are not masked or trimmed. Access control, rather than geographic masking,
|
||||
determines who can see the full route, including start and finish.
|
||||
|
||||
@@ -58,7 +63,7 @@ uv run --locked flask --app app import-local --email owner@example.com --account
|
||||
The application uses CSRF protection, hashed passwords, login throttling, and
|
||||
server-side authorization on every activity page and data endpoint. Before public
|
||||
deployment, finish email verification/recovery, shared production rate-limit
|
||||
storage, encrypted token uploads, scheduled jobs and PostgreSQL/Dokku validation.
|
||||
storage, and account recovery. Deployment instructions and sync behavior are in docs/DEPLOYMENT.md.
|
||||
The current server binds only to loopback and runs without debug mode.
|
||||
|
||||
Users will upload FIT files or connect Garmin by authenticating locally with
|
||||
@@ -134,8 +139,7 @@ operations, but possession of its token file provides broader account access.
|
||||
|
||||
Local credential storage is plain JSON with restricted permissions on POSIX; on
|
||||
Windows it relies on your directory ACLs. Use a private Windows user directory or
|
||||
restrict this workspace's ACLs on a shared machine. Encrypted database credential
|
||||
storage belongs to the future web implementation, not this local proof of concept.
|
||||
restrict this workspace's ACLs on a shared machine. Uploaded web sessions are encrypted in PostgreSQL using TOKEN_ENCRYPTION_KEY.
|
||||
No credentials or real activity files should be committed to Git.
|
||||
|
||||
## Verification
|
||||
@@ -151,10 +155,16 @@ A live verification requires running login, syncing at least one activity, then
|
||||
running sync again to confirm it skips the previously downloaded files. Record
|
||||
the actual outcome in `docs/SYNC_VALIDATION.md`.
|
||||
|
||||
Current scope: local Flask app and sync CLI. Scheduled worker, token-upload web
|
||||
endpoint, email verification/recovery, and Dokku deployment remain to be implemented.
|
||||
The web app supports encrypted Garmin session uploads, manual queued sync, hourly scheduled sync, and a Dokku worker. Email verification/recovery remains unimplemented.
|
||||
|
||||
For an isolated browser test with synthetic data, run
|
||||
`uv run python scripts/preview-fixture.py` and visit port 5001. This test process
|
||||
uses an in-memory database and auto-login, never reads Garmin files, and must not
|
||||
be deployed. The regular app does not have auto-login or seeded test accounts.
|
||||
|
||||
## Server sync
|
||||
|
||||
Under Settings & imports, upload your exported Garmin session JSON to connect.
|
||||
Use **Sync now** for manual sync; hourly sync is enabled for new connections.
|
||||
Both use a separate persistent worker and import the latest 100 activities privately.
|
||||
Pause hourly sync or disconnect at any time. See [deployment notes](docs/DEPLOYMENT.md).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"scripts": {"dokku": {"predeploy": "flask --app app db upgrade"}},
|
||||
"formation": {"web": {"quantity": 1}, "worker": {"quantity": 1}},
|
||||
"healthchecks": {"web": [{"type": "startup", "name": "database", "description": "Application and PostgreSQL", "path": "/health", "attempts": 10, "wait": 5, "timeout": 5}]}
|
||||
}
|
||||
+14
-3
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import click
|
||||
from flask import Flask, render_template
|
||||
from flask import Flask, render_template, request
|
||||
|
||||
from .extensions import csrf, db, limiter, login_manager, migrate
|
||||
|
||||
@@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
app = Flask(__name__, instance_path=str(ROOT / "instance"))
|
||||
app = Flask(__name__, instance_path=os.environ.get("INSTANCE_PATH", str(ROOT / "instance")))
|
||||
production = os.environ.get("APP_ENV") == "production"
|
||||
app.config.from_mapping(
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///dashboard.db"),
|
||||
@@ -24,10 +24,15 @@ def create_app(config=None):
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
|
||||
MAX_CONTENT_LENGTH=16 * 1024 * 1024,
|
||||
LOCAL_SETUP=not production,
|
||||
DATA_ROOT=str(ROOT / "data" / "garmin"),
|
||||
DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")),
|
||||
TOKEN_ENCRYPTION_KEY=os.environ.get("TOKEN_ENCRYPTION_KEY"),
|
||||
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
|
||||
)
|
||||
app.config.update(config or {})
|
||||
if production:
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||
if production and (not app.config["SECRET_KEY"] or not os.environ.get("DATABASE_URL")):
|
||||
raise RuntimeError("Production requires SECRET_KEY and DATABASE_URL.")
|
||||
if not app.config["SECRET_KEY"]:
|
||||
@@ -59,6 +64,9 @@ def create_app(config=None):
|
||||
from .routes import web
|
||||
|
||||
app.register_blueprint(web)
|
||||
from .sync import register_cli
|
||||
|
||||
register_cli(app)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
@@ -84,6 +92,9 @@ def create_app(config=None):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
if request.path.startswith("/shared/runs/"):
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
return response
|
||||
|
||||
for code in (400, 403, 404, 413, 429):
|
||||
|
||||
@@ -27,6 +27,19 @@ class User(UserMixin, db.Model):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
class GarminConnection(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), unique=True, nullable=False)
|
||||
account_key = db.Column(db.String(24), unique=True, nullable=False)
|
||||
tokens = db.Column(db.Text, nullable=False)
|
||||
pending = db.Column(db.Boolean, nullable=False, default=True)
|
||||
scheduled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
next_sync = db.Column(db.DateTime(timezone=True), nullable=False)
|
||||
last_success = db.Column(db.DateTime(timezone=True))
|
||||
message = db.Column(db.String(200), nullable=False, default="Sync queued.")
|
||||
failures = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class Activity(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
@@ -40,6 +53,7 @@ class Activity(db.Model):
|
||||
avg_hr = db.Column(db.Float)
|
||||
metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
|
||||
visibility = db.Column(db.String(12), nullable=False, default="private")
|
||||
share_token = db.Column(db.String(64), unique=True)
|
||||
samples = db.Column(db.JSON, nullable=False, default=list)
|
||||
laps = db.Column(db.JSON, nullable=False, default=list)
|
||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
+109
-2
@@ -26,7 +26,7 @@ from garmin_sync.service import SyncError, atomic_write
|
||||
|
||||
from .analytics import analyse, pace_text
|
||||
from .extensions import db, limiter
|
||||
from .models import Activity, Friendship, User
|
||||
from .models import Activity, Friendship, GarminConnection, User
|
||||
from .services import activity_data, import_cache, import_upload
|
||||
|
||||
web = Blueprint("web", __name__)
|
||||
@@ -73,6 +73,17 @@ def are_friends(first, second):
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -208,6 +219,7 @@ def dashboard():
|
||||
stats=overview(rows, current_user.timezone),
|
||||
profile=current_user,
|
||||
personal=True,
|
||||
mapped_run=latest_mapped_run(rows),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,6 +302,7 @@ def profile(username):
|
||||
stats=overview(rows, user.timezone),
|
||||
profile=user,
|
||||
personal=False,
|
||||
mapped_run=latest_mapped_run(rows),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,6 +321,52 @@ def data(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):
|
||||
@@ -334,7 +393,55 @@ def settings():
|
||||
db.session.commit()
|
||||
flash("Profile saved.")
|
||||
return redirect(url_for("web.settings"))
|
||||
return render_template("settings.html")
|
||||
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")
|
||||
|
||||
+37
-4
@@ -1,4 +1,36 @@
|
||||
"use strict";
|
||||
const shareButton = document.getElementById("copy-run-link");
|
||||
if (shareButton) shareButton.addEventListener("click", async () => {
|
||||
const status = document.getElementById("share-status");
|
||||
const fallback = document.getElementById("share-fallback");
|
||||
const input = document.getElementById("share-link-value");
|
||||
shareButton.disabled = true;
|
||||
fallback.hidden = true;
|
||||
status.textContent = "Creating share link…";
|
||||
try {
|
||||
const response = await fetch(shareButton.dataset.url, {
|
||||
method: "POST", headers: {"X-CSRFToken": shareButton.dataset.csrf},
|
||||
});
|
||||
if (!response.ok || response.redirected) throw new Error("Share unavailable");
|
||||
const data = await response.json();
|
||||
input.value = data.url;
|
||||
document.getElementById("revoke-share").hidden = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.url);
|
||||
status.textContent = "Link copied! Anyone with the link can view this run.";
|
||||
} catch {
|
||||
// Clipboard permissions and non-HTTPS hosts can prevent automatic copying.
|
||||
fallback.hidden = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
status.textContent = "Link ready. Copy it below to share this run without sign-in.";
|
||||
}
|
||||
} catch {
|
||||
status.textContent = "Couldn’t create a share link. Please try again.";
|
||||
} finally {
|
||||
shareButton.disabled = false;
|
||||
}
|
||||
});
|
||||
const valid = v => typeof v === "number" && Number.isFinite(v);
|
||||
const paceLabel = v => {const s = Math.round(v); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;};
|
||||
async function showActivity() {
|
||||
@@ -67,6 +99,7 @@ async function showActivity() {
|
||||
document.getElementById("fit-route").disabled = true;
|
||||
}
|
||||
if (!window.Chart) return;
|
||||
Chart.defaults.color = "#a0aaac";
|
||||
const line = (label,key,colour,unit,axis="y") => ({label,key,borderColor:colour,unit,yAxisID:axis});
|
||||
const chart = (id, series, reverse=false) => {
|
||||
const canvas = document.getElementById(id);
|
||||
@@ -74,12 +107,12 @@ async function showActivity() {
|
||||
const message=document.createElement("p"); message.className="chart-empty";message.textContent="Not recorded by your watch for this activity.";canvas.replaceWith(message);return;
|
||||
}
|
||||
const datasets = series.map(s=>({...s,data:data.chart.filter(r=>valid(r.distance)).map(r=>({x:r.distance/1000,y:valid(r[s.key])?r[s.key]:null})),borderWidth:1.6,pointRadius:0,pointHitRadius:8,tension:.15,spanGaps:false}));
|
||||
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#edf0e9",drawOnChartArea:!right},border:{display:false},ticks:{color:"#7d847b",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
|
||||
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#7d847b",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
|
||||
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#30383b",drawOnChartArea:!right},border:{display:false},ticks:{color:"#a0aaac",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
|
||||
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#a0aaac",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
|
||||
};
|
||||
chart("pace-chart",[line("Pace","pace","#38795b","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
|
||||
chart("pace-chart",[line("Pace","pace","#ff9268","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
|
||||
chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]);
|
||||
chart("elevation-chart",[line("Elevation","elevation","#668b47","m"),line("Gradient","grade","#ba965a","%","y2")]);
|
||||
chart("elevation-chart",[line("Elevation","elevation","#a6c896","m"),line("Gradient","grade","#ba965a","%","y2")]);
|
||||
chart("cadence-chart",[line("Cadence","cadence","#5a83ab","spm"),line("Step length","stride_length","#9b83b3","m","y2")]);
|
||||
chart("power-chart",[line("Power","power","#b48a38","W")]);
|
||||
chart("dynamics-chart",[line("Vertical oscillation","vertical_oscillation","#699f96","cm"),line("Ground contact","ground_contact","#a57b9c","ms","y2")]);
|
||||
|
||||
+65
-2
@@ -4,7 +4,70 @@ if (volume && window.Chart) {
|
||||
const data = JSON.parse(volume.textContent);
|
||||
new Chart(document.getElementById("volume-chart"), {
|
||||
type: "bar",
|
||||
data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#294f3a" : "#dbe6bf"), hoverBackgroundColor: "#87a364", borderRadius: 5, maxBarThickness: 34}]},
|
||||
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#f0f2eb"}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}}}
|
||||
data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#ff9268" : "#586367"), hoverBackgroundColor: "#ffb08b", borderRadius: 2, maxBarThickness: 34}]},
|
||||
options: {animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#a0aaac"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#30383b"}, ticks: {font: {size: 10}, color: "#a0aaac", callback: v => `${v} km`}}}}
|
||||
});
|
||||
}
|
||||
|
||||
async function showDashboardRoute() {
|
||||
const container = document.getElementById("dashboard-route-map");
|
||||
if (!container?.dataset.url) return;
|
||||
const status = document.getElementById("dashboard-route-status");
|
||||
const toggle = document.getElementById("toggle-route-replay");
|
||||
try {
|
||||
const response = await fetch(container.dataset.url);
|
||||
if (!response.ok) throw new Error("Route unavailable");
|
||||
const data = await response.json();
|
||||
const segments = data.segments.filter(segment => segment.length);
|
||||
if (!segments.length) { status.textContent = "No GPS route recorded for this run."; return; }
|
||||
const map = L.map(container, {scrollWheelZoom: false});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
const route = L.polyline(segments, {color: "#ff9268", weight: 4, opacity: .85}).addTo(map);
|
||||
const fit = () => map.fitBounds(route.getBounds(), {padding: [28, 28], maxZoom: 16});
|
||||
fit();
|
||||
new ResizeObserver(() => { map.invalidateSize(); fit(); }).observe(container);
|
||||
const first = segments[0][0], last = segments.at(-1).at(-1);
|
||||
L.circleMarker(first, {radius: 6, color: "#fff", fillColor: "#70b992", fillOpacity: 1}).bindTooltip("Start").addTo(map);
|
||||
L.circleMarker(last, {radius: 6, color: "#fff", fillColor: "#ff9268", fillOpacity: 1}).bindTooltip("Finish").addTo(map);
|
||||
const runner = L.marker(first, {interactive: false, icon: L.divIcon({
|
||||
className: "dashboard-runner", html: '<span></span>', iconSize: [20,20], iconAnchor: [10,10],
|
||||
})}).addTo(map);
|
||||
// Measure each recorded segment separately so GPS gaps are never drawn across.
|
||||
let length = 0;
|
||||
const edges = [];
|
||||
for (const segment of segments) for (let i = 1; i < segment.length; i++) {
|
||||
const a = L.latLng(segment[i-1]), b = L.latLng(segment[i]), distance = a.distanceTo(b);
|
||||
if (distance > 0) { edges.push({a, b, start: length, end: length + distance}); length += distance; }
|
||||
}
|
||||
if (!length) { status.textContent = "Recorded GPS location. No movement to replay."; return; }
|
||||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let paused = preference.matches, elapsed = 0, previous = null, frame = null;
|
||||
const tick = now => {
|
||||
if (paused || document.hidden) { previous = null; frame = null; return; }
|
||||
if (previous !== null) elapsed += now - previous;
|
||||
previous = now;
|
||||
const distance = ((elapsed % 20000) / 20000) * length;
|
||||
const edge = edges.find(edge => edge.end >= distance) || edges.at(-1);
|
||||
const fraction = (distance - edge.start) / (edge.end - edge.start);
|
||||
runner.setLatLng([edge.a.lat + (edge.b.lat-edge.a.lat)*fraction, edge.a.lng + (edge.b.lng-edge.a.lng)*fraction]);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const update = () => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = null; previous = null;
|
||||
toggle.textContent = paused ? "Play replay" : "Pause replay";
|
||||
status.textContent = paused ? "Replay paused. Explore the recorded route." : "Following the recorded route · 20-second replay";
|
||||
if (!paused && !document.hidden) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
toggle.hidden = false;
|
||||
toggle.onclick = () => { paused = !paused; update(); };
|
||||
preference.addEventListener("change", event => { paused = event.matches; update(); });
|
||||
document.addEventListener("visibilitychange", update);
|
||||
update();
|
||||
} catch {
|
||||
status.textContent = "Couldn’t load the route. Refresh to try again.";
|
||||
}
|
||||
}
|
||||
showDashboardRoute();
|
||||
|
||||
+19
-2
File diff suppressed because one or more lines are too long
+170
@@ -0,0 +1,170 @@
|
||||
"""Durable sync requests; shared filesystem locks serialize each user's session."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from cryptography.fernet import Fernet
|
||||
from filelock import FileLock, Timeout
|
||||
from flask import current_app
|
||||
from garminconnect import Garmin, GarminConnectAuthenticationError
|
||||
|
||||
from garmin_sync.service import SyncError, account_key, private_directory, sync_activities
|
||||
|
||||
from .extensions import db
|
||||
from .models import GarminConnection, User
|
||||
from .services import import_cache
|
||||
|
||||
|
||||
def cipher():
|
||||
key = current_app.config.get("TOKEN_ENCRYPTION_KEY")
|
||||
if not key:
|
||||
raise SyncError("Garmin connections are not configured on this server.")
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def session_lock(user_id):
|
||||
directory = Path(current_app.instance_path) / "sync-locks"
|
||||
private_directory(directory)
|
||||
return FileLock(str(directory / f"{user_id}.lock"), timeout=0)
|
||||
|
||||
|
||||
def connect(user, payload):
|
||||
if len(payload) > 65536:
|
||||
raise SyncError("Token file must be smaller than 64 KB.")
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
keys = ("di_token", "di_refresh_token", "di_client_id")
|
||||
if not isinstance(data, dict) or not all(
|
||||
isinstance(data.get(k), str) and data[k] for k in keys
|
||||
):
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise SyncError("Export a session with garminconnect 0.3.17.") from None
|
||||
encryption = cipher()
|
||||
with session_lock(user.id):
|
||||
api = Garmin()
|
||||
api.login(json.dumps({k: data[k] for k in keys}))
|
||||
key = account_key(api, "garmin.com")
|
||||
other = db.session.scalar(db.select(User).filter_by(local_account_key=key))
|
||||
if other and other.id != user.id:
|
||||
raise SyncError("This Garmin account is already connected.")
|
||||
connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=user.id))
|
||||
if connection and connection.account_key != key:
|
||||
raise SyncError("Disconnect the existing Garmin account before replacing it.")
|
||||
if not connection:
|
||||
connection = GarminConnection(user_id=user.id, account_key=key)
|
||||
db.session.add(connection)
|
||||
connection.tokens = encryption.encrypt(api.client.dumps().encode()).decode()
|
||||
connection.pending = True
|
||||
connection.next_sync = datetime.now(UTC)
|
||||
connection.failures = 0
|
||||
connection.message = "Session connected. Sync queued."
|
||||
user.local_account_key = key
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def run_connection(connection_id):
|
||||
connection = db.session.get(GarminConnection, connection_id)
|
||||
if not connection:
|
||||
return False
|
||||
try:
|
||||
with session_lock(connection.user_id):
|
||||
db.session.refresh(connection)
|
||||
now = datetime.now(UTC)
|
||||
due = connection.next_sync.replace(tzinfo=UTC) <= now
|
||||
if not connection.pending and not (connection.scheduled and due):
|
||||
return False
|
||||
api = None
|
||||
try:
|
||||
api = Garmin()
|
||||
api.login(cipher().decrypt(connection.tokens.encode()).decode())
|
||||
if account_key(api, "garmin.com") != connection.account_key:
|
||||
raise GarminConnectAuthenticationError("Account mismatch")
|
||||
# Persist rotated sessions even if the subsequent download fails.
|
||||
connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode()
|
||||
db.session.commit()
|
||||
try:
|
||||
result = sync_activities(
|
||||
api,
|
||||
Path(current_app.config["DATA_ROOT"]) / connection.account_key,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
finally:
|
||||
connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode()
|
||||
db.session.commit()
|
||||
user = db.session.get(User, connection.user_id)
|
||||
count = import_cache(user, current_app.config["DATA_ROOT"])
|
||||
if result["failed"]:
|
||||
raise SyncError("Some activities failed; sync will retry.")
|
||||
connection.last_success = now
|
||||
connection.failures = 0
|
||||
connection.message = f"Sync complete. {count} new activities imported."
|
||||
connection.next_sync = now + timedelta(hours=1)
|
||||
except Exception as error: # noqa: BLE001 -- do not expose provider credentials
|
||||
db.session.rollback()
|
||||
connection = db.session.get(GarminConnection, connection_id)
|
||||
connection.failures += 1
|
||||
connection.pending = False
|
||||
if isinstance(error, GarminConnectAuthenticationError):
|
||||
connection.scheduled = False
|
||||
connection.message = (
|
||||
"Session expired. Upload a fresh Garmin session to reconnect."
|
||||
)
|
||||
else:
|
||||
connection.message = (
|
||||
"Sync failed. Retry later or reconnect your Garmin session."
|
||||
)
|
||||
connection.next_sync = now + timedelta(
|
||||
minutes=min(360, 5 * 2 ** min(connection.failures, 6))
|
||||
)
|
||||
connection.pending = False
|
||||
db.session.commit()
|
||||
return True
|
||||
except Timeout:
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def run_due():
|
||||
ids = list(
|
||||
db.session.scalars(
|
||||
db.select(GarminConnection.id).where(
|
||||
db.or_(
|
||||
GarminConnection.pending.is_(True),
|
||||
db.and_(
|
||||
GarminConnection.scheduled.is_(True),
|
||||
GarminConnection.next_sync <= datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
for connection_id in ids:
|
||||
run_connection(connection_id)
|
||||
return len(ids)
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("sync-worker")
|
||||
@click.option("--once", is_flag=True, help="Process currently due syncs and exit.")
|
||||
def worker(once):
|
||||
logging.getLogger("garminconnect").disabled = True
|
||||
while True:
|
||||
try:
|
||||
count = run_due()
|
||||
click.echo(f"Sync worker heartbeat: {count} due connections.")
|
||||
except Exception: # noqa: BLE001 -- suppress provider credentials at boundary
|
||||
db.session.rollback()
|
||||
click.echo("Sync worker iteration failed; retrying without logging credentials.")
|
||||
if once:
|
||||
raise click.ClickException("Sync worker failed.") from None
|
||||
finally:
|
||||
db.session.remove()
|
||||
if once:
|
||||
break
|
||||
time.sleep(30)
|
||||
@@ -1,5 +1,5 @@
|
||||
{% if rows %}
|
||||
<div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>ACTIVITY</th><th>DISTANCE</th><th>TIME</th><th>AVG. PACE</th><th>{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}</th><th><span class="sr-only">Open</span></th></tr></thead><tbody>
|
||||
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">↗</span><span><strong>{{ row.title }}</strong><small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">↗</a></td></tr>{% endfor %}
|
||||
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">{{ icon('run') }}</span><span><strong>{{ row.title }}</strong><small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">{{ icon('arrow') }}</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>Your next chapter awaits.</h3><p>{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run ↗</a>{% endif %}</div>{% endif %}
|
||||
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>No activities yet.</h3><p>{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run {{ icon('arrow') }}</a>{% endif %}</div>{% endif %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% macro icon(name) -%}
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{% if name == 'overview' %}<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>{% elif name == 'profile' %}<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>{% elif name == 'friends' %}<circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M16 5a3 3 0 0 1 0 6m2 4a5 5 0 0 1 3 5"/>{% elif name == 'settings' %}<path d="M4 7h16M4 17h16"/><circle cx="8" cy="7" r="3"/><circle cx="16" cy="17" r="3"/>{% elif name == 'clock' %}<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>{% elif name == 'route' %}<circle cx="5" cy="18" r="2"/><circle cx="19" cy="6" r="2"/><path d="M5 16V7a3 3 0 0 1 6 0v10a3 3 0 0 0 6 0V6"/>{% elif name == 'run' %}<circle cx="15" cy="4" r="2"/><path d="m5 9 5-2 4 4 5 1M12 9l-3 6 5 3v4M9 15l-3 5H2"/>{% elif name == 'plus' %}<path d="M12 5v14M5 12h14"/>{% elif name == 'upload' %}<path d="M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6"/>{% else %}<path d="M4 12h16m-6-6 6 6-6 6"/>{% endif %}</svg>
|
||||
{%- endmacro %}
|
||||
{% macro running_map(compact=false) -%}
|
||||
<div class="running-map {{ 'compact-map' if compact }}">
|
||||
<svg viewBox="0 0 760 320" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Illustrated running route through a city park">
|
||||
<rect width="760" height="320" fill="#171d20"/>
|
||||
<g fill="#202d29" stroke="#2b3b33" stroke-width="1"><path d="m260 45 170-20 62 88-36 93-173-11-52-63Z"/><path d="m545 202 140-22 57 120H548Z"/></g>
|
||||
<path d="M-20 270C110 190 112 342 278 268S480 259 560 123 711 81 800 8" fill="none" stroke="#253943" stroke-width="35"/>
|
||||
<g fill="none" stroke="#30383b" stroke-width="9"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305M290-20 290 30M430-20 430 24M332 210V340"/></g>
|
||||
<g fill="none" stroke="#41484a" stroke-width="1"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305"/></g>
|
||||
<g fill="none" stroke="#2b3336" stroke-width="3"><path d="M95 0v320M0 103h215M0 255h165M590 0v320M730 0v320M495 105h265M540 265h220M215 0v100M377 228v92"/></g>
|
||||
<g font-family="monospace" font-size="9" letter-spacing="2" fill="#788682"><text x="313" y="114">NORTH PARK</text><text x="567" y="44">PARK AVENUE</text><text x="72" y="205" transform="rotate(-90 72 205)">RIVER STREET</text><text x="565" y="290">RIVERSIDE</text></g>
|
||||
<path class="map-route-glow" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
|
||||
<path class="map-route" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
|
||||
<circle cx="166" cy="215" r="6" fill="#171d20" stroke="#ff8659" stroke-width="3"/>
|
||||
<g class="map-runner"><circle r="15" fill="#ff8659" opacity=".16"/><circle r="6" fill="#fff0e6" stroke="#ff8659" stroke-width="3"/></g>
|
||||
<g transform="translate(713 28)" fill="none" stroke="#94a19e"><path d="M0 24V0m-5 8 5-8 5 8"/><text x="-3" y="-8" fill="#94a19e" stroke="none" font-size="9">N</text></g>
|
||||
</svg>
|
||||
<div class="map-caption"><span><i></i> RUN THE CITY <small>Illustrated route</small></span></div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
@@ -6,8 +6,9 @@
|
||||
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
|
||||
{% endmacro %}
|
||||
{% block content %}
|
||||
<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a><span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
|
||||
{% if not public_view %}<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>{% endif %}
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By {% if public_view %}{{ activity.owner.display_name }}{% else %}<a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a>{% endif %}<span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><div class="activity-actions"><span class="pill {{ 'public' if public_view else activity.visibility }}">{{ 'Shared via link' if public_view else activity.visibility|title }}</span>{% if owner_view %}<button type="button" class="button secondary copy-link-button" id="copy-run-link" aria-label="Copy public link to this run" title="Copy public link — anyone with the link can view this run" data-url="{{ url_for('web.share_activity', activity_id=activity.id) }}" data-csrf="{{ csrf_token() }}"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m10 13 4-4m-6 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 2 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0"/></svg></button>{% endif %}</div></div>
|
||||
{% if owner_view %}<div class="share-feedback"><p id="share-status" role="status" aria-live="polite"></p><label id="share-fallback" hidden>Copy this public link<input id="share-link-value" readonly aria-label="Public run link"></label></div>{% endif %}
|
||||
<nav class="activity-nav" aria-label="Activity sections"><a href="#route-section">Route & speed</a><a href="#performance">Performance</a><a href="#terrain">Terrain & effort</a><a href="#splits">Laps & splits</a><a href="#method">About the numbers</a></nav>
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div><p>Recorded by your watch</p></article>
|
||||
@@ -17,7 +18,7 @@
|
||||
</div>
|
||||
<section class="panel map-panel run-map" id="route-section">
|
||||
<div class="section-head"><div><span class="eyebrow">FOLLOW YOUR FOOTSTEPS</span><h2>Your run, in colour.</h2></div><div class="map-controls"><label>Colour by <select id="map-metric"><option value="speed">Speed</option><option value="heart_rate">Heart rate</option><option value="grade">Gradient</option></select></label><label class="directions-toggle"><input type="checkbox" id="show-directions" checked> Direction arrows</label><button type="button" class="button secondary" id="fit-route">Fit route</button></div></div>
|
||||
<div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
|
||||
<div id="route-map" data-url="{{ data_url or url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
|
||||
<div class="route-legend"><span id="legend-low">Slower</span><div class="legend-ramp" id="legend-ramp"></div><span id="legend-high">Faster</span><span class="legend-note">S Start · F Finish · Grey = no measurement</span></div>
|
||||
<p id="map-status" class="map-status" aria-live="polite"></p>
|
||||
</section>
|
||||
@@ -42,7 +43,7 @@
|
||||
</section>
|
||||
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
|
||||
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
|
||||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity in Stride?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.</p><button class="button primary">Save changes {{ icon('arrow') }}</button></form><form id="revoke-share" method="post" action="{{ url_for('web.revoke_activity_share',activity_id=activity.id) }}" {{ 'hidden' if not activity.share_token }}><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disable public link</button></form></section>{% endif %}</div>
|
||||
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.</p></div></details>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block breadcrumb %}{{ 'WELCOME' if mode == 'setup' else 'ACCOUNT' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-layout">
|
||||
<section class="auth-intro"><span class="eyebrow">THE NEXT CHAPTER STARTS HERE</span><h1>Your miles.<br>Your story.<br><em>Your space.</em></h1><p>A home for the runs, the quiet progress,<br>and everything you’re working towards.</p><div class="auth-art" aria-hidden="true"><svg viewBox="0 0 460 230"><path d="M-20 185 C90 50 100 275 235 90 S365 170 490 20"/><path d="M-20 209 C90 74 100 299 235 114 S365 194 490 44"/><circle cx="235" cy="90" r="8"/></svg></div></section>
|
||||
<section class="auth-intro"><span class="eyebrow">MADE FOR THE LONG RUN</span><h1>Run. Record.<br><em>Repeat.</em></h1><p>Every route. Every split. Your progress.<br>A home for all the miles you put in.</p><ul class="landing-features"><li>{{ icon('route') }} Replay your routes</li><li>{{ icon('overview') }} See your progress</li><li>{{ icon('friends') }} Share your runs</li></ul><div class="auth-art landing-map">{{ running_map() }}</div></section>
|
||||
<section class="panel auth-card">
|
||||
<span class="eyebrow">{{ 'FIRST STEPS' if mode == 'setup' else 'WELCOME BACK' if mode == 'login' else 'FIND YOUR STRIDE' }}</span>
|
||||
<h2>{{ 'Make yourself at home.' if mode == 'setup' else 'Good to see you.' if mode == 'login' else 'Create your account.' }}</h2>
|
||||
@@ -17,7 +17,7 @@
|
||||
{% endif %}
|
||||
<label>Email address<input type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com" value="{{ request.form.get('email','') }}"></label>
|
||||
<label>Password<input type="password" name="password" required minlength="{{ 1 if mode == 'login' else 12 }}" maxlength="128" autocomplete="{{ 'current-password' if mode == 'login' else 'new-password' }}" placeholder="{{ 'Your password' if mode == 'login' else 'At least 12 characters' }}"></label>
|
||||
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} <span>↗</span></button>
|
||||
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} {{ icon('arrow') }}</button>
|
||||
</form>
|
||||
<p class="auth-foot">{% if mode == 'login' %}New here? <a href="{{ url_for('web.register') }}">Create an account</a>{% else %}Already have an account? <a href="{{ url_for('web.login') }}">Sign in</a>{% endif %}</p>
|
||||
{% if mode != 'login' %}<small class="muted">This is your Stride password, separate from your Garmin account.</small>{% endif %}
|
||||
|
||||
+24
-12
@@ -1,3 +1,4 @@
|
||||
{% from '_ui.html' import icon, running_map %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -7,36 +8,47 @@
|
||||
<link rel="icon" href="{{ url_for('static', filename='mark.svg') }}" type="image/svg+xml">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body class="{{ 'app-shell' if current_user.is_authenticated else 'public-shell' }}">
|
||||
{% if current_user.is_authenticated %}
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="{{ url_for('web.index') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
|
||||
<div class="sidebar-label">YOUR RUNNING STORY</div>
|
||||
<div class="sidebar-label">TRAINING LOG</div>
|
||||
<nav aria-label="Main navigation">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}"><span>◫</span> Overview</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}"><span>◎</span> Shared profile</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">{{ icon('overview') }} Overview</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}">{{ icon('profile') }} Shared profile</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}"><span>↗</span> Friends</a>{% endif %}
|
||||
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}">{{ icon('friends') }} Friends</a>{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}"><span>⚙</span> Settings & imports</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}">{{ icon('settings') }} Settings & imports</a>
|
||||
{% else %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}"><span>↪</span> Sign in</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}">{{ icon('arrow') }} Sign in</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-note"><span class="tiny-label">A LITTLE FURTHER.</span><p>Every run adds<br>to your story.</p><div class="trail-art" aria-hidden="true"><svg viewBox="0 0 180 95"><path d="M-10 80 C30 30 70 110 90 55 S120 5 195 10"/><path d="M-10 94 C30 44 70 124 90 69 S120 19 195 24"/></svg></div></div>
|
||||
<div class="sidebar-bottom">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Your running space</small></div></div>
|
||||
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out ↗</button></form>
|
||||
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Runner</small></div></div>
|
||||
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out {{ icon('arrow') }}</button></form>
|
||||
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
{% else %}
|
||||
<header class="landing-header">
|
||||
<a class="brand" href="{{ url_for('web.index') }}" aria-label="Stride home"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
|
||||
<nav aria-label="Account navigation">
|
||||
{% if request.endpoint == 'web.login' %}<a class="button secondary" href="{{ url_for('web.register') }}">Create account {{ icon('arrow') }}</a>
|
||||
{% else %}<a class="button secondary" href="{{ url_for('web.login') }}">Sign in {{ icon('arrow') }}</a>{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>
|
||||
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Your data. Your pace.</span></header>
|
||||
{% if current_user.is_authenticated %}
|
||||
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Personal running log</span></header>
|
||||
{% endif %}
|
||||
<div class="page">
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}<div role="status" class="flash {{ category }}">{{ message }}</div>{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="footer"><span>STRIDE · EVERY KILOMETRE COUNTS</span><span>Built for your own path.</span></footer>
|
||||
<footer class="footer"><span>STRIDE / RUNNING LOG</span><span>DISTANCE · TIME · PROGRESS</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
|
||||
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %}
|
||||
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ 'THE BIG PICTURE' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Find your stride.' if personal else profile.display_name }}</h1><p>{{ 'A little perspective on every kilometre you’ve put in.' if personal else 'The runs, the progress, and the miles along the way.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">+ Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Overview' if personal else profile.display_name }}</h1><p>{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
|
||||
<section class="dashboard-route panel" aria-label="Latest running route">
|
||||
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
|
||||
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a><button class="button secondary" id="toggle-route-replay" type="button" hidden>Pause replay</button>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
|
||||
<p class="field-help" id="dashboard-route-status" role="status">{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}</p></div>
|
||||
<div id="dashboard-route-map" {% if mapped_run %}data-url="{{ url_for('web.data',activity_id=mapped_run.id) }}"{% endif %} aria-label="Map of the latest recorded running route"></div>
|
||||
</section>
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED <span>↗</span></div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
|
||||
<article class="stat-card"><div class="stat-label">ACTIVITIES <span>◫</span></div><div class="stat-value">{{ stats.count }}</div><p>One step, then another</p></article>
|
||||
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET <span>◷</span></div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
|
||||
<article class="stat-card"><div class="stat-label">AVERAGE PACE <span>↝</span></div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
|
||||
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED {{ icon('route') }}</div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
|
||||
<article class="stat-card"><div class="stat-label">ACTIVITIES {{ icon('run') }}</div><div class="stat-value">{{ stats.count }}</div><p>Recorded sessions</p></article>
|
||||
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET {{ icon('clock') }}</div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
|
||||
<article class="stat-card"><div class="stat-label">AVERAGE PACE {{ icon('route') }}</div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">CONSISTENCY ADDS UP</span><h2>Your miles, over time.</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
|
||||
<section class="milestone-card"><span class="eyebrow">THE VIEW FROM UP HERE</span><div class="mountain" aria-hidden="true"><svg viewBox="0 0 300 120"><path d="M-20 120 L85 36 L123 76 L177 9 L310 120"/><path d="M10 120 L90 65 L123 101 L177 38 L290 120"/><circle cx="239" cy="28" r="16"/></svg></div><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation earned.</h3><p>Every climb is part of the story.</p></section>
|
||||
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">WEEKLY DISTANCE</span><h2>Training volume</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
|
||||
<section class="milestone-card"><span class="eyebrow">TOTAL ASCENT</span><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation gain</h3><div class="elevation-profile" aria-hidden="true"><svg viewBox="0 0 300 70"><path d="m0 65 30-15 15 8 35-35 20 15 35-32 25 32 20-12 25 28 20-12 30 23 25-10"/></svg></div><p>Across {{ stats.count }} recorded activities</p></section>
|
||||
</div>
|
||||
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">THE WORK YOU’VE PUT IN</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
|
||||
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride ↗</a></div>{% endblock %}
|
||||
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride {{ icon('arrow') }}</a></div>{% endblock %}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">YOUR PEOPLE, YOUR PACE</span><h1>Better with friends.</h1><p>Only accepted friends can see the activities you share.</p></div></div>
|
||||
<div class="settings-grid friends-top">
|
||||
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request ↗</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request {{ icon('arrow') }}</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">YOUR CIRCLE</span><h2>Connections</h2>
|
||||
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} ↗</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
|
||||
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} {{ icon('arrow') }}</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
|
||||
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='accept') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Accept</button></form>{% endif %}
|
||||
<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='remove') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">{{ 'Remove' if entry.link.status == 'accepted' else 'Cancel' if entry.link.requested_by == current_user.id else 'Decline' }}</button></form></div></div>{% else %}<p class="muted">Your circle starts here. Add a friend using the email they registered with.</p>{% endfor %}
|
||||
</section></div>
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
{% block title %}Settings & imports · Stride{% endblock %}
|
||||
{% block breadcrumb %}SETTINGS & IMPORTS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">MAKE IT YOUR OWN</span><h1>Your space, your way.</h1><p>Manage your profile and bring your activities home.</p></div></div>
|
||||
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">THE PERSON BEHIND THE MILES</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile ↗</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">BRING YOUR RUNS WITH YOU</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">↑</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity ↗</button></form></section>
|
||||
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Your local sync</h2><p class="muted">{% if current_user.local_account_key %}Your account is linked to the activities downloaded by your local Garmin sync command.{% else %}You can import FIT files now. Web-based Garmin token connections are coming in the next development stage.{% endif %}</p></div>{% if current_user.local_account_key %}<div class="connection-actions"><span class="pill public">Local imports linked</span><p class="field-help">After running the sync command, load any new downloads here. This button reads local files; it does not contact Garmin.</p><form method="post" action="{{ url_for('web.import_local') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Load new downloads ↻</button></form></div>{% endif %}</section></div>
|
||||
<div class="page-heading"><div><span class="eyebrow">ACCOUNT</span><h1>Settings & imports</h1><p>Manage your profile and bring your activities home.</p></div></div>
|
||||
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">PROFILE</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile {{ icon('arrow') }}</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">ACTIVITY IMPORT</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">{{ icon('upload') }}</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity {{ icon('arrow') }}</button></form></section>
|
||||
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Garmin sync</h2>
|
||||
{% if connection %}
|
||||
<p>{{ connection.message }}</p><p class="field-help">{% if connection.last_success %}Last successful sync: {{ connection.last_success|localdate(current_user.timezone, '%d %b %Y %H:%M') }}.{% endif %} {% if connection.pending %}A sync is queued.{% endif %} Automatic sync is {{ 'on (hourly)' if connection.scheduled else 'off' }}.</p>
|
||||
<form method="post" action="{{ url_for('web.garmin_action', action='sync') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Sync now</button></form>
|
||||
<form method="post" action="{{ url_for('web.garmin_action', action='schedule') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="enabled" value="{{ 'no' if connection.scheduled else 'yes' }}"><button class="button secondary">{{ 'Pause' if connection.scheduled else 'Enable' }} hourly sync</button></form>
|
||||
<form method="post" action="{{ url_for('web.garmin_action', action='disconnect') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disconnect Garmin</button></form>
|
||||
{% endif %}
|
||||
<p class="muted">Connect or reconnect by uploading the session JSON exported by the local Garmin login helper (garminconnect 0.3.17). Sessions are encrypted on the server. New imports are private. Sync checks your latest 100 activities.</p>
|
||||
<form method="post" enctype="multipart/form-data" action="{{ url_for('web.garmin_action', action='connect') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Garmin session<input type="file" name="tokens" accept=".json" required></label><button class="button primary">Connect Garmin</button></form>
|
||||
<p class="field-help">Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.</p>
|
||||
</div></section></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Dokku deployment
|
||||
|
||||
App: `stride`; database: `stride-db`; domain: `stride.peterstockings.com`.
|
||||
The Dockerfile installs locked dependencies on Python 3.12. Dokku runs database
|
||||
migrations in the predeploy step, then one web container and one sync worker.
|
||||
|
||||
Mount `/var/lib/dokku/data/storage/stride:/storage` with ownership 1000:1000 and
|
||||
mode 0700. Set APP_ENV=production, INSTANCE_PATH=/storage/instance,
|
||||
DATA_ROOT=/storage/data, SECRET_KEY (random secret), TOKEN_ENCRYPTION_KEY
|
||||
(Fernet key). Linking PostgreSQL supplies DATABASE_URL. Preserve the encryption
|
||||
key separately from database backups; losing it requires Garmin reconnection.
|
||||
|
||||
Push to Gitea, then deploy with `dokku git:sync --build stride
|
||||
https://gitea.peterstockings.com/peterstockings/Stride.git main` on the server.
|
||||
Enable HTTPS using `dokku letsencrypt:enable stride`.
|
||||
|
||||
## Sync
|
||||
|
||||
Upload a garminconnect 0.3.17 session JSON in Settings & imports. Credentials are
|
||||
validated with Garmin, encrypted, and assigned to the authenticated owner.
|
||||
Manual sync queues durable work; the worker polls every 30 seconds. Automatic
|
||||
sync runs hourly and checks the latest 100 activities, reusing download manifests
|
||||
and unique activity source keys to avoid duplicates. Older activities can be
|
||||
imported with FIT uploads or the operator's local import command.
|
||||
|
||||
Shared storage file locks prevent overlapping jobs and disconnect races. A crash
|
||||
releases the lock; due or pending work is retried after restart. Refresh tokens
|
||||
are persisted before downloading and after download failures. Transient failures
|
||||
back off up to six hours; rejected authentication pauses scheduling until the
|
||||
user reconnects and enables it again. Disconnect removes credentials and keeps
|
||||
previously imported activities. Only garmin.com is supported by the web connector.
|
||||
|
||||
Inspect `dokku logs stride -p worker`, the status in Settings, and `/health`.
|
||||
Run `dokku run stride flask --app app sync-worker --once` for an operator check.
|
||||
Do not run the local sync simultaneously using a copy of the same Garmin session.
|
||||
|
||||
Back up PostgreSQL, `/storage`, and configuration securely. Rate limiting still
|
||||
uses per-process memory; email verification and password recovery are not yet
|
||||
implemented. For a larger public deployment configure shared rate-limit storage.
|
||||
|
||||
## Validation
|
||||
|
||||
Local automated suite: 39 passing tests, including manual/scheduled jobs,
|
||||
encryption, retry/authentication failure, locking, CSRF, and access control.
|
||||
Live deployment and Garmin results will be recorded after verification.
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Add opt-in public activity share links."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c72418a19e30"
|
||||
down_revision = "bdbac89195a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("activity") as batch_op:
|
||||
batch_op.add_column(sa.Column("share_token", sa.String(64), nullable=True))
|
||||
batch_op.create_unique_constraint("uq_activity_share_token", ["share_token"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("activity") as batch_op:
|
||||
batch_op.drop_constraint("uq_activity_share_token", type_="unique")
|
||||
batch_op.drop_column("share_token")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Persistent encrypted Garmin connections and sync requests."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d81b97ae5401"
|
||||
down_revision = "c72418a19e30"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"garmin_connection",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, unique=True),
|
||||
sa.Column("account_key", sa.String(24), nullable=False, unique=True),
|
||||
sa.Column("tokens", sa.Text(), nullable=False),
|
||||
sa.Column("pending", sa.Boolean(), nullable=False),
|
||||
sa.Column("scheduled", sa.Boolean(), nullable=False),
|
||||
sa.Column("next_sync", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_success", sa.DateTime(timezone=True)),
|
||||
sa.Column("message", sa.String(200), nullable=False),
|
||||
sa.Column("failures", sa.Integer(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("garmin_connection")
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"psycopg[binary]>=3.3.6",
|
||||
"waitress>=3.0.2",
|
||||
"gunicorn>=23,<26; sys_platform != 'win32'",
|
||||
"cryptography>=46,<49",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -86,6 +86,27 @@ def test_owner_dashboard_activity_json_and_settings_render(app):
|
||||
assert "point" not in data["chart"][0]
|
||||
|
||||
|
||||
def test_dashboard_replays_latest_accessible_gps_run(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
page = client.get("/dashboard").data
|
||||
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
|
||||
assert b'id="toggle-route-replay"' in page
|
||||
assert b'vendor/leaflet.js' in page
|
||||
assert b'class="dashboard-map"' not in page
|
||||
client.post("/logout")
|
||||
login(client, "other")
|
||||
page = client.get("/dashboard").data
|
||||
assert b'No GPS running route available yet.' in page
|
||||
assert b'/activities/1/data' not in page
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).samples = []
|
||||
db.session.commit()
|
||||
client.post("/logout")
|
||||
login(client)
|
||||
assert b'No GPS running route available yet.' in client.get("/dashboard").data
|
||||
|
||||
|
||||
def test_friend_access_requires_acceptance_and_revokes_immediately(app):
|
||||
owner = app.test_client()
|
||||
friend = app.test_client()
|
||||
@@ -132,6 +153,66 @@ def test_full_route_includes_start_and_finish():
|
||||
assert activity_data(row)["segments"] == [points]
|
||||
|
||||
|
||||
def test_public_run_link_copy_view_revoke_and_recreate(app):
|
||||
owner = app.test_client()
|
||||
anonymous = app.test_client()
|
||||
login(owner)
|
||||
assert b'id="copy-run-link"' in owner.get("/activities/1").data
|
||||
response = owner.post("/activities/1/share")
|
||||
assert response.status_code == 200
|
||||
url = response.json["url"]
|
||||
assert url.startswith("http://localhost/shared/runs/")
|
||||
assert len(url.rsplit("/", 1)[1]) >= 40
|
||||
assert owner.post("/activities/1/share").json["url"] == url
|
||||
page = anonymous.get(url)
|
||||
assert page.status_code == 200
|
||||
assert b"A quiet morning run" in page.data
|
||||
assert b"Shared via link" in page.data
|
||||
assert b'id="copy-run-link"' not in page.data
|
||||
assert b"/activities/1/edit" not in page.data
|
||||
assert b"/u/runner" not in page.data
|
||||
assert (url.removeprefix("http://localhost") + "/data").encode() in page.data
|
||||
assert page.headers["X-Robots-Tag"] == "noindex, nofollow"
|
||||
assert page.headers["Referrer-Policy"] == "no-referrer"
|
||||
data = anonymous.get(url + "/data")
|
||||
assert data.status_code == 200
|
||||
assert data.json["segments"] == [[[-33, 151]]]
|
||||
assert "no-store" in data.headers["Cache-Control"]
|
||||
assert anonymous.get("/activities/1").status_code == 404
|
||||
assert anonymous.get("/activities/1/data").status_code == 404
|
||||
with app.app_context():
|
||||
assert db.session.get(Activity, 1).visibility == "private"
|
||||
assert owner.post("/activities/1/share/revoke").status_code == 302
|
||||
assert anonymous.get(url).status_code == 404
|
||||
assert anonymous.get(url + "/data").status_code == 404
|
||||
assert owner.post("/activities/1/share").json["url"] != url
|
||||
|
||||
|
||||
def test_only_owner_can_create_or_revoke_public_run_links(app):
|
||||
client = app.test_client()
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 302
|
||||
login(client, "other")
|
||||
with app.app_context():
|
||||
db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted"))
|
||||
db.session.get(Activity, 1).visibility = "friends"
|
||||
db.session.commit()
|
||||
assert client.get("/activities/1").status_code == 200
|
||||
assert b'id="copy-run-link"' not in client.get("/activities/1").data
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 404
|
||||
for suffix in ("", "/data"):
|
||||
assert client.get(f"/shared/runs/invalid-token{suffix}").status_code == 404
|
||||
|
||||
|
||||
def test_public_run_link_mutations_require_csrf(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 400
|
||||
|
||||
|
||||
def test_register_creates_separate_user_with_no_local_account(app):
|
||||
client = app.test_client()
|
||||
response = client.post(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import io
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from garminconnect import GarminConnectAuthenticationError
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import GarminConnection, User
|
||||
from app.sync import run_due, session_lock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connected(tmp_path, monkeypatch):
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "testing",
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
"LOCAL_SETUP": False,
|
||||
"TOKEN_ENCRYPTION_KEY": Fernet.generate_key().decode(),
|
||||
"DATA_ROOT": str(tmp_path / "data"),
|
||||
}
|
||||
)
|
||||
app.instance_path = str(tmp_path / "instance")
|
||||
api = Mock(profile_id=123)
|
||||
api.client.dumps.return_value = '{"rotated":"private-session"}'
|
||||
monkeypatch.setattr("app.sync.Garmin", lambda: api)
|
||||
sync = Mock(return_value={"failed": 0})
|
||||
monkeypatch.setattr("app.sync.sync_activities", sync)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = User(username="owner", email="owner@example.test", display_name="Owner")
|
||||
user.set_password("long-test-password")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
client = app.test_client()
|
||||
client.post("/login", data={"email": "owner@example.test", "password": "long-test-password"})
|
||||
payload = json.dumps(
|
||||
{k: "secret-value" for k in ("di_token", "di_refresh_token", "di_client_id")}
|
||||
)
|
||||
response = client.post(
|
||||
"/garmin/connect", data={"tokens": (io.BytesIO(payload.encode()), "session.json")}
|
||||
)
|
||||
assert response.status_code == 302
|
||||
return app, client, api, sync
|
||||
|
||||
|
||||
def test_manual_scheduled_encryption_and_disconnect(connected):
|
||||
app, client, _api, sync = connected
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert "private-session" not in connection.tokens
|
||||
assert run_due() == 1
|
||||
assert connection.last_success
|
||||
assert not connection.pending
|
||||
assert run_due() == 0
|
||||
connection.next_sync = datetime.now(UTC) - timedelta(minutes=1)
|
||||
db.session.commit()
|
||||
assert run_due() == 1
|
||||
assert sync.call_count == 2
|
||||
client.post("/garmin/schedule", data={"enabled": "no"})
|
||||
client.post("/garmin/sync")
|
||||
with app.app_context():
|
||||
assert run_due() == 1
|
||||
assert sync.call_count == 3
|
||||
client.post("/garmin/disconnect")
|
||||
with app.app_context():
|
||||
assert run_due() == 0
|
||||
assert db.session.scalar(db.select(GarminConnection)) is None
|
||||
assert db.session.get(User, 1).local_account_key is None
|
||||
|
||||
|
||||
def test_retry_and_rotated_tokens_survive_failure(connected):
|
||||
app, client, _api, sync = connected
|
||||
api = _api
|
||||
sync.side_effect = RuntimeError("provider secret must not appear")
|
||||
with app.app_context():
|
||||
assert run_due() == 1
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert connection.failures == 1
|
||||
assert "provider secret" not in connection.message
|
||||
assert (
|
||||
Fernet(app.config["TOKEN_ENCRYPTION_KEY"]).decrypt(connection.tokens.encode())
|
||||
== api.client.dumps.return_value.encode()
|
||||
)
|
||||
assert run_due() == 0
|
||||
client.post("/garmin/sync")
|
||||
api.login.side_effect = GarminConnectAuthenticationError("private error")
|
||||
with app.app_context():
|
||||
run_due()
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert not connection.scheduled
|
||||
assert "expired" in connection.message
|
||||
|
||||
|
||||
def test_lock_prevents_overlapping_sync_and_disconnect(connected):
|
||||
app, client, _api, sync = connected
|
||||
with app.app_context(), session_lock(1):
|
||||
run_due()
|
||||
sync.assert_not_called()
|
||||
response = client.post("/garmin/disconnect", follow_redirects=True)
|
||||
assert b"sync is running" in response.data
|
||||
assert db.session.scalar(db.select(GarminConnection)) is not None
|
||||
|
||||
|
||||
def test_sync_requires_login_and_csrf(connected):
|
||||
app, client, _api, _sync = connected
|
||||
assert app.test_client().post("/garmin/sync").status_code == 302
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/garmin/sync").status_code == 400
|
||||
@@ -116,6 +116,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl-cffi"
|
||||
version = "0.16.3"
|
||||
@@ -507,6 +546,7 @@ name = "running-dashboard"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "filelock" },
|
||||
{ name = "fitdecode" },
|
||||
@@ -530,6 +570,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=46,<49" },
|
||||
{ name = "curl-cffi", specifier = ">=0.13,<1" },
|
||||
{ name = "filelock", specifier = ">=3.20,<4" },
|
||||
{ name = "fitdecode", specifier = "==0.11.0" },
|
||||
|
||||
Reference in New Issue
Block a user