Harden auth/upload, fix PR-delete cascade and sync backfill
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s

- OIDC: require signed short-lived state on login callback; reject
  missing userinfo sub (account-takeover guard); validate token
  exchange + userinfo responses
- Upload: safe zip extraction (path-traversal + zip-bomb cap),
  streamed size-capped writes, sanitised filenames
- Garmin: increasing lookback resets last_sync_at for one-time backfill
- Activities: delete/reprocess remove PersonalRecord rows (no FK cascade)
- Profile: validate /weight limit; sync lookback UI copy
- Dashboard: sleep shading uses same day as charted body battery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:24:24 +01:00
parent 04689a29bd
commit bdd5f80c7e
8 changed files with 158 additions and 46 deletions
+7 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap from app.models.user import User, Activity, ActivityDataPoint, ActivityLap, PersonalRecord
router = APIRouter() router = APIRouter()
@@ -266,6 +266,10 @@ async def delete_activity(
activity = result.scalar_one_or_none() activity = result.scalar_one_or_none()
if not activity: if not activity:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
# PersonalRecord.activity_id has no cascade, so remove the activity's PR rows
# first or the delete fails the FK constraint. (segment_efforts cascade in DB;
# data_points/laps cascade via the ORM relationship.)
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity) await db.delete(activity)
await db.commit() await db.commit()
@@ -297,6 +301,8 @@ async def reprocess_activity(
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id)) await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id)) await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
# Drop PR rows referencing this activity (no cascade); the re-parse re-computes them.
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity) await db.delete(activity)
await db.commit() await db.commit()
+38 -1
View File
@@ -19,6 +19,7 @@ router = APIRouter()
# to a normal sign-in), so the callback attaches the passkey to a known user # to a normal sign-in), so the callback attaches the passkey to a known user
# instead of creating/looking-up by identity. # instead of creating/looking-up by identity.
LINK_STATE_PURPOSE = "pocketid-link" LINK_STATE_PURPOSE = "pocketid-link"
LOGIN_STATE_PURPOSE = "pocketid-login"
def _make_link_state(user_id: int) -> str: def _make_link_state(user_id: int) -> str:
@@ -29,6 +30,25 @@ def _make_link_state(user_id: int) -> str:
) )
def _make_login_state() -> str:
"""Signed, short-lived CSRF token proving the login flow started from this app."""
return create_access_token(
{"sub": "login", "purpose": LOGIN_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _valid_login_state(state: Optional[str]) -> bool:
"""True if `state` is a valid, unexpired login-state token we issued."""
if not state:
return False
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
return payload.get("purpose") == LOGIN_STATE_PURPOSE
except JWTError:
return False
def _decode_link_state(state: Optional[str]) -> Optional[int]: def _decode_link_state(state: Optional[str]) -> Optional[int]:
"""Return the user id from a valid link-state token, else None.""" """Return the user id from a valid link-state token, else None."""
if not state: if not state:
@@ -157,6 +177,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback", "redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code", "response_type": "code",
"scope": "openid profile email groups", "scope": "openid profile email groups",
"state": _make_login_state(),
} }
return {"url": f"{issuer}/authorize?{urlencode(params)}"} return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@@ -202,10 +223,15 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}") print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
raise HTTPException(status_code=400, detail="Token exchange failed") raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json() tokens = resp.json()
access_token = tokens.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Token exchange failed")
userinfo_resp = await client.get( userinfo_resp = await client.get(
f"{issuer}/api/oidc/userinfo", f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"}, headers={"Authorization": f"Bearer {access_token}"},
) )
if userinfo_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to fetch user info")
userinfo = userinfo_resp.json() userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -214,6 +240,12 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
email = userinfo.get("email") email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email preferred_username = userinfo.get("preferred_username") or email
# A missing subject means we cannot identify the user. Never continue, or the
# `pocketid_sub == sub` (== None → IS NULL) lookups below would match any
# password-only account and log the caller in as someone else.
if not sub:
return RedirectResponse(url="/login?auth_error=no_identity")
# ── Explicit account-link flow ────────────────────────────────────────── # ── Explicit account-link flow ──────────────────────────────────────────
# Initiated by an already-authenticated user from their profile. Attach the # Initiated by an already-authenticated user from their profile. Attach the
# passkey to that account. No group gating here: this is identity linking, # passkey to that account. No group gating here: this is identity linking,
@@ -238,6 +270,11 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
target.email = email target.email = email
return RedirectResponse(url="/profile?linked=1") return RedirectResponse(url="/profile?linked=1")
# Normal sign-in: require the signed, short-lived state we issued in
# /pocketid/login-url, so the callback can't be driven by an injected code.
if not _valid_login_state(state):
return RedirectResponse(url="/login?auth_error=invalid_state")
# Group gating: if an allowed group is configured, the user must be in it. # Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db) allowed_group = await _get_allowed_group(db)
if allowed_group: if allowed_group:
+21
View File
@@ -37,6 +37,17 @@ class GarminConfigOut(BaseModel):
from_attributes = True from_attributes = True
def _wants_more_history(old: int, new: int) -> bool:
"""True if `new` lookback requests older data than `old` (-1 = all-time)."""
if new == old:
return False
if new == -1: # all-time requested where it wasn't before
return True
if old == -1: # was all-time, now finite → narrower, not more
return False
return new > old
@router.get("/config", response_model=GarminConfigOut) @router.get("/config", response_model=GarminConfigOut)
async def get_config( async def get_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -111,6 +122,16 @@ async def save_config(
if not cfg: if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup") raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
# If the user is now asking for MORE history than before, reset last_sync_at so
# the next sync treats it as a first sync and does a one-time backfill of the
# wider lookback window (then resumes cheap incremental syncs). Scheduled syncs
# otherwise only refresh the last day or two, so without this an increased
# lookback would never actually fetch the older data.
old_lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
if _wants_more_history(old_lookback, body.sync_lookback_days):
cfg.last_sync_at = None
cfg.last_sync_status = "Lookback increased — backfill on next sync"
cfg.sync_enabled = body.sync_enabled cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness cfg.sync_wellness = body.sync_wellness
+2 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from pydantic import BaseModel from pydantic import BaseModel
@@ -192,7 +192,7 @@ class WeightOut(BaseModel):
@router.get("/weight", response_model=List[WeightOut]) @router.get("/weight", response_model=List[WeightOut])
async def list_weight( async def list_weight(
limit: int = 365, limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
+82 -39
View File
@@ -1,5 +1,4 @@
import os import os
import shutil
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
@@ -13,18 +12,68 @@ from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter() router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"} MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
_CHUNK = 1024 * 1024
def _safe_name(filename: str) -> str:
"""Reduce an uploaded filename to a safe basename — no path traversal."""
name = os.path.basename((filename or "").replace("\\", "/"))
if not name or name in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid filename")
return name
def save_upload(upload: UploadFile, dest_dir: Path) -> Path: def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename dest = dest_dir / _safe_name(upload.filename)
size = 0
with open(dest, "wb") as f: with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f) while True:
chunk = upload.file.read(_CHUNK)
if not chunk:
break
size += len(chunk)
if size > MAX_FILE_SIZE:
f.close()
dest.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="File exceeds the 500 MB limit")
f.write(chunk)
return dest return dest
def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
"""Extract a zip safely: skip path-traversal members, cap total uncompressed
bytes (zip-bomb guard). Returns the list of extracted regular-file paths."""
dest_dir.mkdir(parents=True, exist_ok=True)
dest_root = dest_dir.resolve()
total = 0
extracted: list[Path] = []
for info in zf.infolist():
if info.is_dir():
continue
target = (dest_root / info.filename).resolve()
# Reject absolute paths and ../ traversal: the target must stay under dest_root.
if target != dest_root and dest_root not in target.parents:
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
while True:
chunk = src.read(_CHUNK)
if not chunk:
break
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
out.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="Archive expands beyond the size limit")
out.write(chunk)
extracted.append(target)
return extracted
@router.post("/activity") @router.post("/activity")
async def upload_activity( async def upload_activity(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -62,35 +111,31 @@ async def upload_garmin_export(
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir) dest = save_upload(file, dest_dir)
# Extract and queue all FIT files # Extract (safely) and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}" extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = [] task_ids = []
with zipfile.ZipFile(dest) as zf: with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir) extracted = _safe_extract(zf, extract_dir)
for name in zf.namelist():
lower = name.lower() for path in extracted:
if lower.endswith(".fit"): suffix = path.suffix.lower()
fit_path = extract_dir / name if suffix == ".fit":
task = process_activity_file.delay(str(fit_path), current_user.id, "fit") task = process_activity_file.delay(str(path), current_user.id, "fit")
task_ids.append(task.id) task_ids.append(task.id)
elif lower.endswith(".zip"): elif suffix == ".zip":
# Garmin exports nest activity FIT files inside sub-zips # Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip) # (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_zip_path = extract_dir / name nested_extract = path.parent / path.stem
nested_extract = nested_zip_path.parent / nested_zip_path.stem try:
nested_extract.mkdir(exist_ok=True) with zipfile.ZipFile(path) as nzf:
try: nested = _safe_extract(nzf, nested_extract)
with zipfile.ZipFile(nested_zip_path) as nzf: except zipfile.BadZipFile:
nzf.extractall(nested_extract) nested = []
for nested_name in nzf.namelist(): for np in nested:
if nested_name.lower().endswith(".fit"): if np.suffix.lower() == ".fit":
fit_path = nested_extract / nested_name task = process_activity_file.delay(str(np), current_user.id, "fit")
task = process_activity_file.delay(str(fit_path), current_user.id, "fit") task_ids.append(task.id)
task_ids.append(task.id)
except zipfile.BadZipFile:
pass
# Queue health/wellness data extraction # Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id) health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
@@ -116,18 +161,16 @@ async def upload_strava_export(
dest = save_upload(file, dest_dir) dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}" extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = [] task_ids = []
with zipfile.ZipFile(dest) as zf: with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir) extracted = _safe_extract(zf, extract_dir)
for name in zf.namelist():
lower = name.lower() for path in extracted:
if lower.endswith(".fit") or lower.endswith(".gpx"): suffix = path.suffix.lower()
file_path = extract_dir / name if suffix in (".fit", ".gpx"):
ext = Path(name).suffix[1:] task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task = process_activity_file.delay(str(file_path), current_user.id, ext) task_ids.append(task.id)
task_ids.append(task.id)
return { return {
"status": "queued", "status": "queued",
+5 -2
View File
@@ -168,8 +168,11 @@ export default function DashboardPage() {
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
resting_hr: pick('resting_hr'), resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'), sleep_duration_s: pick('sleep_duration_s'),
sleep_start: pick('sleep_start'), // Sleep window must come from the SAME day as `date` (the day whose intraday
sleep_end: pick('sleep_end'), // body battery we chart), not the latest non-null — otherwise the sleep
// shading is aligned to a different night. Null here just means "no shading".
sleep_start: rows[0]?.sleep_start ?? null,
sleep_end: rows[0]?.sleep_end ?? null,
hrv_nightly_avg: pick('hrv_nightly_avg'), hrv_nightly_avg: pick('hrv_nightly_avg'),
sleep_score: pick('sleep_score'), sleep_score: pick('sleep_score'),
steps: pick('steps'), steps: pick('steps'),
+2
View File
@@ -8,6 +8,8 @@ const AUTH_ERRORS = {
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.", not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.", passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
link_failed: "Couldn't link the passkey. Please try again.", link_failed: "Couldn't link the passkey. Please try again.",
invalid_state: "Your sign-in link expired or was invalid. Please try signing in again.",
no_identity: "Couldn't read your identity from the provider. Please try again.",
} }
export default function LoginPage() { export default function LoginPage() {
+1 -1
View File
@@ -363,7 +363,7 @@ export default function ProfilePage() {
))} ))}
</div> </div>
<Field label="Initial sync lookback days" hint="How far back to pull on the FIRST sync only (-1 = all history back to 2010). After that, scheduled syncs just refresh the last few days. To re-pull old history later, disconnect and reconnect."> <Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history back to 2010). After that, scheduled syncs only refresh the last day or two. Increasing this value triggers a one-time backfill of the extra history on the next sync.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1} <Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} /> onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && ( {(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (