Harden auth/upload, fix PR-delete cascade and sync backfill
- 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:
+82
-39
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
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()
|
||||
|
||||
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
|
||||
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:
|
||||
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
async def upload_activity(
|
||||
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 = 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.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit"):
|
||||
fit_path = extract_dir / name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
elif lower.endswith(".zip"):
|
||||
# Garmin exports nest activity FIT files inside sub-zips
|
||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||
nested_zip_path = extract_dir / name
|
||||
nested_extract = nested_zip_path.parent / nested_zip_path.stem
|
||||
nested_extract.mkdir(exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(nested_zip_path) as nzf:
|
||||
nzf.extractall(nested_extract)
|
||||
for nested_name in nzf.namelist():
|
||||
if nested_name.lower().endswith(".fit"):
|
||||
fit_path = nested_extract / nested_name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
except zipfile.BadZipFile:
|
||||
pass
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".fit":
|
||||
task = process_activity_file.delay(str(path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
elif suffix == ".zip":
|
||||
# Garmin exports nest activity FIT files inside sub-zips
|
||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||
nested_extract = path.parent / path.stem
|
||||
try:
|
||||
with zipfile.ZipFile(path) as nzf:
|
||||
nested = _safe_extract(nzf, nested_extract)
|
||||
except zipfile.BadZipFile:
|
||||
nested = []
|
||||
for np in nested:
|
||||
if np.suffix.lower() == ".fit":
|
||||
task = process_activity_file.delay(str(np), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
|
||||
# Queue health/wellness data extraction
|
||||
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)
|
||||
|
||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
||||
file_path = extract_dir / name
|
||||
ext = Path(name).suffix[1:]
|
||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
||||
task_ids.append(task.id)
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
|
||||
for path in extracted:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in (".fit", ".gpx"):
|
||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||
task_ids.append(task.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
|
||||
Reference in New Issue
Block a user