diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 7594721..2c81234 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -37,6 +37,7 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, """ import uvicorn import bincio.serve.server as srv + from bincio.serve import deps from bincio.serve.db import open_db, set_setting, get_setting dd = Path(data_dir).expanduser().resolve() @@ -50,21 +51,21 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, set_setting(db, "max_users", str(max_users)) db.close() - srv.data_dir = dd + deps.data_dir = dd if site_dir: - srv.site_dir = Path(site_dir).expanduser().resolve() + deps.site_dir = Path(site_dir).expanduser().resolve() if strava_client_id: - srv.strava_client_id = strava_client_id + deps.strava_client_id = strava_client_id if strava_client_secret: - srv.strava_client_secret = strava_client_secret + deps.strava_client_secret = strava_client_secret if public_url: - srv.public_url = public_url + deps.public_url = public_url if webroot and site_dir: - srv.webroot = Path(webroot).expanduser().resolve() + deps.webroot = Path(webroot).expanduser().resolve() if dem_url: - srv.dem_url = dem_url + deps.dem_url = dem_url if sync_secret: - srv.sync_secret = sync_secret + deps.sync_secret = sync_secret db = open_db(dd) current_limit = get_setting(db, "max_users") @@ -72,16 +73,16 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, console.print(f"[bold]bincio serve[/bold]") console.print(f" Data: [cyan]{dd}[/cyan]") - if srv.site_dir: - console.print(f" Site: [cyan]{srv.site_dir}[/cyan]") - if srv.webroot: - console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)") + if deps.site_dir: + console.print(f" Site: [cyan]{deps.site_dir}[/cyan]") + if deps.webroot: + console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") if current_limit and int(current_limit) > 0: console.print(f" Users: [yellow]max {current_limit}[/yellow]") else: console.print(f" Users: [dim]unlimited[/dim]") - console.print(f" DEM: [cyan]{srv.dem_url}[/cyan]") + console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]") console.print() log_config = uvicorn.config.LOGGING_CONFIG.copy() diff --git a/bincio/serve/deps.py b/bincio/serve/deps.py new file mode 100644 index 0000000..d0beab3 --- /dev/null +++ b/bincio/serve/deps.py @@ -0,0 +1,168 @@ +"""Shared state and FastAPI dependency functions for bincio.serve. + +All module-level globals live here so routers can import them without +creating circular dependencies through server.py. +The CLI sets these before uvicorn starts. +""" +from __future__ import annotations + +import json +import os +import re +import threading +import time +from pathlib import Path +from typing import Optional + +from fastapi import Cookie, HTTPException, Request, Response + +from bincio.serve.db import ( + User, + authenticate, + create_session, + delete_session, + get_session, + get_user, + open_db, +) +from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID +from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401 +from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401 +from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401 + +# ── Module-level state (set by CLI before uvicorn starts) ───────────────────── + +data_dir: Path | None = None +site_dir: Path | None = None +webroot: Path | None = None +strava_client_id: str = "" +strava_client_secret: str = "" +public_url: str = "" +dem_url: str = "https://api.open-elevation.com" +sync_secret: str = "" +_db = None +_strava_sync_running = False +_strava_sync_lock = threading.Lock() + +# ── Constants ───────────────────────────────────────────────────────────────── + +_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') +_SESSION_COOKIE = "bincio_session" +_COOKIE_MAX_AGE = 30 * 86400 # 30 days +_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None + +_STRAVA_CREDS_FILE = "strava_credentials.json" + +_login_attempts: dict[str, list[float]] = {} +_register_attempts: dict[str, list[float]] = {} +_RATE_WINDOW = 900 # 15 minutes +_LOGIN_RATE_LIMIT = 10 +_REGISTER_RATE_LIMIT = 5 + +# ── Core helpers ────────────────────────────────────────────────────────────── + +def _get_data_dir() -> Path: + if data_dir is None: + raise HTTPException(500, "Server not configured") + return data_dir + + +def _get_db(): + global _db + if _db is None: + _db = open_db(_get_data_dir()) + return _db + + +def _strava_creds(handle: str) -> tuple[str, str]: + """Return (client_id, client_secret) for a user. + + Per-user credentials take precedence over the instance-level globals. + Returns ("", "") when neither is configured. + """ + creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE + if creds_path.exists(): + try: + d = json.loads(creds_path.read_text(encoding="utf-8")) + cid = str(d.get("client_id", "")).strip() + csec = str(d.get("client_secret", "")).strip() + if cid and csec: + return cid, csec + except (OSError, json.JSONDecodeError, KeyError, ValueError): + pass + return strava_client_id, strava_client_secret + + +def _check_id(activity_id: str) -> str: + if not _VALID_ACTIVITY_ID.match(activity_id): + raise HTTPException(400, "Invalid activity ID") + return activity_id + +# ── Rate limiting ───────────────────────────────────────────────────────────── + +def _check_rate_limit( + ip: str, + store: dict[str, list[float]], + limit: int, + msg: str = "Too many attempts. Try again later.", +) -> None: + now = time.time() + attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW] + store[ip] = attempts + if len(attempts) >= limit: + raise HTTPException(429, msg) + attempts.append(now) + store[ip] = attempts + +# ── Auth dependency functions ───────────────────────────────────────────────── + +def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]: + if not bincio_session: + return None + return get_session(_get_db(), bincio_session) + + +def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User: + user = _current_user(bincio_session) + if not user: + raise HTTPException(401, "Not authenticated") + return user + + +def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User: + user = _require_user(bincio_session) + if not user.is_admin: + raise HTTPException(403, "Admin required") + return user + + +def _require_auth( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> User: + """Accept session cookie (web) OR Authorization: Bearer token (mobile).""" + token = bincio_session + if not token: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + token = auth[7:] + if not token: + raise HTTPException(401, "Not authenticated") + user = get_session(_get_db(), token) + if not user: + raise HTTPException(401, "Invalid or expired session") + return user + + +def _set_session_cookie(response: Response, token: str) -> None: + kwargs: dict = dict( + key=_SESSION_COOKIE, + value=token, + max_age=_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, + ) + if _SESSION_DOMAIN: + kwargs["domain"] = _SESSION_DOMAIN + response.set_cookie(**kwargs) diff --git a/bincio/serve/models.py b/bincio/serve/models.py new file mode 100644 index 0000000..09ebb6d --- /dev/null +++ b/bincio/serve/models.py @@ -0,0 +1,85 @@ +"""Pydantic request/response models for bincio.serve.""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + handle: str = Field(..., description="User handle (username)") + password: str = Field(..., description="User password") + + +class LoginResponse(BaseModel): + ok: bool = Field(True, description="Success flag") + handle: str = Field(..., description="User handle") + display_name: str = Field(..., description="User's display name") + + +class ResetPasswordRequest(BaseModel): + handle: str = Field(..., description="User handle") + code: str = Field(..., description="Reset code (24 hours valid)") + password: str = Field(..., description="New password (min 8 chars)") + + +class RegisterRequest(BaseModel): + code: str = Field(..., description="Invite code") + handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)") + password: str = Field(..., description="Password (min 8 characters)") + display_name: str = Field(default="", description="Full name (optional, defaults to handle)") + + +class RegisterResponse(BaseModel): + ok: bool = Field(True, description="Success flag") + handle: str = Field(..., description="New user's handle") + + +class CurrentUserResponse(BaseModel): + handle: str = Field(..., description="User handle") + display_name: str = Field(..., description="User's display name") + is_admin: bool = Field(..., description="Whether user is an admin") + store_originals_default: bool = Field( + default=True, + description="Instance-wide default for storing original files" + ) + + +class ActivityEditRequest(BaseModel): + title: str | None = Field(default=None, description="Activity title") + description: str | None = Field(default=None, description="Activity description (markdown)") + sport: str | None = Field(default=None, description="Sport type") + sub_sport: str | None = Field(default=None, description="Sport sub-category") + private: bool | None = Field(default=None, description="Hide from public feed") + highlight: bool | None = Field(default=None, description="Mark as favorite") + gear: str | None = Field(default=None, description="Gear used") + + +class ActivityEditResponse(BaseModel): + ok: bool = Field(True, description="Success flag") + + +class ResetPasswordCodeResponse(BaseModel): + ok: bool = Field(True, description="Success flag") + code: str = Field(..., description="One-time reset code") + expires_in_hours: int = Field(24, description="Code validity period in hours") + + +class GenericResponse(BaseModel): + ok: bool = Field(True, description="Success flag") + + +class CreateSegmentRequest(BaseModel): + name: str = Field(..., description="Segment name") + sport: Optional[str] = Field(default=None, description="Sport filter") + polyline: list[list[float]] = Field(..., description="[[lat, lon], ...] GPS points") + distance_m: float = Field(..., description="Segment length in metres") + + +class CreateInviteRequest(BaseModel): + grants_activity: bool = Field(default=False) + + +class IdeaBody(BaseModel): + title: str + body: str = "" diff --git a/bincio/serve/routers/__init__.py b/bincio/serve/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bincio/serve/routers/activities.py b/bincio/serve/routers/activities.py new file mode 100644 index 0000000..e4835f3 --- /dev/null +++ b/bincio/serve/routers/activities.py @@ -0,0 +1,380 @@ +"""Activity CRUD and athlete endpoints.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse + +from bincio.serve import deps, tasks +from bincio.serve.models import ActivityEditRequest, ActivityEditResponse, GenericResponse +from bincio.serve.db import User +from bincio.shared.images import ( + ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES, + MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES, + unique_image_name as _unique_image_name, +) + +router = APIRouter() + + +def _user_data_dir(handle: str) -> Path: + """Return the merged data dir for a user, for reading activity files.""" + dd = deps._get_data_dir() + merged = dd / handle / "_merged" + return merged if merged.exists() else dd / handle + + +def _require_owns(activity_id: str, user: User) -> Path: + """Verify the user owns this activity (it lives in their data dir).""" + activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json" + if not activity_path.exists(): + raise HTTPException(404, "Activity not found") + return activity_path + + +@router.get("/api/activity/{activity_id}/geojson") +async def get_activity_geojson( + activity_id: str, + user: User = Depends(deps._require_auth), +) -> JSONResponse: + """Return GeoJSON track for an activity (mobile detail screen).""" + deps._check_id(activity_id) + dd = deps._get_data_dir() + user_dir = dd / user.handle + for base in (user_dir / "_merged" / "activities", user_dir / "activities"): + p = base / f"{activity_id}.geojson" + if p.exists(): + return JSONResponse(json.loads(p.read_text())) + raise HTTPException(404, "GeoJSON not found") + + +@router.get("/api/activity/{activity_id}/timeseries") +async def get_activity_timeseries( + activity_id: str, + user: User = Depends(deps._require_auth), +) -> JSONResponse: + """Return timeseries for an activity (mobile detail screen).""" + deps._check_id(activity_id) + dd = deps._get_data_dir() + user_dir = dd / user.handle + for base in (user_dir / "_merged" / "activities", user_dir / "activities"): + p = base / f"{activity_id}.timeseries.json" + if p.exists(): + return JSONResponse(json.loads(p.read_text())) + raise HTTPException(404, "Timeseries not found") + + +@router.get("/api/activity/{activity_id}") +async def get_activity( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + path = _require_owns(activity_id, user) + detail = json.loads(path.read_text()) + # Normalise for EditDrawer: add `private` bool so the drawer works regardless + # of whether the raw JSON uses the old "private" or the new "unlisted" value. + detail["private"] = detail.get("privacy") in ("private", "unlisted") + return JSONResponse(detail) + + +@router.post("/api/activity/{activity_id}", response_model=ActivityEditResponse) +async def post_activity( + activity_id: str, + edit_req: ActivityEditRequest, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + # Verify the activity belongs to this user before writing + if not (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(404, "Activity not found") + + from bincio.edit.ops import apply_sidecar_edit + body = edit_req.model_dump(exclude_none=True) + # apply_sidecar_edit already calls merge_one internally — no full rebuild needed. + apply_sidecar_edit(activity_id, body, dd) + return JSONResponse({"ok": True}) + + +@router.post("/api/activity/{activity_id}/recalculate-elevation/dem") +async def recalculate_elevation_dem_endpoint( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Replace GPS altitude with DEM terrain elevation and recompute gain/loss. + + Requires --dem-url to be set when starting bincio serve. + """ + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + if not deps.dem_url: + raise HTTPException(503, "DEM URL not configured.") + dd = deps._get_data_dir() / user.handle + if not (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(404, "Activity not found") + try: + from bincio.extract.dem import recalculate_elevation + from bincio.render.merge import merge_one + result = recalculate_elevation(dd, activity_id, deps.dem_url) + merge_one(dd, activity_id) + tasks._trigger_rebuild(user.handle) + return JSONResponse(result) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + except ValueError as e: + raise HTTPException(422, str(e)) + + +@router.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis") +async def recalculate_elevation_hysteresis_endpoint( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Recompute gain/loss from original recorded elevation using source-aware hysteresis.""" + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + if not (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(404, "Activity not found") + try: + from bincio.extract.dem import recalculate_elevation_hysteresis + from bincio.render.merge import merge_one + result = recalculate_elevation_hysteresis(dd, activity_id) + merge_one(dd, activity_id) + tasks._trigger_rebuild(user.handle) + return JSONResponse(result) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + except ValueError as e: + raise HTTPException(422, str(e)) + + +@router.delete("/api/activity/{activity_id}", response_model=GenericResponse) +async def delete_activity( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Delete a single activity and all associated files for the logged-in user.""" + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + acts_dir = dd / "activities" + + json_path = acts_dir / f"{activity_id}.json" + if not json_path.exists(): + raise HTTPException(404, "Activity not found") + + import shutil + + # Remove the source files (activities dir) + for suffix in (".json", ".geojson", ".timeseries.json"): + p = acts_dir / f"{activity_id}{suffix}" + p.unlink(missing_ok=True) + + # Remove sidecar edit and images + sidecar = dd / "edits" / f"{activity_id}.md" + sidecar.unlink(missing_ok=True) + images_dir = dd / "edits" / "images" / activity_id + if images_dir.exists(): + shutil.rmtree(images_dir) + + # Remove from the extract-level flat index so merge_all doesn't re-add + # the summary even though the detail file is gone. + index_path = dd / "index.json" + if index_path.exists(): + try: + idx = json.loads(index_path.read_text(encoding="utf-8")) + idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id] + index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False)) + except Exception: + pass # corrupt index — merge_all will clean up on next run + + # Remove from dedup cache so the file can be re-uploaded if needed + cache_path = dd / ".bincio_cache.json" + if cache_path.exists(): + try: + cache = json.loads(cache_path.read_text(encoding="utf-8")) + if isinstance(cache, dict) and "activities" in cache: + cache["activities"] = [ + a for a in cache["activities"] if a.get("id") != activity_id + ] + cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False)) + except Exception: + pass # corrupt cache — leave it; next extract will rebuild + + # Full merge needed: activity removed from index + from bincio.render.merge import merge_all + merge_all(dd) + tasks._trigger_rebuild(user.handle) + + return JSONResponse({"ok": True}) + + +@router.get("/api/activity/{activity_id}/images") +async def list_images( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + images_dir = dd / "edits" / "images" / activity_id + images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else [] + return JSONResponse({"images": images}) + + +@router.post("/api/activity/{activity_id}/images") +async def upload_image( + activity_id: str, + file: UploadFile = File(...), + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + if not (dd / "activities" / f"{activity_id}.json").exists(): + raise HTTPException(404, "Activity not found") + if not file.filename: + raise HTTPException(400, "No filename") + ct = file.content_type or "" + if ct not in _ALLOWED_IMAGE_TYPES: + raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") + contents = await file.read() + if len(contents) > _MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)") + images_dir = dd / "edits" / "images" / activity_id + images_dir.mkdir(parents=True, exist_ok=True) + safe_name = _unique_image_name(images_dir, Path(file.filename).name) + (images_dir / safe_name).write_bytes(contents) + from bincio.render.merge import merge_one + merge_one(dd, activity_id) + return JSONResponse({"ok": True, "filename": safe_name}) + + +@router.delete("/api/activity/{activity_id}/images/{filename}") +async def delete_image( + activity_id: str, + filename: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + deps._check_id(activity_id) + dd = deps._get_data_dir() / user.handle + import shutil + safe_name = Path(filename).name + target = dd / "edits" / "images" / activity_id / safe_name + if target.exists() and target.is_file(): + target.unlink() + if target.parent.exists() and not any(target.parent.iterdir()): + shutil.rmtree(target.parent) + from bincio.render.merge import merge_one + merge_one(dd, activity_id) + return JSONResponse({"ok": True}) + + +@router.get("/api/athlete") +async def get_athlete(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + athlete_path = dd / "athlete.json" + data: dict = {} + if athlete_path.exists(): + data = json.loads(athlete_path.read_text(encoding="utf-8")) + # Layer edits/athlete.yaml on top + edits_path = dd / "edits" / "athlete.yaml" + if edits_path.exists(): + try: + import yaml + edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {} + for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"): + if k in edits: + data[k] = edits[k] + except Exception: + pass + return JSONResponse(data) + + +@router.post("/api/athlete") +async def save_athlete( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + athlete_path = dd / "athlete.json" + if not athlete_path.exists(): + from datetime import datetime, timezone + athlete_path.write_text(json.dumps({ + "bas_version": "1.0", + "generated_at": datetime.now(timezone.utc).isoformat(), + "power_curve": {}, + }), encoding="utf-8") + payload = await request.json() + edits_dir = dd / "edits" + edits_dir.mkdir(exist_ok=True) + overrides: dict[str, Any] = {} + if payload.get("max_hr") is not None: + overrides["max_hr"] = int(payload["max_hr"]) + if payload.get("ftp_w") is not None: + overrides["ftp_w"] = int(payload["ftp_w"]) + if payload.get("hr_zones") is not None: + overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]] + if payload.get("power_zones") is not None: + overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]] + if payload.get("seasons") is not None: + overrides["seasons"] = [ + {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])} + for s in payload["seasons"] + ] + if payload.get("gear") is not None: + overrides["gear"] = payload["gear"] + import yaml + (edits_dir / "athlete.yaml").write_text( + yaml.dump(overrides, allow_unicode=True, default_flow_style=False), + encoding="utf-8", + ) + from bincio.render.merge import merge_all + merge_all(dd) + tasks._trigger_rebuild(user.handle) + return JSONResponse({"ok": True}) + + +@router.get("/api/activities/{activity_id}/segment_efforts") +async def activity_segment_efforts( + activity_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Return segment efforts that belong to a specific activity for the logged-in user.""" + from bincio.segments import store as _seg_store + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + efforts_dir = dd / user.handle / "segment_efforts" + result = [] + if efforts_dir.exists(): + import json as _json + for ef_file in sorted(efforts_dir.glob("*.json")): + seg_id = ef_file.stem + all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id) + matching = [e for e in all_efforts if e.activity_id == activity_id] + if not matching: + continue + seg = _seg_store.load_segment(dd, seg_id) + if not seg: + continue + pr_elapsed = min(e.elapsed_s for e in all_efforts) + for eff in matching: + result.append({ + "segment_id": seg.id, + "segment_name": seg.name, + "segment_distance_m": seg.distance_m, + "elapsed_s": eff.elapsed_s, + "pr_elapsed_s": pr_elapsed, + "started_at": _seg_store._iso(eff.started_at), + }) + return JSONResponse(result) diff --git a/bincio/serve/routers/admin.py b/bincio/serve/routers/admin.py new file mode 100644 index 0000000..c9b10ec --- /dev/null +++ b/bincio/serve/routers/admin.py @@ -0,0 +1,637 @@ +"""Admin endpoints (/api/admin/*).""" +from __future__ import annotations + +import json +import logging +import shutil +import subprocess +import threading +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from bincio.serve import deps, tasks +from bincio.serve.models import ResetPasswordCodeResponse +from bincio.serve.db import ( + User, + get_user, + list_users, +) + +log = logging.getLogger("bincio.serve") + +router = APIRouter() + + +def _wipe_user_activities(user_dir: Path) -> int: + """Delete all extracted activity files and caches for a user. + + Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/, + _merged/, index.json, athlete.json, and the dedup cache. + Leaves the user directory itself intact (account remains in the DB). + Returns the number of files deleted. + """ + import shutil + deleted = 0 + + for subdir in ("activities", "edits", "originals"): + d = user_dir / subdir + if d.exists(): + for f in d.rglob("*"): + if f.is_file(): + deleted += 1 + shutil.rmtree(d) + + for name in ("_merged", ): + d = user_dir / name + if d.exists(): + shutil.rmtree(d) + + for name in ("index.json", "athlete.json", ".bincio_cache.json"): + f = user_dir / name + if f.exists(): + f.unlink() + deleted += 1 + + return deleted + + +@router.get("/api/admin/users") +async def admin_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + deps._require_admin(bincio_session) + users = list_users(deps._get_db()) + return JSONResponse([{ + "handle": u.handle, + "display_name": u.display_name, + "is_admin": u.is_admin, + "suspended": u.suspended, + "created_at": u.created_at, + } for u in users]) + + +@router.get("/api/admin/jobs") +async def admin_jobs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Return currently active upload/processing jobs. Admin only.""" + deps._require_admin(bincio_session) + with tasks._jobs_lock: + jobs = list(tasks._active_jobs.values()) + return JSONResponse(jobs) + + +@router.get("/api/admin/disk") +async def admin_disk(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Per-user disk usage breakdown. Admin only.""" + deps._require_admin(bincio_session) + import shutil + + data_dir = deps._get_data_dir() + + def _mb(path: Path) -> float: + if not path.exists(): + return 0.0 + # Use lstat to count symlink entries (few bytes each) rather than following + # the link to the target — prevents _merged/ from double-counting activities/. + total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink()) + return round(total / 1_048_576, 1) + + def _count(path: Path, pattern: str = "*") -> int: + if not path.exists(): + return 0 + return sum(1 for f in path.glob(pattern) if f.is_file()) + + db = deps._get_db() + from bincio.serve.db import get_user as _get_user + users = [] + for user_dir in sorted(data_dir.iterdir()): + if not user_dir.is_dir() or user_dir.name.startswith("_"): + continue + # leaked tmp zips + leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()] + db_user = _get_user(db, user_dir.name) + users.append({ + "handle": user_dir.name, + "in_db": db_user is not None, + "suspended": db_user.suspended if db_user else False, + "total_mb": _mb(user_dir), + "activities_mb": _mb(user_dir / "activities"), + "activities_count": _count(user_dir / "activities", "*.json"), + "merged_mb": _mb(user_dir / "_merged"), + "originals_mb": _mb(user_dir / "originals"), + "originals_strava_mb": _mb(user_dir / "originals" / "strava"), + "images_mb": _mb(user_dir / "edits" / "images"), + "leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1), + "leaked_zips_count": len(leaked), + }) + + disk = shutil.disk_usage("/") + return JSONResponse({ + "disk": { + "total_gb": round(disk.total / 1_073_741_824, 1), + "used_gb": round(disk.used / 1_073_741_824, 1), + "free_gb": round(disk.free / 1_073_741_824, 1), + "percent": round(disk.used / disk.total * 100, 1), + }, + "users": users, + }) + + +@router.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse) +async def admin_reset_password_code( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Generate a one-time password reset code for a user. Admin only.""" + from bincio.serve.db import create_reset_code + admin = deps._require_admin(bincio_session) + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, f"User '{handle}' not found") + code = create_reset_code(db, handle, admin.handle) + return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24}) + + +@router.post("/api/admin/users/{handle}/suspend") +async def admin_suspend( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Suspend a user account. Blocks login and invalidates existing sessions. Admin only.""" + from bincio.serve.db import set_suspended, purge_expired_sessions + admin = deps._require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot suspend yourself") + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, True) + db.execute("DELETE FROM sessions WHERE handle = ?", (handle,)) + db.commit() + return JSONResponse({"status": "suspended", "handle": handle}) + + +@router.post("/api/admin/users/{handle}/unsuspend") +async def admin_unsuspend( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Re-enable a suspended user account. Admin only.""" + from bincio.serve.db import set_suspended + deps._require_admin(bincio_session) + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, False) + return JSONResponse({"status": "unsuspended", "handle": handle}) + + +@router.delete("/api/admin/users/{handle}/account") +async def admin_delete_account( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Delete a user account from the database. Data directory is NOT removed. Admin only.""" + from bincio.serve.db import delete_user as _delete_user + admin = deps._require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot delete your own account") + db = deps._get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + _delete_user(db, handle) + return JSONResponse({"status": "deleted", "handle": handle}) + + +@router.post("/api/admin/users/{handle}/rebuild") +async def admin_rebuild( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Trigger a merge_all + site rebuild for a user. Admin only.""" + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + tasks._trigger_rebuild(handle) + return JSONResponse({"ok": True}) + + +@router.post("/api/admin/users/{handle}/rebuild-sync") +async def admin_rebuild_sync( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Run merge+rebuild synchronously and return full output. Admin only. + + Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr. + Use for debugging when you need to see what went wrong. + """ + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + if deps.site_dir is None: + raise HTTPException(503, "Server has no --site-dir configured; rebuild not available") + + uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") + cmd = [uv, "run", "bincio", "render", + "--data-dir", str(deps.data_dir), + "--site-dir", str(deps.site_dir), + "--handle", handle, + "--no-build"] + if deps.webroot: + cmd = [uv, "run", "bincio", "render", + "--data-dir", str(deps.data_dir), + "--site-dir", str(deps.site_dir), + "--handle", handle] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + resp: dict[str, Any] = { + "ok": result.returncode == 0, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + if result.returncode == 0 and deps.webroot: + dist_data = deps.site_dir / "dist" / "data" + if dist_data.exists(): + shutil.rmtree(dist_data) + rsync = subprocess.run( + ["rsync", "-a", "--delete", "--exclude=data/", + f"{deps.site_dir}/dist/", str(deps.webroot) + "/"], + capture_output=True, text=True, timeout=120, + ) + resp["rsync_returncode"] = rsync.returncode + resp["rsync_stdout"] = rsync.stdout + resp["rsync_stderr"] = rsync.stderr + resp["ok"] = rsync.returncode == 0 + + return JSONResponse(resp) + + +@router.post("/api/admin/users/{handle}/reextract-originals") +async def admin_reextract_originals( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> StreamingResponse: + """Re-extract activities from stored Strava originals without hitting the API. + + Spawns `bincio reextract-originals` as a subprocess so heavy memory use + is isolated from the server process. Streams its JSON-lines output as SSE. + Triggers a full rebuild on completion. + """ + import asyncio + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + originals_dir = user_dir / "originals" / "strava" + if not originals_dir.exists(): + raise HTTPException(404, f"No Strava originals directory for '{handle}'") + + # Use the bincio script from the same venv bin dir as the running Python. + # This is reliable in systemd environments where PATH may not include uv. + import sys as _sys + bincio_exe = str(Path(_sys.executable).parent / "bincio") + data_dir = str(deps._get_data_dir()) + + # Count originals so we can split into memory-safe batches. + total_originals = len(list(originals_dir.glob("*.json"))) + # Each activity can briefly peak at ~10–30 MB; 100 per batch keeps RSS + # well under 3 GB even on a cheap VPS. + _BATCH = 100 + log.info("reextract[%s]: %d originals, batch size %d, via %s", + handle, total_originals, _BATCH, bincio_exe) + + async def event_stream(): + total_imported = total_skipped = total_errors = 0 + offset = 0 + + while offset < total_originals: + limit = min(_BATCH, total_originals - offset) + proc = await asyncio.create_subprocess_exec( + bincio_exe, "reextract-originals", + "--data-dir", data_dir, + "--handle", handle, + "--offset", str(offset), + "--limit", str(limit), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + + async for raw_line in proc.stdout: + line = raw_line.decode(errors="replace").strip() + if not line: + continue + yield f"data: {line}\n\n" + try: + evt = json.loads(line) + if evt.get("type") == "done": + total_imported += evt.get("imported", 0) + total_skipped += evt.get("skipped", 0) + total_errors += evt.get("errors", 0) + except Exception: + pass + + await proc.wait() + if proc.returncode != 0: + stderr_out = await proc.stderr.read() if proc.stderr else b"" + log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s", + handle, offset, proc.returncode, + stderr_out.decode(errors="replace")[:500]) + yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}–{offset+limit} exited with code {proc.returncode}'})}\n\n" + return # stop on batch failure + + offset += limit + + # All batches complete + log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild", + handle, total_imported, total_skipped, total_errors) + tasks._trigger_rebuild(handle) + yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.get("/api/admin/users/{handle}/diag") +async def admin_diag( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Return a diagnostic snapshot of a user's data directory. Admin only.""" + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + + def _count(path: Path, glob: str = "*") -> int: + return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0 + + def _size_mb(path: Path) -> float: + if not path.exists(): + return 0.0 + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576 + + activities_dir = user_dir / "activities" + merged_dir = user_dir / "_merged" + originals_dir = user_dir / "originals" + uploads_dir = user_dir / "_uploads" + + merged_index = merged_dir / "index.json" + root_index = user_dir / "index.json" + + merged_activity_count: int | None = None + if merged_index.exists(): + try: + idx = json.loads(merged_index.read_text()) + merged_activity_count = len(idx.get("activities", [])) + except Exception: + merged_activity_count = -1 + + root_activity_count: int | None = None + if root_index.exists(): + try: + idx = json.loads(root_index.read_text()) + root_activity_count = len(idx.get("activities", [])) + except Exception: + root_activity_count = -1 + + # Peek at a few filenames in activities/ to understand the actual state + acts_sample: list[str] = [] + acts_symlinks = 0 + if activities_dir.exists(): + for f in sorted(activities_dir.iterdir())[:10]: + acts_sample.append(f.name + (" → symlink" if f.is_symlink() else "")) + if f.is_symlink(): + acts_symlinks += 1 + + # Check _merged/activities/ separately + merged_acts_dir = merged_dir / "activities" + merged_acts_json = _count(merged_acts_dir, "*.json") + merged_acts_geojson = _count(merged_acts_dir, "*.geojson") + + # List pending files + pending_files: list[str] = [] + if uploads_dir.exists(): + pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()] + + return JSONResponse({ + "handle": handle, + "user_dir": str(user_dir), + "activities": { + "json_files": _count(activities_dir, "*.json"), + "geojson_files": _count(activities_dir, "*.geojson"), + "size_mb": round(_size_mb(activities_dir), 2), + "sample": acts_sample, + "symlink_count": acts_symlinks, + }, + "originals": { + "exists": originals_dir.exists(), + "size_mb": round(_size_mb(originals_dir), 2), + "strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0, + }, + "merged": { + "exists": merged_dir.exists(), + "activity_count_in_index": merged_activity_count, + "size_mb": round(_size_mb(merged_dir), 2), + "activities_json": merged_acts_json, + "activities_geojson": merged_acts_geojson, + }, + "root_index": { + "exists": root_index.exists(), + "activity_count": root_activity_count, + }, + "pending_uploads": len(pending_files), + "pending_files": pending_files, + "dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(), + "athlete_json_exists": (user_dir / "athlete.json").exists(), + }) + + +@router.delete("/api/admin/users/{handle}/activities") +async def admin_delete_activities( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Delete all activity data for a user and wipe the merged cache.""" + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for user '{handle}'") + + deleted = _wipe_user_activities(user_dir) + tasks._trigger_rebuild(handle) + return JSONResponse({"ok": True, "deleted": deleted}) + + +@router.delete("/api/admin/users/{handle}/directory") +async def admin_delete_user_directory( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Delete the entire user directory from disk (for ghost users not in the DB). + + Refuses if the handle exists as an account in the database — use + DELETE /api/admin/users/{handle}/activities for registered users. + """ + import shutil + deps._require_admin(bincio_session) + db = deps._get_db() + from bincio.serve.db import get_user as _get_user + if _get_user(db, handle) is not None: + raise HTTPException( + 400, + f"User '{handle}' is still in the database. Remove the account first, " + "or use 'Reset data' to wipe only activity files.", + ) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No directory for '{handle}'") + shutil.rmtree(user_dir) + # Rebuild root manifest so the ghost shard disappears from the site + from bincio.render.cli import _write_root_manifest + try: + _write_root_manifest(deps._get_data_dir()) + except Exception: + pass + return JSONResponse({"ok": True}) + + +@router.get("/api/admin/strava-sync") +async def admin_strava_sync_status( + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Return per-user Strava sync status for the admin panel.""" + deps._require_admin(bincio_session) + root = deps._get_data_dir() + users = [] + for tf in sorted(root.glob("*/strava_token.json")): + user_dir = tf.parent + handle = user_dir.name + has_creds = (user_dir / "strava_credentials.json").exists() + + last_sync: str | None = None + total_imported = 0 + sync_path = user_dir / "_strava_sync.json" + if sync_path.exists(): + try: + sc = json.loads(sync_path.read_text(encoding="utf-8")) + last_sync = sc.get("last_sync") + total_imported = len(sc.get("imported_ids", [])) + except Exception: + pass + + run_status: str | None = None + run_imported = 0 + run_errors = 0 + run_error_message: str | None = None + last_run: str | None = None + status_path = user_dir / "_strava_sync_status.json" + if status_path.exists(): + try: + ss = json.loads(status_path.read_text(encoding="utf-8")) + run_status = ss.get("status") + run_imported = ss.get("imported", 0) + run_errors = ss.get("errors", 0) + run_error_message = ss.get("error_message") + last_run = ss.get("last_run") + except Exception: + pass + + users.append({ + "handle": handle, + "has_credentials": has_creds, + "last_sync": last_sync, + "total_imported": total_imported, + "run_status": run_status, + "run_imported": run_imported, + "run_errors": run_errors, + "run_error_message": run_error_message, + "last_run": last_run, + }) + + return JSONResponse({"running": deps._strava_sync_running, "users": users}) + + +@router.post("/api/admin/strava-sync/run") +async def admin_strava_sync_run( + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Trigger an immediate Strava sync for all users (admin only).""" + deps._require_admin(bincio_session) + with deps._strava_sync_lock: + if deps._strava_sync_running: + raise HTTPException(409, "Sync already running") + deps._strava_sync_running = True + + def _run() -> None: + try: + from bincio.sync_strava import sync_all + results = sync_all(deps._get_data_dir()) + total_new = sum(n for n, _ in results.values()) + if total_new > 0: + tasks._site_rebuild_event.set() + except Exception: + log.exception("admin_strava_sync_run: unexpected error") + finally: + deps._strava_sync_running = False + + threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start() + return JSONResponse({"ok": True}, status_code=202) + + +@router.post("/api/admin/users/{handle}/recompute-elevation") +async def admin_recompute_elevation( + handle: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Recompute elevation gain/loss for all activities of a user from stored timeseries. + + Skips activities with altitude_source == 'dem' (already DEM-corrected). + Applies the leading-zero no-fix fix and source-aware hysteresis. + Returns patched/skipped/error counts. + """ + deps._require_admin(bincio_session) + user_dir = deps._get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for '{handle}'") + + from bincio.extract.dem import recalculate_elevation_hysteresis + from bincio.render.merge import merge_one + + patched = skipped = errors = 0 + acts_dir = user_dir / "activities" + for json_path in sorted(acts_dir.glob("*.json")): + if json_path.name.endswith(".timeseries.json"): + continue + activity_id = json_path.stem + try: + detail = json.loads(json_path.read_text(encoding="utf-8")) + if detail.get("altitude_source") == "dem": + skipped += 1 + continue + ts_path = acts_dir / f"{activity_id}.timeseries.json" + if not ts_path.exists(): + skipped += 1 + continue + ts = json.loads(ts_path.read_text(encoding="utf-8")) + ele_arr = ts.get("elevation_m") or [] + if not any(e for e in ele_arr if e is not None): + skipped += 1 + continue + recalculate_elevation_hysteresis(user_dir, activity_id) + merge_one(user_dir, activity_id) + patched += 1 + except Exception as exc: + log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc) + errors += 1 + + if patched > 0: + tasks._trigger_rebuild(handle) + + return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors}) diff --git a/bincio/serve/routers/auth.py b/bincio/serve/routers/auth.py new file mode 100644 index 0000000..efd9259 --- /dev/null +++ b/bincio/serve/routers/auth.py @@ -0,0 +1,204 @@ +"""Authentication and registration endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse + +from bincio.serve import deps, tasks +from bincio.serve.models import ( + CreateInviteRequest, + GenericResponse, + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + ResetPasswordRequest, +) +from bincio.serve.db import ( + authenticate, + count_activity_users, + count_wiki_users, + create_invite, + create_session, + create_user, + delete_session, + get_invite, + get_setting, + get_user, + list_invites, + use_invite, +) + +router = APIRouter() + + +@router.post("/api/auth/login", response_model=LoginResponse) +async def login( + login_req: LoginRequest, + request: Request, +) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.") + + handle = login_req.handle.strip().lower() + password = login_req.password + + user = authenticate(deps._get_db(), handle, password) + if not user: + raise HTTPException(401, "Invalid credentials") + + token = create_session(deps._get_db(), handle) + resp = JSONResponse({ + "ok": True, + "handle": user.handle, + "display_name": user.display_name, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) + deps._set_session_cookie(resp, token) + return resp + + +@router.post("/api/auth/logout", response_model=GenericResponse) +async def logout(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + if bincio_session: + delete_session(deps._get_db(), bincio_session) + resp = JSONResponse({"ok": True}) + kwargs: dict = dict(key=deps._SESSION_COOKIE) + if deps._SESSION_DOMAIN: + kwargs["domain"] = deps._SESSION_DOMAIN + resp.delete_cookie(**kwargs) + return resp + + +@router.post("/api/auth/token") +async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse: + """Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie.""" + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.") + handle = login_req.handle.strip().lower() + user = authenticate(deps._get_db(), handle, login_req.password) + if not user: + raise HTTPException(401, "Invalid credentials") + token = create_session(deps._get_db(), handle) + return JSONResponse({ + "ok": True, + "token": token, + "handle": user.handle, + "display_name": user.display_name, + }) + + +@router.post("/api/auth/reset-password", response_model=GenericResponse) +async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse: + """Validate a reset code and set a new password. Public endpoint.""" + from bincio.serve.db import use_reset_code, change_password + handle = reset_req.handle.strip().lower() + code = reset_req.code.strip().upper() + new_pw = reset_req.password + if len(new_pw) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + db = deps._get_db() + if not use_reset_code(db, code, handle): + raise HTTPException(400, "Invalid or expired reset code") + change_password(db, handle, new_pw) + return JSONResponse({"ok": True}) + + +# ── Registration ────────────────────────────────────────────────────────────── + +@router.post("/api/register", response_model=RegisterResponse) +async def register( + register_req: RegisterRequest, + request: Request, +) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._register_attempts, deps._REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.") + + code = register_req.code.strip().upper() + handle = register_req.handle.strip().lower() + password = register_req.password + display = register_req.display_name.strip() or handle + + if not deps._VALID_HANDLE.match(handle): + raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)") + if len(password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + + invite = get_invite(deps._get_db(), code) + if not invite or invite.used: + raise HTTPException(400, "Invalid or already-used invite code") + if get_user(deps._get_db(), handle): + raise HTTPException(409, "Handle already taken") + + db = deps._get_db() + max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users") + if max_wiki_val is not None: + limit = int(max_wiki_val) + if limit > 0 and count_wiki_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})") + + if invite.grants_activity: + max_act_val = get_setting(db, "max_activity_users") + if max_act_val is not None: + limit = int(max_act_val) + if limit > 0 and count_activity_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its activity user limit ({limit})") + + create_user(deps._get_db(), handle, display, password, is_admin=False, + wiki_access=True, activity_access=invite.grants_activity) + use_invite(deps._get_db(), code, handle) + + # Create per-user directories + dd = deps._get_data_dir() + user_dir = dd / handle + (user_dir / "activities").mkdir(parents=True, exist_ok=True) + (user_dir / "edits").mkdir(parents=True, exist_ok=True) + + # Write an empty index.json so the shard URL resolves immediately, + # even before the user uploads any activities. + from bincio.extract.writer import write_index + index_path = user_dir / "index.json" + if not index_path.exists(): + write_index([], user_dir, {"handle": handle, "display_name": display or handle}) + + # Update root manifest so the new user's shard is discoverable immediately + from bincio.render.cli import _write_root_manifest + _write_root_manifest(dd) + + # Rebuild site so the new user's profile pages exist immediately + tasks._trigger_rebuild(handle) + + token = create_session(deps._get_db(), handle) + resp = JSONResponse({"ok": True, "handle": handle}) + deps._set_session_cookie(resp, token) + return resp + + +# ── Invites ─────────────────────────────────────────────────────────────────── + +@router.get("/api/invites") +async def get_invites(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + invites = list_invites(deps._get_db(), user.handle) + return JSONResponse([{ + "code": i.code, + "used": i.used, + "used_by": i.used_by, + "created_at": i.created_at, + "used_at": i.used_at, + "grants_activity": i.grants_activity, + } for i in invites]) + + +@router.post("/api/invites") +async def post_invite( + body: CreateInviteRequest = CreateInviteRequest(), + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + try: + code = create_invite(deps._get_db(), user.handle, grants_activity=body.grants_activity) + except ValueError as e: + raise HTTPException(400, str(e)) + return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity}) diff --git a/bincio/serve/routers/feed.py b/bincio/serve/routers/feed.py new file mode 100644 index 0000000..70f7f91 --- /dev/null +++ b/bincio/serve/routers/feed.py @@ -0,0 +1,129 @@ +"""Feed and wheel endpoints.""" +from __future__ import annotations + +import json + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse + +from bincio.serve import deps, tasks +from bincio.serve.models import CurrentUserResponse +from bincio.serve.db import ( + User, + get_member_tree, + get_setting, +) + +router = APIRouter() + + +@router.get("/api/me", response_model=CurrentUserResponse) +async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._current_user(bincio_session) + if not user: + raise HTTPException(401, "Not authenticated") + store_orig = get_setting(deps._get_db(), "store_originals") + return JSONResponse({ + "handle": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + "store_originals_default": store_orig != "false", + "dem_configured": bool(deps.dem_url), + }) + + +@router.get("/api/stats") +async def stats() -> JSONResponse: + """Public endpoint: member count, join dates, and invitation tree.""" + import time as _time + now = int(_time.time()) + members = get_member_tree(deps._get_db()) + return JSONResponse({ + "user_count": len(members), + "members": [ + { + "handle": m["handle"], + "display_name": m["display_name"], + "member_since": m["created_at"], + "member_for_days": (now - m["created_at"]) // 86400, + "invited_by": m["invited_by"], + } + for m in members + ], + }) + + +@router.post("/api/internal/rebuild") +async def internal_rebuild(request: Request) -> JSONResponse: + """Trigger a site rebuild. Authenticated via X-Sync-Secret header. + + Called by the bincio sync-strava systemd timer after syncing new activities. + Returns 503 if webroot is not configured (rebuild not possible). + Returns 403 if the secret is missing or wrong. + """ + if not deps.sync_secret: + raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)") + if request.headers.get("X-Sync-Secret") != deps.sync_secret: + raise HTTPException(403, "Forbidden") + if deps.site_dir is None: + raise HTTPException(503, "No site dir configured") + tasks._site_rebuild_event.set() + return JSONResponse({"status": "rebuild queued"}) + + +@router.get("/api/wheel/version") +async def wheel_version() -> JSONResponse: + """Public endpoint: current bincio wheel version for mobile app update checks.""" + import importlib.metadata + try: + version = importlib.metadata.version("bincio") + except importlib.metadata.PackageNotFoundError: + version = "0.1.0" + return JSONResponse({ + "version": version, + "url": f"/bincio-{version}-py3-none-any.whl", + "api_url": f"/api/wheel/download", + }) + + +@router.get("/api/wheel/download") +async def wheel_download() -> FileResponse: + """Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl).""" + import importlib.metadata + from pathlib import Path + try: + version = importlib.metadata.version("bincio") + except importlib.metadata.PackageNotFoundError: + version = "0.1.0" + wheel_name = f"bincio-{version}-py3-none-any.whl" + # Look in dist/ relative to repo root (two levels up from this file) + dist_dir = Path(__file__).parent.parent.parent.parent / "dist" + wheel_path = dist_dir / wheel_name + if not wheel_path.exists(): + raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/") + return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name) + + +@router.get("/api/feed") +async def get_feed(user: User = Depends(deps._require_auth)) -> JSONResponse: + """Return the authenticated user's activity summaries (mobile feed sync). + + _merged/index.json is a shard manifest (activities: []) when the user has + more than FEED_PAGE_SIZE activities. Collect from all shard files. + """ + dd = deps._get_data_dir() + user_dir = dd / user.handle + for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"): + if not index_path.exists(): + continue + index = json.loads(index_path.read_text()) + activities: list[dict] = index.get("activities", []) + for shard in index.get("shards", []): + shard_path = index_path.parent / shard["url"] + if shard_path.exists(): + shard_doc = json.loads(shard_path.read_text()) + activities.extend(shard_doc.get("activities", [])) + return JSONResponse({"activities": activities}) + return JSONResponse({"activities": []}) diff --git a/bincio/serve/routers/garmin.py b/bincio/serve/routers/garmin.py new file mode 100644 index 0000000..1e82575 --- /dev/null +++ b/bincio/serve/routers/garmin.py @@ -0,0 +1,119 @@ +"""Garmin Connect endpoints (/api/garmin/*).""" +from __future__ import annotations + +import json +from typing import Optional + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from bincio.serve import deps, tasks + +router = APIRouter() + + +def _garmin_user_message(exc: Exception) -> str: + """Return a human-friendly error message for common Garmin login failures.""" + msg = str(exc) + fallback = ( + " In the meantime, you can export your activities from Garmin Connect " + "(garmin.com → Activities → Export) or Garmin Express as FIT files " + "and upload them directly." + ) + if "429" in msg or "rate limit" in msg.lower(): + return ( + "Garmin is rate-limiting this server's IP address (HTTP 429). " + "Wait a few hours and try again." + fallback + ) + if "403" in msg: + return ( + "Cloudflare is blocking the login request (HTTP 403). " + "This is a known upstream issue — try again later or update garminconnect " + "(uv sync --extra garmin)." + fallback + ) + if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower(): + return ( + "Garmin's login page returned a CAPTCHA or MFA challenge that " + "cannot be completed automatically. Try again later, or disable " + "two-factor authentication on your Garmin account." + fallback + ) + return f"Login failed: {exc}" + fallback + + +@router.get("/api/garmin/status") +async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Return whether Garmin credentials are stored for the current user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + from bincio.extract.garmin_api import has_credentials + from bincio.extract.garmin_sync import _load_sync_state + connected = has_credentials(dd) + last_sync = None + if connected: + state = _load_sync_state(dd) + last_sync = state.get("last_sync_at") + return JSONResponse({"connected": connected, "last_sync": last_sync}) + + +@router.post("/api/garmin/connect") +async def garmin_connect( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Test Garmin login with the supplied credentials and save them on success.""" + user = deps._require_user(bincio_session) + body = await request.json() + email = (body.get("email") or "").strip() + password = body.get("password") or "" + if not email or not password: + raise HTTPException(400, "email and password are required") + + data_dir = deps._get_data_dir() + user_dir = data_dir / user.handle + from bincio.extract.garmin_api import GarminError, test_login + try: + info = test_login(data_dir, user_dir, email, password) + except GarminError as exc: + raise HTTPException(400, _garmin_user_message(exc)) + return JSONResponse({"ok": True, **info}) + + +@router.post("/api/garmin/disconnect") +async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Remove stored Garmin credentials and session for the current user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + from bincio.extract.garmin_api import delete_credentials + delete_credentials(dd) + return JSONResponse({"ok": True}) + + +@router.get("/api/garmin/sync/stream") +async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: + """SSE endpoint — streams per-activity Garmin sync progress.""" + user = deps._require_user(bincio_session) + data_dir = deps._get_data_dir() + user_dir = data_dir / user.handle + + from bincio.extract.garmin_api import GarminError, has_credentials + if not has_credentials(user_dir): + raise HTTPException(400, "No Garmin credentials stored — connect first") + + from bincio.extract.garmin_sync import garmin_sync_iter + + def event_stream(): + try: + for event in garmin_sync_iter(data_dir, user_dir): + if event["type"] == "done": + tasks._trigger_rebuild(user.handle) + yield f"data: {json.dumps(event)}\n\n" + except GarminError as exc: + yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/bincio/serve/routers/ideas.py b/bincio/serve/routers/ideas.py new file mode 100644 index 0000000..fb2c7d6 --- /dev/null +++ b/bincio/serve/routers/ideas.py @@ -0,0 +1,229 @@ +"""Ideas and feedback endpoints (/api/ideas/*, /api/feedback).""" +from __future__ import annotations + +import fcntl as _fcntl +import json +import secrets +import time +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +from bincio.serve import deps +from bincio.serve.models import IdeaBody + +router = APIRouter() + +_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} +_FEEDBACK_MAX_IMAGES = 3 +_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB + + +def _ideas_dir(data_dir: Path) -> Path: + d = data_dir / "_ideas" + d.mkdir(parents=True, exist_ok=True) + return d + + +@router.get("/api/ideas") +async def list_ideas( + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + ideas = [] + for path in sorted(_ideas_dir(dd).glob("*.json")): + try: + idea = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + votes = idea.get("votes", []) + idea["vote_count"] = len(votes) + idea["my_vote"] = user.handle in votes + ideas.append(idea) + ideas.sort(key=lambda x: (x.get("status") == "done", -x["vote_count"], -x["created_at"])) + return JSONResponse({"ideas": ideas}) + + +@router.post("/api/ideas") +async def create_idea( + data: IdeaBody, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + title = data.title.strip()[:200] + body = data.body.strip()[:2000] + if not title: + raise HTTPException(400, "Title required") + dd = deps._get_data_dir() + idea_id = secrets.token_hex(8) + idea = { + "id": idea_id, + "title": title, + "body": body, + "author": user.handle, + "created_at": int(time.time()), + "votes": [], + } + path = _ideas_dir(dd) / f"{idea_id}.json" + path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8") + return JSONResponse({"id": idea_id}) + + +@router.post("/api/ideas/{idea_id}/vote") +async def toggle_idea_vote( + idea_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + with open(path, "r+", encoding="utf-8") as f: + _fcntl.flock(f, _fcntl.LOCK_EX) + idea = json.load(f) + votes: list = idea.get("votes", []) + if user.handle in votes: + votes.remove(user.handle) + voted = False + else: + votes.append(user.handle) + voted = True + idea["votes"] = votes + f.seek(0) + f.truncate() + json.dump(idea, f, ensure_ascii=False, indent=2) + return JSONResponse({"voted": voted, "votes": len(votes)}) + + +@router.post("/api/ideas/{idea_id}/status") +async def toggle_idea_status( + idea_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + if not user.is_admin: + raise HTTPException(403, "Forbidden") + dd = deps._get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + with open(path, "r+", encoding="utf-8") as f: + _fcntl.flock(f, _fcntl.LOCK_EX) + idea = json.load(f) + idea["status"] = "open" if idea.get("status") == "done" else "done" + f.seek(0) + f.truncate() + json.dump(idea, f, ensure_ascii=False, indent=2) + return JSONResponse({"status": idea["status"]}) + + +@router.patch("/api/ideas/{idea_id}") +async def edit_idea( + idea_id: str, + data: IdeaBody, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + title = data.title.strip()[:200] + body = data.body.strip()[:2000] + if not title: + raise HTTPException(400, "Title required") + with open(path, "r+", encoding="utf-8") as f: + _fcntl.flock(f, _fcntl.LOCK_EX) + idea = json.load(f) + if not user.is_admin and idea.get("author") != user.handle: + raise HTTPException(403, "Forbidden") + idea["title"] = title + idea["body"] = body + f.seek(0) + f.truncate() + json.dump(idea, f, ensure_ascii=False, indent=2) + return JSONResponse({"ok": True, "title": title, "body": body}) + + +@router.delete("/api/ideas/{idea_id}") +async def delete_idea( + idea_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + try: + idea = json.loads(path.read_text(encoding="utf-8")) + except Exception: + raise HTTPException(500, "Could not read idea") + if not user.is_admin and idea.get("author") != user.handle: + raise HTTPException(403, "Forbidden") + path.unlink() + return JSONResponse({"ok": True}) + + +# ── Feedback ────────────────────────────────────────────────────────────────── + +@router.post("/api/feedback") +async def submit_feedback( + text: str = Form(""), + images: list[UploadFile] = File(default=[]), + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + + text = text.strip() + if not text and not any(f.filename for f in images): + raise HTTPException(400, "Feedback must include text or at least one image") + if len(images) > _FEEDBACK_MAX_IMAGES: + raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission") + + feedback_dir = deps._get_data_dir() / "_feedback" + feedback_dir.mkdir(exist_ok=True) + images_dir = feedback_dir / user.handle + images_dir.mkdir(exist_ok=True) + + now = int(time.time()) + submission_id = f"{now}_{secrets.token_hex(4)}" + saved_images: list[str] = [] + + for img in images: + if not img.filename: + continue + suffix = Path(img.filename).suffix.lower() + if suffix not in _FEEDBACK_IMAGE_SUFFIXES: + raise HTTPException(400, f"Unsupported image type '{suffix}'") + contents = await img.read() + if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit") + safe_name = f"{submission_id}_{Path(img.filename).name}" + (images_dir / safe_name).write_bytes(contents) + saved_images.append(safe_name) + + from datetime import datetime, timezone + entry = { + "id": submission_id, + "handle": user.handle, + "submitted_at": datetime.now(timezone.utc).isoformat(), + "text": text, + "images": saved_images, + } + + log_file = feedback_dir / f"{user.handle}.json" + existing: list[dict] = [] + if log_file.exists(): + try: + existing = json.loads(log_file.read_text()) + except Exception: + existing = [] + existing.append(entry) + log_file.write_text(json.dumps(existing, indent=2)) + + return JSONResponse({"ok": True, "id": submission_id}) diff --git a/bincio/serve/routers/me.py b/bincio/serve/routers/me.py new file mode 100644 index 0000000..bb2f04f --- /dev/null +++ b/bincio/serve/routers/me.py @@ -0,0 +1,293 @@ +"""Self-service user settings endpoints (/api/me/*).""" +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse + +from bincio.serve import deps, tasks +from bincio.serve.db import ( + authenticate, + get_user_prefs, + set_user_prefs, +) + +router = APIRouter() + + +def _wipe_user_activities(user_dir: Path) -> int: + """Delete all extracted activity files and caches for a user. + + Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/, + _merged/, index.json, athlete.json, and the dedup cache. + Leaves the user directory itself intact (account remains in the DB). + Returns the number of files deleted. + """ + import shutil + deleted = 0 + + for subdir in ("activities", "edits", "originals"): + d = user_dir / subdir + if d.exists(): + for f in d.rglob("*"): + if f.is_file(): + deleted += 1 + shutil.rmtree(d) + + for name in ("_merged", ): + d = user_dir / name + if d.exists(): + shutil.rmtree(d) + + for name in ("index.json", "athlete.json", ".bincio_cache.json"): + f = user_dir / name + if f.exists(): + f.unlink() + deleted += 1 + + return deleted + + +@router.get("/api/me/storage") +async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Return per-category disk usage for the logged-in user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + + def _mb(path: Path) -> float: + if not path.exists(): + return 0.0 + total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink()) + return round(total / 1_048_576, 2) + + def _count(path: Path, pattern: str = "*") -> int: + if not path.exists(): + return 0 + return sum(1 for f in path.glob(pattern) if f.is_file()) + + activities_mb = _mb(dd / "activities") + originals_mb = _mb(dd / "originals") + strava_mb = _mb(dd / "originals" / "strava") + images_mb = _mb(dd / "edits" / "images") + total_mb = _mb(dd) + + return JSONResponse({ + "total_mb": total_mb, + "activities_mb": activities_mb, + "activities_count": _count(dd / "activities", "*.json"), + "originals_mb": originals_mb, + "strava_originals_mb": strava_mb, + "strava_originals_count": _count(dd / "originals" / "strava", "*.json"), + "images_mb": images_mb, + }) + + +@router.delete("/api/me/originals") +async def me_delete_originals(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Delete the user's originals/ directory (frees space after re-extraction).""" + user = deps._require_user(bincio_session) + originals = deps._get_data_dir() / user.handle / "originals" + if not originals.exists(): + return JSONResponse({"ok": True, "freed_mb": 0.0}) + + freed = round( + sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2 + ) + shutil.rmtree(originals) + return JSONResponse({"ok": True, "freed_mb": freed}) + + +@router.delete("/api/me/activities") +async def me_delete_activities( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON). + + Requires the user's current password in the request body for confirmation. + """ + user = deps._require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(deps._get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + user_dir = deps._get_data_dir() / user.handle + deleted = _wipe_user_activities(user_dir) + tasks._trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "deleted": deleted}) + + +@router.delete("/api/me") +async def me_delete_account( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Delete the account and all data permanently. + + Requires the user's current password. Deletes the DB row, all sessions, + and the entire user data directory. The root shard manifest is updated. + """ + user = deps._require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(deps._get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + # Wipe data directory + user_dir = deps._get_data_dir() / user.handle + if user_dir.is_dir(): + shutil.rmtree(user_dir) + + # Remove from DB (cascades to sessions, invites, reset_codes) + from bincio.serve.db import delete_user as _delete_user + _delete_user(deps._get_db(), user.handle) + + # Update root manifest so the shard disappears + from bincio.render.cli import _write_root_manifest + try: + _write_root_manifest(deps._get_data_dir()) + except Exception: + pass + + resp = JSONResponse({"ok": True}) + resp.delete_cookie(deps._SESSION_COOKIE) + return resp + + +@router.put("/api/me/display-name") +async def me_update_display_name( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Update the logged-in user's display name.""" + user = deps._require_user(bincio_session) + body = await request.json() + display_name = str(body.get("display_name", "")).strip() + if len(display_name) > 60: + raise HTTPException(400, "Display name too long (max 60 characters)") + db = deps._get_db() + db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle)) + db.commit() + return JSONResponse({"ok": True, "display_name": display_name}) + + +@router.get("/api/me/prefs") +async def me_get_prefs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Return all user preferences as a key→value dict.""" + user = deps._require_user(bincio_session) + return JSONResponse(get_user_prefs(deps._get_db(), user.handle)) + + +@router.put("/api/me/prefs") +async def me_set_prefs( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Upsert one or more user preferences. Body: {key: value, ...} (all strings).""" + user = deps._require_user(bincio_session) + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(400, "Body must be a JSON object") + # Coerce all values to strings; ignore unknown keys silently + prefs = {str(k): str(v) for k, v in body.items()} + set_user_prefs(deps._get_db(), user.handle, prefs) + return JSONResponse({"ok": True}) + + +@router.get("/api/me/strava-credentials") +async def me_get_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Return whether per-user Strava credentials are configured (never returns the secret).""" + user = deps._require_user(bincio_session) + creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE + has_user_creds = False + client_id_hint = "" + if creds_path.exists(): + try: + d = json.loads(creds_path.read_text(encoding="utf-8")) + cid = str(d.get("client_id", "")).strip() + csec = str(d.get("client_secret", "")).strip() + if cid and csec: + has_user_creds = True + client_id_hint = cid + except Exception: + pass + return JSONResponse({ + "has_user_creds": has_user_creds, + "client_id": client_id_hint, + "instance_configured": bool(deps.strava_client_id), + }) + + +@router.put("/api/me/strava-credentials") +async def me_set_strava_credentials( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Save per-user Strava credentials. Body: {client_id, client_secret}.""" + user = deps._require_user(bincio_session) + body = await request.json() + cid = str(body.get("client_id", "")).strip() + csec = str(body.get("client_secret", "")).strip() + if not cid: + raise HTTPException(400, "client_id is required") + creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE + # If client_secret is omitted, preserve existing secret (if any) + if not csec: + if creds_path.exists(): + try: + existing = json.loads(creds_path.read_text(encoding="utf-8")) + csec = str(existing.get("client_secret", "")).strip() + except Exception: + pass + if not csec: + raise HTTPException(400, "client_secret is required (no existing secret to preserve)") + + # If the client_id changed, the existing token belongs to a different OAuth + # app and will fail on refresh — delete it so the user must re-authenticate. + token_path = deps._get_data_dir() / user.handle / "strava_token.json" + if creds_path.exists() and token_path.exists(): + try: + old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip() + if old_cid and old_cid != cid: + token_path.unlink(missing_ok=True) + except Exception: + pass + + creds_path.write_text( + json.dumps({"client_id": cid, "client_secret": csec}, indent=2), + encoding="utf-8", + ) + return JSONResponse({"ok": True}) + + +@router.delete("/api/me/strava-credentials") +async def me_delete_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + """Remove per-user Strava credentials (falls back to instance credentials).""" + user = deps._require_user(bincio_session) + creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE + creds_path.unlink(missing_ok=True) + return JSONResponse({"ok": True}) + + +@router.put("/api/me/password") +async def me_change_password( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + """Change the logged-in user's password. Requires current password.""" + from bincio.serve.db import change_password as _change_password + user = deps._require_user(bincio_session) + body = await request.json() + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + if not authenticate(deps._get_db(), user.handle, current): + raise HTTPException(401, "Current password is wrong") + if len(new_pw) < 8: + raise HTTPException(400, "New password must be at least 8 characters") + _change_password(deps._get_db(), user.handle, new_pw) + return JSONResponse({"ok": True}) diff --git a/bincio/serve/routers/segments.py b/bincio/serve/routers/segments.py new file mode 100644 index 0000000..f740a8a --- /dev/null +++ b/bincio/serve/routers/segments.py @@ -0,0 +1,293 @@ +"""Segments endpoints (/api/segments/*).""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Cookie, HTTPException +from fastapi.responses import JSONResponse + +from bincio.serve import deps +from bincio.serve.models import CreateSegmentRequest +from bincio.segments import models as _seg_models +from bincio.segments import store as _seg_store + +router = APIRouter() + + +def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int: + """Scan all of a user's activities against one segment. Returns effort count.""" + from datetime import datetime as _datetime + from bincio.segments.detect import track_from_timeseries_json, detect_one + + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + return 0 + user_dir = dd / handle + acts_dir = user_dir / "activities" + total = 0 + for detail_path in sorted(acts_dir.glob("*.json")): + if ".timeseries." in detail_path.name: + continue + try: + detail = json.loads(detail_path.read_text(encoding="utf-8")) + except Exception: + continue + ts_url = detail.get("timeseries_url") + if not ts_url: + continue + ts_path = user_dir / ts_url + if not ts_path.exists(): + continue + try: + ts = json.loads(ts_path.read_text(encoding="utf-8")) + except Exception: + continue + started_raw = detail.get("started_at") + if not started_raw: + continue + try: + started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00")) + except Exception: + continue + track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem), + detail.get("sport", "other"), started_at) + if track is None: + continue + efforts = detect_one(track, seg) + for effort in efforts: + _seg_store.add_effort(dd, handle, segment_id, effort) + total += len(efforts) + return total + + +@router.get("/api/segments") +async def get_segments( + bbox: Optional[str] = None, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """List segments, optionally filtered to a map viewport bbox (lon_min,lat_min,lon_max,lat_max).""" + deps._require_user(bincio_session) + parsed_bbox: Optional[list[float]] = None + if bbox: + try: + parts = [float(x) for x in bbox.split(",")] + if len(parts) == 4: + parsed_bbox = parts + except ValueError: + raise HTTPException(400, "bbox must be four comma-separated floats") + dd = deps._get_data_dir() + segs = _seg_store.list_segments(dd, parsed_bbox) + return JSONResponse([{ + "id": s.id, + "name": s.name, + "sport": s.sport, + "distance_m": s.distance_m, + "bbox": s.bbox, + "polyline": s.polyline, + "created_by": s.created_by, + "created_at": _seg_store._iso(s.created_at), + } for s in segs]) + + +@router.get("/api/segments/{segment_id}") +async def get_segment(segment_id: str) -> JSONResponse: + """Return metadata for a single segment.""" + dd = deps._get_data_dir() + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + raise HTTPException(404, "Segment not found") + return JSONResponse({ + "id": seg.id, + "name": seg.name, + "sport": seg.sport, + "polyline": seg.polyline, + "distance_m": seg.distance_m, + "bbox": seg.bbox, + "created_by": seg.created_by, + "created_at": _seg_store._iso(seg.created_at), + }) + + +@router.post("/api/segments") +async def create_segment( + body: CreateSegmentRequest, + background_tasks: BackgroundTasks, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + if len(body.polyline) < 2: + raise HTTPException(400, "polyline must have at least 2 points") + if body.distance_m < 500: + raise HTTPException(400, "segment must be at least 500 m long") + + lats = [p[0] for p in body.polyline] + lons = [p[1] for p in body.polyline] + bbox = [min(lons), min(lats), max(lons), max(lats)] + + seg_id = _seg_store.make_segment_id(body.name) + from datetime import datetime, timezone as _tz + seg = _seg_models.Segment( + id=seg_id, + name=body.name, + sport=body.sport or None, + polyline=body.polyline, + distance_m=body.distance_m, + bbox=bbox, + created_by=user.handle, + created_at=datetime.now(_tz.utc), + ) + dd = deps._get_data_dir() + _seg_store.save_segment(dd, seg) + background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id) + return JSONResponse({"id": seg_id}, status_code=201) + + +@router.delete("/api/segments/{segment_id}") +async def delete_segment( + segment_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + raise HTTPException(404, "Segment not found") + if seg.created_by != user.handle and not user.is_admin: + raise HTTPException(403, "Not allowed") + _seg_store.delete_segment(dd, segment_id) + return JSONResponse({"ok": True}) + + +@router.get("/api/segments/{segment_id}/efforts") +async def get_segment_efforts( + segment_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Return all efforts on a segment for the logged-in user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + raise HTTPException(404, "Segment not found") + efforts = _seg_store.load_efforts(dd, user.handle, segment_id) + return JSONResponse([ + { + "activity_id": e.activity_id, + "started_at": _seg_store._iso(e.started_at), + "elapsed_s": e.elapsed_s, + "avg_speed_kmh": e.avg_speed_kmh, + "avg_hr_bpm": e.avg_hr_bpm, + "avg_power_w": e.avg_power_w, + "np_power_w": e.np_power_w, + } + for e in efforts + ]) + + +@router.post("/api/segments/{segment_id}/detect") +async def trigger_detect( + segment_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Retroactively detect efforts on a segment for the logged-in user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + if _seg_store.load_segment(dd, segment_id) is None: + raise HTTPException(404, "Segment not found") + _seg_store.save_efforts(dd, user.handle, segment_id, []) + total = _scan_segment_for_user(dd, user.handle, segment_id) + return JSONResponse({"ok": True, "efforts_found": total}) + + +@router.post("/api/me/segment-rescan") +async def me_segment_rescan( + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Retroactively detect efforts for ALL segments across ALL activities for the logged-in user.""" + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() + user_dir = dd / user.handle + acts_dir = user_dir / "activities" + + from datetime import datetime as _datetime + from bincio.segments.detect import track_from_timeseries_json, detect_one + import json as _json + + segments = _seg_store.list_segments(dd) + if not segments: + return JSONResponse({"ok": True, "efforts_found": 0}) + + for seg in segments: + _seg_store.save_efforts(dd, user.handle, seg.id, []) + + total = 0 + for detail_path in sorted(acts_dir.glob("*.json")): + if ".timeseries." in detail_path.name: + continue + try: + detail = _json.loads(detail_path.read_text(encoding="utf-8")) + except Exception: + continue + ts_url = detail.get("timeseries_url") + if not ts_url: + continue + ts_path = user_dir / ts_url + if not ts_path.exists(): + continue + try: + ts = _json.loads(ts_path.read_text(encoding="utf-8")) + except Exception: + continue + started_raw = detail.get("started_at") + if not started_raw: + continue + try: + started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00")) + except Exception: + continue + track = track_from_timeseries_json( + ts, detail.get("id", detail_path.stem), + detail.get("sport", "other"), started_at, + ) + if track is None: + continue + for seg in segments: + efforts = detect_one(track, seg) + for effort in efforts: + _seg_store.add_effort(dd, user.handle, seg.id, effort) + total += len(efforts) + + return JSONResponse({"ok": True, "efforts_found": total}) + + +@router.get("/api/users/{handle}/segment_summary") +async def user_segment_summary(handle: str) -> JSONResponse: + """Public endpoint: segments where this user has efforts, with best time and count.""" + dd = deps._get_data_dir() + efforts_dir = dd / handle / "segment_efforts" + result = [] + if efforts_dir.exists(): + for ef_file in sorted(efforts_dir.glob("*.json")): + seg_id = ef_file.stem + efforts = _seg_store.load_efforts(dd, handle, seg_id) + if not efforts: + continue + seg = _seg_store.load_segment(dd, seg_id) + if not seg: + continue + best = min(efforts, key=lambda e: e.elapsed_s) + result.append({ + "segment": { + "id": seg.id, + "name": seg.name, + "sport": seg.sport, + "distance_m": seg.distance_m, + }, + "best_elapsed_s": best.elapsed_s, + "best_activity_id": best.activity_id, + "effort_count": len(efforts), + }) + result.sort(key=lambda x: x["segment"]["name"].lower()) + return JSONResponse(result) diff --git a/bincio/serve/routers/strava.py b/bincio/serve/routers/strava.py new file mode 100644 index 0000000..72ce5f2 --- /dev/null +++ b/bincio/serve/routers/strava.py @@ -0,0 +1,192 @@ +"""Strava integration endpoints (/api/strava/*).""" +from __future__ import annotations + +import json +import secrets +from typing import Optional + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse + +from bincio.serve import deps, tasks +from bincio.serve.db import get_setting + +router = APIRouter() + +_strava_oauth_states: set[str] = set() + + +@router.get("/api/strava/status") +async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + cid, _ = deps._strava_creds(user.handle) + if not cid: + return JSONResponse({"configured": False, "connected": False, "last_sync": None}) + dd = deps._get_data_dir() / user.handle + from bincio.extract.strava_api import load_token + token = load_token(dd) + return JSONResponse({ + "configured": True, + "connected": token is not None, + "last_sync": token.get("last_sync_at") if token else None, + }) + + +@router.post("/api/strava/disconnect") +async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Remove the stored Strava token, forcing a fresh OAuth on next connect.""" + user = deps._require_user(bincio_session) + token_path = deps._get_data_dir() / user.handle / "strava_token.json" + token_path.unlink(missing_ok=True) + return JSONResponse({"ok": True}) + + +@router.post("/api/strava/reset") +async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Reset last_sync_at so the next sync re-fetches from a chosen point. + + mode=soft — set to the started_at of the most recent activity on disk + (next sync only fetches activities newer than the last known one) + mode=hard — clear last_sync_at entirely + (next sync re-downloads full Strava history, skipping existing files) + """ + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + from bincio.extract.strava_api import load_token, save_token + token = load_token(dd) + if token is None: + raise HTTPException(400, "Not connected to Strava") + + body = await request.json() + mode = body.get("mode", "soft") + + if mode == "hard": + token.pop("last_sync_at", None) + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None}) + + # soft: find the most recent started_at across the user's merged index + from datetime import datetime, timezone + last_ts: int | None = None + for index_path in [dd / "_merged" / "index.json", dd / "index.json"]: + if not index_path.exists(): + continue + try: + index_data = json.loads(index_path.read_text(encoding="utf-8")) + started_ats = [ + a.get("started_at") for a in index_data.get("activities", []) + if a.get("started_at") + ] + if started_ats: + latest = max(started_ats) + dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) + last_ts = int(dt.astimezone(timezone.utc).timestamp()) + break + except Exception: + continue + + if last_ts is None: + token.pop("last_sync_at", None) + else: + token["last_sync_at"] = last_ts + save_token(dd, token) + return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) + + +@router.get("/api/strava/auth-url") +async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + cid, _ = deps._strava_creds(user.handle) + if not cid: + raise HTTPException(400, "Strava client ID not configured on this server") + state = secrets.token_urlsafe(16) + _strava_oauth_states.add(state) + if deps.public_url: + redirect_uri = deps.public_url.rstrip("/") + "/api/strava/callback" + else: + redirect_uri = str(request.url_for("strava_callback")) + from bincio.extract.strava_api import auth_url + return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)}) + + +@router.get("/api/strava/callback", name="strava_callback") +async def strava_callback( + request: Request, + code: str = "", + error: str = "", + state: str = "", + bincio_session: Optional[str] = Cookie(default=None), +) -> RedirectResponse: + site_origin = deps.public_url.rstrip("/") if deps.public_url else str(request.base_url).rstrip("/") + if error or not code: + return RedirectResponse(f"{site_origin}/?strava=error") + if state not in _strava_oauth_states: + return RedirectResponse(f"{site_origin}/?strava=error") + _strava_oauth_states.discard(state) + user = deps._current_user(bincio_session) + if not user: + return RedirectResponse(f"{site_origin}/?strava=error") + cid, csec = deps._strava_creds(user.handle) + if not cid or not csec: + return RedirectResponse(f"{site_origin}/?strava=error") + dd = deps._get_data_dir() / user.handle + from bincio.extract.strava_api import StravaError, exchange_code, save_token + try: + token = exchange_code(cid, csec, code) + except StravaError: + return RedirectResponse(f"{site_origin}/?strava=error") + save_token(dd, token) + return RedirectResponse(f"{site_origin}/?strava=connected") + + +@router.get("/api/strava/sync/stream") +async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: + """SSE endpoint — streams per-activity progress then a final summary event.""" + user = deps._require_user(bincio_session) + cid, csec = deps._strava_creds(user.handle) + if not cid or not csec: + raise HTTPException(400, "Strava not configured on this server") + dd = deps._get_data_dir() / user.handle + store_orig_setting = get_setting(deps._get_db(), "store_originals") + store_orig = store_orig_setting == "true" + originals_dir = (dd / "originals" / "strava") if store_orig else None + if originals_dir: + originals_dir.mkdir(parents=True, exist_ok=True) + + from bincio.extract.ingest import strava_sync_iter + + def event_stream(): + try: + for event in strava_sync_iter(dd, cid, csec, originals_dir): + if event["type"] == "done": + tasks._trigger_rebuild(user.handle) # start before client closes connection + yield f"data: {json.dumps(event)}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/api/strava/sync") +async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + cid, csec = deps._strava_creds(user.handle) + if not cid or not csec: + raise HTTPException(400, "Strava not configured on this server") + dd = deps._get_data_dir() / user.handle + store_orig_setting = get_setting(deps._get_db(), "store_originals") + store_orig = store_orig_setting == "true" + originals_dir = (dd / "originals" / "strava") if store_orig else None + if originals_dir: + originals_dir.mkdir(parents=True, exist_ok=True) + from bincio.edit.ops import run_strava_sync + try: + result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir) + except RuntimeError as e: + raise HTTPException(502, str(e)) + tasks._trigger_rebuild(user.handle) + return JSONResponse(result) diff --git a/bincio/serve/routers/uploads.py b/bincio/serve/routers/uploads.py new file mode 100644 index 0000000..7b52fed --- /dev/null +++ b/bincio/serve/routers/uploads.py @@ -0,0 +1,501 @@ +"""File upload endpoints (/api/upload/*).""" +from __future__ import annotations + +import json +import logging +import uuid +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Cookie, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse + +from bincio.serve import deps, tasks + +log = logging.getLogger("bincio.serve") + +router = APIRouter() + +_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} + + +def _file_suffix(name: str) -> str: + """Return the effective suffix, including .gz double-extension.""" + p = Path(name.lower()) + if p.suffix == ".gz": + return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz" + return p.suffix + + +def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None: + """Add or update an activity summary in user_dir/index.json. + + Called after writing BAS activity files so that merge_all can include the + activity in year shards. Without this, uploaded activities exist on disk + but never appear in the browser feed. + """ + # Build preview coords from geojson if available ([lat, lng] order) + preview: Optional[list] = None + if geojson: + try: + coords = geojson.get("geometry", {}).get("coordinates", []) + if coords: + step = max(1, len(coords) // 9) + preview = [[c[1], c[0]] for c in coords[::step]][:9] + except Exception: + pass + + has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists() + summary = { + "id": activity_id, + "title": activity.get("title", activity_id), + "sport": activity.get("sport"), + "sub_sport": activity.get("sub_sport"), + "started_at": activity.get("started_at"), + "distance_m": activity.get("distance_m"), + "duration_s": activity.get("duration_s"), + "moving_time_s": activity.get("moving_time_s"), + "elevation_gain_m": activity.get("elevation_gain_m"), + "avg_speed_kmh": activity.get("avg_speed_kmh"), + "max_speed_kmh": activity.get("max_speed_kmh"), + "avg_hr_bpm": activity.get("avg_hr_bpm"), + "max_hr_bpm": activity.get("max_hr_bpm"), + "avg_cadence_rpm": activity.get("avg_cadence_rpm"), + "avg_power_w": activity.get("avg_power_w"), + "mmp": activity.get("mmp"), + "best_efforts": activity.get("best_efforts"), + "best_climb_m": activity.get("best_climb_m"), + "source": activity.get("source"), + "privacy": activity.get("privacy", "public"), + "detail_url": f"activities/{activity_id}.json", + "track_url": f"activities/{activity_id}.geojson" if has_track else None, + "preview_coords": preview, + } + + index_path = user_dir / "index.json" + if index_path.exists(): + index_data = json.loads(index_path.read_text(encoding="utf-8")) + else: + index_data = { + "bas_version": "1.0", + "owner": {"handle": user_dir.name}, + "generated_at": None, + "activities": [], + } + existing = {a["id"]: a for a in index_data.get("activities", [])} + existing[activity_id] = summary + index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True) + index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8") + + +@router.post("/api/upload/bas") +async def upload_bas_activity( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Accept a pre-extracted BAS activity JSON from the mobile app. + + Body (JSON): + activity – full BAS activity dict (required, must have 'id') + timeseries – timeseries dict (optional) + geojson – GeoJSON dict (optional) + + Returns: + {"ok": true, "id": "...", "status": "imported" | "duplicate"} + """ + user = deps._require_auth(request, bincio_session) + body = await request.json() + + activity = body.get("activity") + if not activity or not activity.get("id"): + raise HTTPException(400, "Missing activity.id") + + activity_id = str(activity["id"]) + deps._check_id(activity_id) + + user_dir = deps._get_data_dir() / user.handle + acts_dir = user_dir / "activities" + acts_dir.mkdir(parents=True, exist_ok=True) + + out = acts_dir / f"{activity_id}.json" + if out.exists(): + return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"}) + + out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8") + + if body.get("timeseries"): + ts_path = acts_dir / f"{activity_id}.timeseries.json" + if not ts_path.exists(): + ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8") + + geojson_body: Optional[dict] = body.get("geojson") or None + if geojson_body: + gj_path = acts_dir / f"{activity_id}.geojson" + if not gj_path.exists(): + gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8") + + _upsert_index_summary(user_dir, activity_id, activity, geojson_body) + + try: + from bincio.render.merge import merge_one, write_combined_feed + merge_one(user_dir, activity_id) + write_combined_feed(deps._get_data_dir()) + except Exception as exc: + log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc) + + log.info("upload/bas[%s]: imported %s", user.handle, activity_id) + return JSONResponse({"ok": True, "id": activity_id, "status": "imported"}) + + +@router.post("/api/upload/raw") +async def upload_raw_activity( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it + server-side, store it in the user's activity library, and return the full + extracted data so the mobile can cache it locally. + + Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69). + + Body (JSON): + filename – original filename (used only to determine file extension) + base64 – base64-encoded raw file bytes + + Auth: Authorization: Bearer + + Returns: + {"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null, + "geojson": {...}|null, "source_hash": ""} + """ + import base64 as _b64 + import hashlib + import shutil + + user = deps._require_auth(request, bincio_session) + + body = await request.json() + filename_hint: str = body.get("filename") or "activity.fit" + b64: str = body.get("base64") or "" + user_title: Optional[str] = body.get("user_title") or None + if not b64: + raise HTTPException(400, "Missing base64 field") + + try: + raw = _b64.b64decode(b64) + except Exception: + raise HTTPException(400, "Invalid base64 encoding") + + source_hash = hashlib.sha256(raw).hexdigest() + + suffix = Path(filename_hint).suffix or ".fit" + tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}") + tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}") + try: + tmp_in.write_bytes(raw) + tmp_out.mkdir() + + from bincio.extract.parsers.factory import parse_file + from bincio.extract.metrics import compute + from bincio.extract.writer import make_activity_id, write_activity + from bincio.extract.timeseries import build_timeseries + + activity = parse_file(tmp_in) + metrics = compute(activity) + write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001) + act_id = make_activity_id(activity) + + acts_tmp = tmp_out / "activities" + detail_path = acts_tmp / f"{act_id}.json" + ts_path = acts_tmp / f"{act_id}.timeseries.json" + geojson_path = acts_tmp / f"{act_id}.geojson" + + if not ts_path.exists(): + ts_data = build_timeseries(activity.points, activity.started_at, "public") + if ts_data.get("t"): + ts_path.write_text(json.dumps(ts_data)) + + detail = json.loads(detail_path.read_text()) + timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None + geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None + + # Also store on the server so the activity appears in the user's feed. + user_dir = deps._get_data_dir() / user.handle + acts_dir = user_dir / "activities" + acts_dir.mkdir(parents=True, exist_ok=True) + out = acts_dir / f"{act_id}.json" + if not out.exists(): + out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8") + if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists(): + (acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8") + if geojson and not (acts_dir / f"{act_id}.geojson").exists(): + (acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8") + + _upsert_index_summary(user_dir, act_id, detail, geojson) + + if user_title: + import yaml as _yaml + edits_dir = user_dir / "edits" + edits_dir.mkdir(parents=True, exist_ok=True) + (edits_dir / f"{act_id}.md").write_text( + f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n", + encoding="utf-8", + ) + + except Exception as exc: + log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc) + raise HTTPException(422, f"Could not extract activity: {exc}") from exc + finally: + tmp_in.unlink(missing_ok=True) + shutil.rmtree(tmp_out, ignore_errors=True) + + # Merge and update feed — best effort; a race or transient FS error here must + # not turn a successful extraction into a 422 (the file is on disk; the mobile + # would retry indefinitely and the activity would never be marked synced). + try: + from bincio.render.merge import merge_one, write_combined_feed + merge_one(user_dir, act_id) + write_combined_feed(deps._get_data_dir()) + except Exception as exc: + log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc) + + log.info("upload/raw[%s]: imported %s", user.handle, act_id) + return JSONResponse({ + "ok": True, + "id": act_id, + "detail": detail, + "timeseries": timeseries, + "geojson": geojson, + "source_hash": source_hash, + }) + + +@router.post("/api/upload") +async def upload_activity( + files: list[UploadFile] = File(...), + store_original: bool = Form(False), + overwrite: bool = Form(False), + bincio_session: Optional[str] = Cookie(default=None), +) -> StreamingResponse: + """Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing. + + activities.csv (Strava export format) can be included in the batch to: + - Enrich activity files in the same batch (matched by filename) + - Retroactively update sidecars for existing activities (matched by strava_id) + + SSE events: + {"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"} + {"type": "csv", "updates": N} -- only when CSV was included + {"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N} + """ + from bincio.extract.ingest import ingest_parsed + from bincio.extract.parsers.factory import parse_file + from bincio.extract.writer import make_activity_id + from bincio.render.merge import merge_all + + user = deps._require_user(bincio_session) + dd = deps._get_data_dir() / user.handle + staging = dd / "_uploads" + staging.mkdir(exist_ok=True) + + # Read all files into memory now (async), then process synchronously in the generator + csv_bytes_list: list[bytes] = [] + activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes) + + for f in files: + fname = Path(f.filename or "").name + raw = await f.read() + if fname.lower().endswith(".csv"): + csv_bytes_list.append(raw) + else: + activity_items.append((fname, raw)) + + # Build metadata from the first CSV + metadata = None + if csv_bytes_list: + from bincio.extract.strava_csv import StravaMetadata + import tempfile + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp: + tmp.write(csv_bytes_list[0]) + tmp_path = Path(tmp.name) + try: + metadata = StravaMetadata(tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + + total_files = len(activity_items) + job_id = tasks._job_start(user.handle, total_files) if total_files > 0 else None + + def event_stream(): + added = 0 + overwritten = 0 + duplicates = 0 + errors = 0 + any_added = False + + for n, (name, contents) in enumerate(activity_items, 1): + if job_id: + tasks._job_update(job_id, n - 1, name) + + suffix = _file_suffix(name) + if suffix not in _SUPPORTED_SUFFIXES: + errors += 1 + yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n" + continue + + if len(contents) > 50 * 1024 * 1024: + errors += 1 + yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n" + continue + + staged = staging / name + staged.write_bytes(contents) + kept = False + try: + activity = parse_file(staged) + if metadata is not None: + metadata.enrich(name, activity) + activity_id = make_activity_id(activity) + was_overwrite = False + if (dd / "activities" / f"{activity_id}.json").exists(): + if not overwrite: + duplicates += 1 + yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n" + continue + # Overwrite: delete existing files before re-ingesting. + for ext in (".json", ".geojson", ".timeseries.json"): + (dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True) + # Remove stale summary from index so ingest_parsed writes a clean one + index_path = dd / "index.json" + if index_path.exists(): + idx = json.loads(index_path.read_text(encoding="utf-8")) + idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id] + index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False)) + # Remove from dedup hash cache so the new file isn't blocked + cache_path = dd / ".bincio_cache.json" + if cache_path.exists(): + try: + cache = json.loads(cache_path.read_text(encoding="utf-8")) + cache.pop(activity_id, None) + cache_path.write_text(json.dumps(cache, ensure_ascii=False)) + except Exception: + pass + # Remove merged copies (merge_all will regenerate them after ingest) + merged_acts = dd / "_merged" / "activities" + if merged_acts.exists(): + for ext in (".json", ".geojson", ".timeseries.json"): + p = merged_acts / f"{activity_id}{ext}" + if p.exists() or p.is_symlink(): + p.unlink(missing_ok=True) + was_overwrite = True + ingest_parsed(activity, dd, privacy="public") + if store_original: + originals_dir = dd / "originals" + originals_dir.mkdir(exist_ok=True) + staged.rename(originals_dir / name) + kept = True + if was_overwrite: + overwritten += 1 + else: + added += 1 + any_added = True + status = 'overwritten' if was_overwrite else 'imported' + yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n" + except Exception as exc: + errors += 1 + log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True) + yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n" + finally: + if not kept: + staged.unlink(missing_ok=True) + + # Retroactively apply CSV metadata to existing activities + csv_updates = 0 + if metadata is not None: + from bincio.extract.strava_csv import apply_csv_to_data_dir + csv_updates = apply_csv_to_data_dir(dd, metadata) + if csv_updates: + yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n" + + if any_added or csv_updates: + merge_all(dd) + if any_added: + tasks._trigger_rebuild(user.handle) + + yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n" + + if job_id: + tasks._job_finish(job_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/api/upload/strava-zip") +async def upload_strava_zip( + file: UploadFile = File(...), + private: str = Form(default="false"), + bincio_session: Optional[str] = Cookie(default=None), +) -> StreamingResponse: + """Accept a Strava bulk export ZIP and stream SSE progress while processing. + + The ZIP is written to a temp file, processed activity-by-activity, then deleted. + Originals are never kept — the UI informs the user of this upfront. + """ + user = deps._require_user(bincio_session) + if not file.filename or not file.filename.lower().endswith(".zip"): + raise HTTPException(400, "Please upload a .zip file") + + privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public" + + dd = deps._get_data_dir() / user.handle + import tempfile + tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + zip_path = Path(tmp.name) + try: + while chunk := await file.read(1024 * 1024): # 1 MB chunks + tmp.write(chunk) + finally: + tmp.close() + + from bincio.extract.strava_zip import strava_zip_iter + from bincio.render.merge import merge_all + + log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy) + + def event_stream(): + any_imported = False + imported_count = 0 + error_count = 0 + try: + for event in strava_zip_iter(zip_path, dd, privacy=privacy): + yield f"data: {json.dumps(event)}\n\n" + if event.get("type") == "progress": + status = event.get("status") + if status == "imported": + any_imported = True + imported_count += 1 + elif status == "error": + error_count += 1 + log.warning("strava-zip[%s]: error on %s: %s", + user.handle, event.get("name"), event.get("detail", "")) + if event.get("type") == "done": + log.info("strava-zip[%s]: done — imported=%d errors=%d", + user.handle, imported_count, error_count) + if any_imported: + merge_all(dd) + tasks._trigger_rebuild(user.handle) + except Exception as exc: + log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True) + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + finally: + zip_path.unlink(missing_ok=True) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 2d2be7b..dd44678 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -5,230 +5,44 @@ nginx serves static files; this server only handles /api/* routes. Run via `bincio serve` CLI command. """ - from __future__ import annotations -import json -import logging -import os -import re -import secrets -import shutil -import subprocess +import glob as _glob import threading -import time -import uuid from pathlib import Path -from typing import Any, Optional -log = logging.getLogger("bincio.serve") - -from fastapi import BackgroundTasks, Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile -from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import JSONResponse -from bincio.serve.db import ( - User, - authenticate, - count_activity_users, - count_wiki_users, - create_invite, - create_session, - count_users, - create_user, - delete_session, - get_invite, - get_member_tree, - get_session, - get_setting, - get_user, - get_user_prefs, - set_user_prefs, - list_invites, - list_users, - open_db, - use_invite, +from bincio.serve import deps, tasks +from bincio.serve.routers import ( + activities, + admin, + auth, + feed, + garmin, + ideas, + me, + segments, + strava, + uploads, ) -from pydantic import BaseModel, Field - -# ── Pydantic request/response models ───────────────────────────────────────── - - -class LoginRequest(BaseModel): - handle: str = Field(..., description="User handle (username)") - password: str = Field(..., description="User password") - - -class LoginResponse(BaseModel): - ok: bool = Field(True, description="Success flag") - handle: str = Field(..., description="User handle") - display_name: str = Field(..., description="User's display name") - - -class ResetPasswordRequest(BaseModel): - handle: str = Field(..., description="User handle") - code: str = Field(..., description="Reset code (24 hours valid)") - password: str = Field(..., description="New password (min 8 chars)") - - -class RegisterRequest(BaseModel): - code: str = Field(..., description="Invite code") - handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)") - password: str = Field(..., description="Password (min 8 characters)") - display_name: str = Field(default="", description="Full name (optional, defaults to handle)") - - -class RegisterResponse(BaseModel): - ok: bool = Field(True, description="Success flag") - handle: str = Field(..., description="New user's handle") - - -class CurrentUserResponse(BaseModel): - handle: str = Field(..., description="User handle") - display_name: str = Field(..., description="User's display name") - is_admin: bool = Field(..., description="Whether user is an admin") - store_originals_default: bool = Field( - default=True, - description="Instance-wide default for storing original files" - ) - - -class ActivityEditRequest(BaseModel): - title: str | None = Field(default=None, description="Activity title") - description: str | None = Field(default=None, description="Activity description (markdown)") - sport: str | None = Field(default=None, description="Sport type") - sub_sport: str | None = Field(default=None, description="Sport sub-category (e.g. road, trail, pool)") - private: bool | None = Field(default=None, description="Hide from public feed") - highlight: bool | None = Field(default=None, description="Mark as favorite") - gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')") - - -class ActivityEditResponse(BaseModel): - ok: bool = Field(True, description="Success flag") - - -class ResetPasswordCodeResponse(BaseModel): - ok: bool = Field(True, description="Success flag") - code: str = Field(..., description="One-time reset code") - expires_in_hours: int = Field(24, description="Code validity period in hours") - - -class GenericResponse(BaseModel): - ok: bool = Field(True, description="Success flag") - - -class CreateSegmentRequest(BaseModel): - name: str = Field(..., description="Segment name") - sport: Optional[str] = Field(default=None, description="Sport filter (e.g. cycling)") - polyline: list[list[float]] = Field(..., description="[[lat, lon], ...] ordered GPS points") - distance_m: float = Field(..., description="Segment length in metres") - - -# ── Active job tracker ─────────────────────────────────────────────────────── -# Tracks in-progress upload/processing jobs so admins can see what's running. -# Jobs are added when a streaming upload starts and removed when it finishes. - -_jobs_lock = threading.Lock() -_active_jobs: dict[str, dict] = {} - - -def _job_start(user_handle: str, total_files: int) -> str: - job_id = uuid.uuid4().hex[:8] - with _jobs_lock: - _active_jobs[job_id] = { - "id": job_id, - "user": user_handle, - "started_at": int(time.time()), - "total": total_files, - "done": 0, - "current": "", - } - return job_id - - -def _job_update(job_id: str, done: int, current: str) -> None: - with _jobs_lock: - if job_id in _active_jobs: - _active_jobs[job_id]["done"] = done - _active_jobs[job_id]["current"] = current - - -def _job_finish(job_id: str) -> None: - with _jobs_lock: - _active_jobs.pop(job_id, None) - - -# ── Globals (set by CLI before uvicorn starts) ──────────────────────────────── - -data_dir: Path | None = None -site_dir: Path | None = None # for post-write rebuild trigger -webroot: Path | None = None # nginx webroot — when set, trigger full rebuild + rsync -strava_client_id: str = "" -strava_client_secret: str = "" -public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs -dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL -sync_secret: str = "" # shared secret for /api/internal/rebuild (set via --sync-secret) -_db = None # sqlite3.Connection, opened lazily -_strava_sync_running = False -_strava_sync_lock = threading.Lock() - - -def _get_db(): - global _db - if _db is None: - _db = open_db(_get_data_dir()) - return _db - - -_STRAVA_CREDS_FILE = "strava_credentials.json" - - -def _strava_creds(handle: str) -> tuple[str, str]: - """Return (client_id, client_secret) for a user. - - Per-user credentials stored in {user_dir}/strava_credentials.json take - precedence over the global instance-level strava_client_id/secret. - Returns ("", "") when neither is configured. - """ - creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE - if creds_path.exists(): - try: - d = json.loads(creds_path.read_text(encoding="utf-8")) - cid = str(d.get("client_id", "")).strip() - csec = str(d.get("client_secret", "")).strip() - if cid and csec: - return cid, csec - except Exception: - pass - return strava_client_id, strava_client_secret - - -def _get_data_dir() -> Path: - if data_dir is None: - raise HTTPException(500, "Server not configured") - return data_dir - - -# ── App ─────────────────────────────────────────────────────────────────────── - app = FastAPI(title="BincioActivity Serve") @app.on_event("startup") async def _on_startup() -> None: """Startup tasks: clean orphaned tmp zips; launch site-rebuild worker if --webroot set.""" - import glob as _glob - data_dir = _get_data_dir() + data_dir = deps._get_data_dir() for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")): try: Path(p).unlink() except Exception: pass - if webroot is not None: - threading.Thread(target=_site_rebuild_worker, daemon=True, name="site-rebuild").start() + if deps.webroot is not None: + threading.Thread(target=tasks._site_rebuild_worker, daemon=True, name="site-rebuild").start() app.add_middleware(GZipMiddleware, minimum_size=1024) @@ -240,2979 +54,16 @@ app.add_middleware( allow_headers=["Content-Type"], ) -_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') -from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID -from bincio.segments import models as _seg_models -from bincio.segments import store as _seg_store -_SESSION_COOKIE = "bincio_session" -_COOKIE_MAX_AGE = 30 * 86400 # 30 days -_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None # e.g. ".bincio.org" in production - - -def _check_id(activity_id: str) -> str: - if not _VALID_ACTIVITY_ID.match(activity_id): - raise HTTPException(400, "Invalid activity ID") - return activity_id - -# ── Rate limiting (simple in-memory, per IP) ────────────────────────────────── - -_login_attempts: dict[str, list[float]] = {} -_register_attempts: dict[str, list[float]] = {} -_RATE_WINDOW = 900 # 15 minutes -_LOGIN_RATE_LIMIT = 10 -_REGISTER_RATE_LIMIT = 5 - - -def _check_rate_limit( - ip: str, - store: dict[str, list[float]], - limit: int, - msg: str = "Too many attempts. Try again later.", -) -> None: - now = time.time() - attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW] - store[ip] = attempts - if len(attempts) >= limit: - raise HTTPException(429, msg) - attempts.append(now) - store[ip] = attempts - - -# ── Auth helpers ────────────────────────────────────────────────────────────── - -def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]: - if not bincio_session: - return None - return get_session(_get_db(), bincio_session) - - -def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User: - user = _current_user(bincio_session) - if not user: - raise HTTPException(401, "Not authenticated") - return user - - -def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User: - user = _require_user(bincio_session) - if not user.is_admin: - raise HTTPException(403, "Admin required") - return user - - -def _require_auth( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> User: - """Accept session cookie (web) OR Authorization: Bearer token (mobile).""" - token = bincio_session - if not token: - auth = request.headers.get("Authorization", "") - if auth.startswith("Bearer "): - token = auth[7:] - if not token: - raise HTTPException(401, "Not authenticated") - user = get_session(_get_db(), token) - if not user: - raise HTTPException(401, "Invalid or expired session") - return user - - -def _set_session_cookie(response: Response, token: str) -> None: - kwargs: dict = dict( - key=_SESSION_COOKIE, - value=token, - max_age=_COOKIE_MAX_AGE, - httponly=True, - samesite="lax", - secure=False, # nginx/caddy handles TLS termination - ) - if _SESSION_DOMAIN: - kwargs["domain"] = _SESSION_DOMAIN - response.set_cookie(**kwargs) - - -from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES -from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES -from bincio.shared.images import unique_image_name as _unique_image_name - - -# ── Post-write rebuild ──────────────────────────────────────────────────────── - -# Serialises per-user merge subprocesses — concurrent merge_all runs on the -# same user dir would corrupt _merged/activities/. -_rebuild_lock = threading.Lock() - -# Signals the site-rebuild worker that at least one merge has completed. -# Using an Event as a boolean flag: set() by any merge, cleared by the worker. -_site_rebuild_event = threading.Event() - - -def _site_rebuild_worker() -> None: - """Single background thread: debounced Astro build + rsync after uploads. - - Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle, - then runs one full build. 271 concurrent uploads → 1 build, not 271. - Uploads that arrive during the build set the event again, so a follow-up - build starts after the current one finishes. - """ - _webroot = str(webroot) - _data_dir = str(data_dir) - _site_dir = str(site_dir) - uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") - while True: - _site_rebuild_event.wait() - _site_rebuild_event.clear() - time.sleep(60) # collect burst uploads - _site_rebuild_event.clear() # discard signals from the sleep window - log.info("site-rebuild: starting full build + rsync to %s", _webroot) - try: - result = subprocess.run( - [uv, "run", "bincio", "render", - "--data-dir", _data_dir, - "--site-dir", _site_dir], - capture_output=True, - text=True, - ) - if result.returncode != 0: - log.error("site-rebuild: build failed (rc=%d):\n%s\n%s", - result.returncode, result.stdout, result.stderr) - continue - dist_data = Path(_site_dir) / "dist" / "data" - if dist_data.exists(): - shutil.rmtree(dist_data) - rsync = subprocess.run( - ["rsync", "-a", "--delete", "--exclude=data/", - f"{_site_dir}/dist/", _webroot + "/"], - capture_output=True, - text=True, - ) - if rsync.returncode != 0: - log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s", - rsync.returncode, rsync.stdout, rsync.stderr) - else: - log.info("site-rebuild: done") - except Exception: - log.exception("site-rebuild: unexpected error") - - -def _trigger_rebuild(handle: str) -> None: - """Merge sidecars for handle asynchronously; signal the site-rebuild worker.""" - if site_dir is None: - return - if not _VALID_HANDLE.match(handle): - return # safety: never pass untrusted strings to subprocess - - uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") - _data_dir = str(data_dir) - _site_dir = str(site_dir) - _handle = handle - - def _run() -> None: - try: - log.info("rebuild[%s]: merge-only", _handle) - with _rebuild_lock: - result = subprocess.run( - [uv, "run", "bincio", "render", - "--data-dir", _data_dir, - "--site-dir", _site_dir, - "--handle", _handle, - "--no-build"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s", - _handle, result.returncode, result.stdout, result.stderr) - else: - log.info("rebuild[%s]: merge done", _handle) - if webroot is not None: - _site_rebuild_event.set() - except Exception: - log.exception("rebuild[%s]: unexpected error", _handle) - - threading.Thread(target=_run, daemon=True).start() - - -# ── Auth endpoints ──────────────────────────────────────────────────────────── - -@app.get("/api/me", response_model=CurrentUserResponse) -async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _current_user(bincio_session) - if not user: - raise HTTPException(401, "Not authenticated") - store_orig = get_setting(_get_db(), "store_originals") - return JSONResponse({ - "handle": user.handle, - "display_name": user.display_name, - "is_admin": user.is_admin, - "wiki_access": user.wiki_access, - "activity_access": user.activity_access, - "store_originals_default": store_orig != "false", - "dem_configured": bool(dem_url), - }) - - -@app.get("/api/stats") -async def stats() -> JSONResponse: - """Public endpoint: member count, join dates, and invitation tree.""" - import time as _time - now = int(_time.time()) - members = get_member_tree(_get_db()) - return JSONResponse({ - "user_count": len(members), - "members": [ - { - "handle": m["handle"], - "display_name": m["display_name"], - "member_since": m["created_at"], - "member_for_days": (now - m["created_at"]) // 86400, - "invited_by": m["invited_by"], - } - for m in members - ], - }) - - -@app.post("/api/internal/rebuild") -async def internal_rebuild(request: Request) -> JSONResponse: - """Trigger a site rebuild. Authenticated via X-Sync-Secret header. - - Called by the bincio sync-strava systemd timer after syncing new activities. - Returns 503 if webroot is not configured (rebuild not possible). - Returns 403 if the secret is missing or wrong. - """ - if not sync_secret: - raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)") - if request.headers.get("X-Sync-Secret") != sync_secret: - raise HTTPException(403, "Forbidden") - if site_dir is None: - raise HTTPException(503, "No site dir configured") - _site_rebuild_event.set() - return JSONResponse({"status": "rebuild queued"}) - - -@app.get("/api/activity/{activity_id}/geojson") -async def get_activity_geojson( - activity_id: str, - user: User = Depends(_require_auth), -) -> JSONResponse: - """Return GeoJSON track for an activity (mobile detail screen).""" - _check_id(activity_id) - dd = _get_data_dir() - user_dir = dd / user.handle - for base in (user_dir / "_merged" / "activities", user_dir / "activities"): - p = base / f"{activity_id}.geojson" - if p.exists(): - return JSONResponse(json.loads(p.read_text())) - raise HTTPException(404, "GeoJSON not found") - - -@app.get("/api/activity/{activity_id}/timeseries") -async def get_activity_timeseries( - activity_id: str, - user: User = Depends(_require_auth), -) -> JSONResponse: - """Return timeseries for an activity (mobile detail screen).""" - _check_id(activity_id) - dd = _get_data_dir() - user_dir = dd / user.handle - for base in (user_dir / "_merged" / "activities", user_dir / "activities"): - p = base / f"{activity_id}.timeseries.json" - if p.exists(): - return JSONResponse(json.loads(p.read_text())) - raise HTTPException(404, "Timeseries not found") - - -def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None: - """Add or update an activity summary in user_dir/index.json. - - Called after writing BAS activity files so that merge_all can include the - activity in year shards. Without this, uploaded activities exist on disk - but never appear in the browser feed. - """ - # Build preview coords from geojson if available ([lat, lng] order) - preview: Optional[list] = None - if geojson: - try: - coords = geojson.get("geometry", {}).get("coordinates", []) - if coords: - step = max(1, len(coords) // 9) - preview = [[c[1], c[0]] for c in coords[::step]][:9] - except Exception: - pass - - has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists() - summary = { - "id": activity_id, - "title": activity.get("title", activity_id), - "sport": activity.get("sport"), - "sub_sport": activity.get("sub_sport"), - "started_at": activity.get("started_at"), - "distance_m": activity.get("distance_m"), - "duration_s": activity.get("duration_s"), - "moving_time_s": activity.get("moving_time_s"), - "elevation_gain_m": activity.get("elevation_gain_m"), - "avg_speed_kmh": activity.get("avg_speed_kmh"), - "max_speed_kmh": activity.get("max_speed_kmh"), - "avg_hr_bpm": activity.get("avg_hr_bpm"), - "max_hr_bpm": activity.get("max_hr_bpm"), - "avg_cadence_rpm": activity.get("avg_cadence_rpm"), - "avg_power_w": activity.get("avg_power_w"), - "mmp": activity.get("mmp"), - "best_efforts": activity.get("best_efforts"), - "best_climb_m": activity.get("best_climb_m"), - "source": activity.get("source"), - "privacy": activity.get("privacy", "public"), - "detail_url": f"activities/{activity_id}.json", - "track_url": f"activities/{activity_id}.geojson" if has_track else None, - "preview_coords": preview, - } - - index_path = user_dir / "index.json" - if index_path.exists(): - index_data = json.loads(index_path.read_text(encoding="utf-8")) - else: - index_data = { - "bas_version": "1.0", - "owner": {"handle": user_dir.name}, - "generated_at": None, - "activities": [], - } - existing = {a["id"]: a for a in index_data.get("activities", [])} - existing[activity_id] = summary - index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True) - index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8") - - -@app.post("/api/upload/bas") -async def upload_bas_activity( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Accept a pre-extracted BAS activity JSON from the mobile app. - - Body (JSON): - activity – full BAS activity dict (required, must have 'id') - timeseries – timeseries dict (optional) - geojson – GeoJSON dict (optional) - - Returns: - {"ok": true, "id": "...", "status": "imported" | "duplicate"} - """ - user = _require_auth(request, bincio_session) - body = await request.json() - - activity = body.get("activity") - if not activity or not activity.get("id"): - raise HTTPException(400, "Missing activity.id") - - activity_id = str(activity["id"]) - _check_id(activity_id) - - user_dir = _get_data_dir() / user.handle - acts_dir = user_dir / "activities" - acts_dir.mkdir(parents=True, exist_ok=True) - - out = acts_dir / f"{activity_id}.json" - if out.exists(): - return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"}) - - out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8") - - if body.get("timeseries"): - ts_path = acts_dir / f"{activity_id}.timeseries.json" - if not ts_path.exists(): - ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8") - - geojson_body: Optional[dict] = body.get("geojson") or None - if geojson_body: - gj_path = acts_dir / f"{activity_id}.geojson" - if not gj_path.exists(): - gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8") - - _upsert_index_summary(user_dir, activity_id, activity, geojson_body) - - try: - from bincio.render.merge import merge_one, write_combined_feed - merge_one(user_dir, activity_id) - write_combined_feed(_get_data_dir()) - except Exception as exc: - log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc) - - log.info("upload/bas[%s]: imported %s", user.handle, activity_id) - return JSONResponse({"ok": True, "id": activity_id, "status": "imported"}) - - -@app.post("/api/upload/raw") -async def upload_raw_activity( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it - server-side, store it in the user's activity library, and return the full - extracted data so the mobile can cache it locally. - - Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69). - - Body (JSON): - filename – original filename (used only to determine file extension) - base64 – base64-encoded raw file bytes - - Auth: Authorization: Bearer - - Returns: - {"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null, - "geojson": {...}|null, "source_hash": ""} - """ - import base64 as _b64 - import hashlib - - user = _require_auth(request, bincio_session) - - body = await request.json() - filename_hint: str = body.get("filename") or "activity.fit" - b64: str = body.get("base64") or "" - user_title: Optional[str] = body.get("user_title") or None - if not b64: - raise HTTPException(400, "Missing base64 field") - - try: - raw = _b64.b64decode(b64) - except Exception: - raise HTTPException(400, "Invalid base64 encoding") - - source_hash = hashlib.sha256(raw).hexdigest() - - suffix = Path(filename_hint).suffix or ".fit" - tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}") - tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}") - try: - tmp_in.write_bytes(raw) - tmp_out.mkdir() - - from bincio.extract.parsers.factory import parse_file - from bincio.extract.metrics import compute - from bincio.extract.writer import make_activity_id, write_activity - from bincio.extract.timeseries import build_timeseries - - activity = parse_file(tmp_in) - metrics = compute(activity) - write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001) - act_id = make_activity_id(activity) - - acts_tmp = tmp_out / "activities" - detail_path = acts_tmp / f"{act_id}.json" - ts_path = acts_tmp / f"{act_id}.timeseries.json" - geojson_path = acts_tmp / f"{act_id}.geojson" - - if not ts_path.exists(): - ts_data = build_timeseries(activity.points, activity.started_at, "public") - if ts_data.get("t"): - ts_path.write_text(json.dumps(ts_data)) - - detail = json.loads(detail_path.read_text()) - timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None - geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None - - # Also store on the server so the activity appears in the user's feed. - user_dir = _get_data_dir() / user.handle - acts_dir = user_dir / "activities" - acts_dir.mkdir(parents=True, exist_ok=True) - out = acts_dir / f"{act_id}.json" - if not out.exists(): - out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8") - if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists(): - (acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8") - if geojson and not (acts_dir / f"{act_id}.geojson").exists(): - (acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8") - - _upsert_index_summary(user_dir, act_id, detail, geojson) - - if user_title: - import yaml as _yaml - edits_dir = user_dir / "edits" - edits_dir.mkdir(parents=True, exist_ok=True) - (edits_dir / f"{act_id}.md").write_text( - f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n", - encoding="utf-8", - ) - - except Exception as exc: - log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc) - raise HTTPException(422, f"Could not extract activity: {exc}") from exc - finally: - tmp_in.unlink(missing_ok=True) - shutil.rmtree(tmp_out, ignore_errors=True) - - # Merge and update feed — best effort; a race or transient FS error here must - # not turn a successful extraction into a 422 (the file is on disk; the mobile - # would retry indefinitely and the activity would never be marked synced). - try: - from bincio.render.merge import merge_one, write_combined_feed - merge_one(user_dir, act_id) - write_combined_feed(_get_data_dir()) - except Exception as exc: - log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc) - - log.info("upload/raw[%s]: imported %s", user.handle, act_id) - return JSONResponse({ - "ok": True, - "id": act_id, - "detail": detail, - "timeseries": timeseries, - "geojson": geojson, - "source_hash": source_hash, - }) - - -@app.get("/api/wheel/version") -async def wheel_version() -> JSONResponse: - """Public endpoint: current bincio wheel version for mobile app update checks.""" - import importlib.metadata - try: - version = importlib.metadata.version("bincio") - except importlib.metadata.PackageNotFoundError: - version = "0.1.0" - return JSONResponse({ - "version": version, - "url": f"/bincio-{version}-py3-none-any.whl", - "api_url": f"/api/wheel/download", - }) - - -@app.get("/api/wheel/download") -async def wheel_download() -> FileResponse: - """Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl).""" - import importlib.metadata - try: - version = importlib.metadata.version("bincio") - except importlib.metadata.PackageNotFoundError: - version = "0.1.0" - wheel_name = f"bincio-{version}-py3-none-any.whl" - # Look in dist/ relative to repo root (two levels up from this file) - dist_dir = Path(__file__).parent.parent.parent / "dist" - wheel_path = dist_dir / wheel_name - if not wheel_path.exists(): - raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/") - return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name) - - -@app.post("/api/auth/login", response_model=LoginResponse) -async def login( - login_req: LoginRequest, - request: Request, -) -> JSONResponse: - ip = request.client.host if request.client else "unknown" - _check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.") - - handle = login_req.handle.strip().lower() - password = login_req.password - - user = authenticate(_get_db(), handle, password) - if not user: - raise HTTPException(401, "Invalid credentials") - - token = create_session(_get_db(), handle) - resp = JSONResponse({ - "ok": True, - "handle": user.handle, - "display_name": user.display_name, - "wiki_access": user.wiki_access, - "activity_access": user.activity_access, - }) - _set_session_cookie(resp, token) - return resp - - -@app.post("/api/auth/logout", response_model=GenericResponse) -async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - if bincio_session: - delete_session(_get_db(), bincio_session) - resp = JSONResponse({"ok": True}) - kwargs: dict = dict(key=_SESSION_COOKIE) - if _SESSION_DOMAIN: - kwargs["domain"] = _SESSION_DOMAIN - resp.delete_cookie(**kwargs) - return resp - - -@app.post("/api/auth/token") -async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse: - """Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie.""" - ip = request.client.host if request.client else "unknown" - _check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.") - handle = login_req.handle.strip().lower() - user = authenticate(_get_db(), handle, login_req.password) - if not user: - raise HTTPException(401, "Invalid credentials") - token = create_session(_get_db(), handle) - return JSONResponse({ - "ok": True, - "token": token, - "handle": user.handle, - "display_name": user.display_name, - }) - - -@app.get("/api/feed") -async def get_feed(user: User = Depends(_require_auth)) -> JSONResponse: - """Return the authenticated user's activity summaries (mobile feed sync). - - _merged/index.json is a shard manifest (activities: []) when the user has - more than FEED_PAGE_SIZE activities. Collect from all shard files. - """ - dd = _get_data_dir() - user_dir = dd / user.handle - for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"): - if not index_path.exists(): - continue - index = json.loads(index_path.read_text()) - activities: list[dict] = index.get("activities", []) - for shard in index.get("shards", []): - shard_path = index_path.parent / shard["url"] - if shard_path.exists(): - shard_doc = json.loads(shard_path.read_text()) - activities.extend(shard_doc.get("activities", [])) - return JSONResponse({"activities": activities}) - return JSONResponse({"activities": []}) - - -@app.post("/api/auth/reset-password", response_model=GenericResponse) -async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse: - """Validate a reset code and set a new password. Public endpoint.""" - from bincio.serve.db import use_reset_code, change_password - handle = reset_req.handle.strip().lower() - code = reset_req.code.strip().upper() - new_pw = reset_req.password - if len(new_pw) < 8: - raise HTTPException(400, "Password must be at least 8 characters") - db = _get_db() - if not use_reset_code(db, code, handle): - raise HTTPException(400, "Invalid or expired reset code") - change_password(db, handle, new_pw) - return JSONResponse({"ok": True}) - - -# ── Registration ────────────────────────────────────────────────────────────── - -@app.post("/api/register", response_model=RegisterResponse) -async def register( - register_req: RegisterRequest, - request: Request, -) -> JSONResponse: - ip = request.client.host if request.client else "unknown" - _check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.") - - code = register_req.code.strip().upper() - handle = register_req.handle.strip().lower() - password = register_req.password - display = register_req.display_name.strip() or handle - - if not _VALID_HANDLE.match(handle): - raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)") - if len(password) < 8: - raise HTTPException(400, "Password must be at least 8 characters") - - invite = get_invite(_get_db(), code) - if not invite or invite.used: - raise HTTPException(400, "Invalid or already-used invite code") - if get_user(_get_db(), handle): - raise HTTPException(409, "Handle already taken") - - db = _get_db() - max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users") - if max_wiki_val is not None: - limit = int(max_wiki_val) - if limit > 0 and count_wiki_users(db) >= limit: - raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})") - - if invite.grants_activity: - max_act_val = get_setting(db, "max_activity_users") - if max_act_val is not None: - limit = int(max_act_val) - if limit > 0 and count_activity_users(db) >= limit: - raise HTTPException(403, f"This instance has reached its activity user limit ({limit})") - - create_user(_get_db(), handle, display, password, is_admin=False, - wiki_access=True, activity_access=invite.grants_activity) - use_invite(_get_db(), code, handle) - - # Create per-user directories - dd = _get_data_dir() - user_dir = dd / handle - (user_dir / "activities").mkdir(parents=True, exist_ok=True) - (user_dir / "edits").mkdir(parents=True, exist_ok=True) - - # Write an empty index.json so the shard URL resolves immediately, - # even before the user uploads any activities. - from bincio.extract.writer import write_index - index_path = user_dir / "index.json" - if not index_path.exists(): - write_index([], user_dir, {"handle": handle, "display_name": display or handle}) - - # Update root manifest so the new user's shard is discoverable immediately - from bincio.render.cli import _write_root_manifest - _write_root_manifest(dd) - - # Rebuild site so the new user's profile pages exist immediately - _trigger_rebuild(handle) - - token = create_session(_get_db(), handle) - resp = JSONResponse({"ok": True, "handle": handle}) - _set_session_cookie(resp, token) - return resp - - -# ── Invites ─────────────────────────────────────────────────────────────────── - -@app.get("/api/invites") -async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _require_user(bincio_session) - invites = list_invites(_get_db(), user.handle) - return JSONResponse([{ - "code": i.code, - "used": i.used, - "used_by": i.used_by, - "created_at": i.created_at, - "used_at": i.used_at, - "grants_activity": i.grants_activity, - } for i in invites]) - - -class CreateInviteRequest(BaseModel): - grants_activity: bool = Field(default=False) - - -@app.post("/api/invites") -async def post_invite( - body: CreateInviteRequest = CreateInviteRequest(), - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - try: - code = create_invite(_get_db(), user.handle, grants_activity=body.grants_activity) - except ValueError as e: - raise HTTPException(400, str(e)) - return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity}) - - -# ── Admin ───────────────────────────────────────────────────────────────────── - -@app.get("/api/admin/users") -async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - _require_admin(bincio_session) - users = list_users(_get_db()) - return JSONResponse([{ - "handle": u.handle, - "display_name": u.display_name, - "is_admin": u.is_admin, - "suspended": u.suspended, - "created_at": u.created_at, - } for u in users]) - - -@app.get("/api/admin/jobs") -async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Return currently active upload/processing jobs. Admin only.""" - _require_admin(bincio_session) - with _jobs_lock: - jobs = list(_active_jobs.values()) - return JSONResponse(jobs) - - -@app.get("/api/admin/disk") -async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Per-user disk usage breakdown. Admin only.""" - _require_admin(bincio_session) - import shutil - - data_dir = _get_data_dir() - - def _mb(path: Path) -> float: - if not path.exists(): - return 0.0 - # Use lstat to count symlink entries (few bytes each) rather than following - # the link to the target — prevents _merged/ from double-counting activities/. - total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink()) - return round(total / 1_048_576, 1) - - def _count(path: Path, pattern: str = "*") -> int: - if not path.exists(): - return 0 - return sum(1 for f in path.glob(pattern) if f.is_file()) - - db = _get_db() - from bincio.serve.db import get_user as _get_user - users = [] - for user_dir in sorted(data_dir.iterdir()): - if not user_dir.is_dir() or user_dir.name.startswith("_"): - continue - # leaked tmp zips - leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()] - db_user = _get_user(db, user_dir.name) - users.append({ - "handle": user_dir.name, - "in_db": db_user is not None, - "suspended": db_user.suspended if db_user else False, - "total_mb": _mb(user_dir), - "activities_mb": _mb(user_dir / "activities"), - "activities_count": _count(user_dir / "activities", "*.json"), - "merged_mb": _mb(user_dir / "_merged"), - "originals_mb": _mb(user_dir / "originals"), - "originals_strava_mb": _mb(user_dir / "originals" / "strava"), - "images_mb": _mb(user_dir / "edits" / "images"), - "leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1), - "leaked_zips_count": len(leaked), - }) - - disk = shutil.disk_usage("/") - return JSONResponse({ - "disk": { - "total_gb": round(disk.total / 1_073_741_824, 1), - "used_gb": round(disk.used / 1_073_741_824, 1), - "free_gb": round(disk.free / 1_073_741_824, 1), - "percent": round(disk.used / disk.total * 100, 1), - }, - "users": users, - }) - - -@app.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse) -async def admin_reset_password_code( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Generate a one-time password reset code for a user. Admin only.""" - from bincio.serve.db import create_reset_code - admin = _require_admin(bincio_session) - db = _get_db() - if not get_user(db, handle): - raise HTTPException(404, f"User '{handle}' not found") - code = create_reset_code(db, handle, admin.handle) - return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24}) - - -@app.post("/api/admin/users/{handle}/suspend") -async def admin_suspend( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Suspend a user account. Blocks login and invalidates existing sessions. Admin only.""" - from bincio.serve.db import set_suspended, purge_expired_sessions - admin = _require_admin(bincio_session) - if handle == admin.handle: - raise HTTPException(400, "Cannot suspend yourself") - db = _get_db() - if not get_user(db, handle): - raise HTTPException(404, "User not found") - set_suspended(db, handle, True) - db.execute("DELETE FROM sessions WHERE handle = ?", (handle,)) - db.commit() - return JSONResponse({"status": "suspended", "handle": handle}) - - -@app.post("/api/admin/users/{handle}/unsuspend") -async def admin_unsuspend( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Re-enable a suspended user account. Admin only.""" - from bincio.serve.db import set_suspended - _require_admin(bincio_session) - db = _get_db() - if not get_user(db, handle): - raise HTTPException(404, "User not found") - set_suspended(db, handle, False) - return JSONResponse({"status": "unsuspended", "handle": handle}) - - -@app.delete("/api/admin/users/{handle}/account") -async def admin_delete_account( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Delete a user account from the database. Data directory is NOT removed. Admin only.""" - from bincio.serve.db import delete_user as _delete_user - admin = _require_admin(bincio_session) - if handle == admin.handle: - raise HTTPException(400, "Cannot delete your own account") - db = _get_db() - if not get_user(db, handle): - raise HTTPException(404, "User not found") - _delete_user(db, handle) - return JSONResponse({"status": "deleted", "handle": handle}) - - -@app.post("/api/admin/users/{handle}/rebuild") -async def admin_rebuild( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Trigger a merge_all + site rebuild for a user. Admin only.""" - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No data directory for user '{handle}'") - _trigger_rebuild(handle) - return JSONResponse({"ok": True}) - - -@app.post("/api/admin/users/{handle}/rebuild-sync") -async def admin_rebuild_sync( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Run merge+rebuild synchronously and return full output. Admin only. - - Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr. - Use for debugging when you need to see what went wrong. - """ - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No data directory for user '{handle}'") - if site_dir is None: - raise HTTPException(503, "Server has no --site-dir configured; rebuild not available") - - uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") - cmd = [uv, "run", "bincio", "render", - "--data-dir", str(data_dir), - "--site-dir", str(site_dir), - "--handle", handle, - "--no-build"] - if webroot: - cmd = [uv, "run", "bincio", "render", - "--data-dir", str(data_dir), - "--site-dir", str(site_dir), - "--handle", handle] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - resp: dict[str, Any] = { - "ok": result.returncode == 0, - "returncode": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - } - - if result.returncode == 0 and webroot: - dist_data = site_dir / "dist" / "data" - if dist_data.exists(): - shutil.rmtree(dist_data) - rsync = subprocess.run( - ["rsync", "-a", "--delete", "--exclude=data/", - f"{site_dir}/dist/", str(webroot) + "/"], - capture_output=True, text=True, timeout=120, - ) - resp["rsync_returncode"] = rsync.returncode - resp["rsync_stdout"] = rsync.stdout - resp["rsync_stderr"] = rsync.stderr - resp["ok"] = rsync.returncode == 0 - - return JSONResponse(resp) - - -@app.post("/api/admin/users/{handle}/reextract-originals") -async def admin_reextract_originals( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> StreamingResponse: - """Re-extract activities from stored Strava originals without hitting the API. - - Spawns `bincio reextract-originals` as a subprocess so heavy memory use - is isolated from the server process. Streams its JSON-lines output as SSE. - Triggers a full rebuild on completion. - """ - import asyncio - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - originals_dir = user_dir / "originals" / "strava" - if not originals_dir.exists(): - raise HTTPException(404, f"No Strava originals directory for '{handle}'") - - # Use the bincio script from the same venv bin dir as the running Python. - # This is reliable in systemd environments where PATH may not include uv. - import sys as _sys - bincio_exe = str(Path(_sys.executable).parent / "bincio") - data_dir = str(_get_data_dir()) - - # Count originals so we can split into memory-safe batches. - total_originals = len(list(originals_dir.glob("*.json"))) - # Each activity can briefly peak at ~10–30 MB; 100 per batch keeps RSS - # well under 3 GB even on a cheap VPS. - _BATCH = 100 - log.info("reextract[%s]: %d originals, batch size %d, via %s", - handle, total_originals, _BATCH, bincio_exe) - - async def event_stream(): - total_imported = total_skipped = total_errors = 0 - offset = 0 - - while offset < total_originals: - limit = min(_BATCH, total_originals - offset) - proc = await asyncio.create_subprocess_exec( - bincio_exe, "reextract-originals", - "--data-dir", data_dir, - "--handle", handle, - "--offset", str(offset), - "--limit", str(limit), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - - async for raw_line in proc.stdout: - line = raw_line.decode(errors="replace").strip() - if not line: - continue - yield f"data: {line}\n\n" - try: - evt = json.loads(line) - if evt.get("type") == "done": - total_imported += evt.get("imported", 0) - total_skipped += evt.get("skipped", 0) - total_errors += evt.get("errors", 0) - except Exception: - pass - - await proc.wait() - if proc.returncode != 0: - stderr_out = await proc.stderr.read() if proc.stderr else b"" - log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s", - handle, offset, proc.returncode, - stderr_out.decode(errors="replace")[:500]) - yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}–{offset+limit} exited with code {proc.returncode}'})}\n\n" - return # stop on batch failure - - offset += limit - - # All batches complete - log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild", - handle, total_imported, total_skipped, total_errors) - _trigger_rebuild(handle) - yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -@app.get("/api/admin/users/{handle}/diag") -async def admin_diag( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Return a diagnostic snapshot of a user's data directory. Admin only.""" - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No data directory for user '{handle}'") - - def _count(path: Path, glob: str = "*") -> int: - return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0 - - def _size_mb(path: Path) -> float: - if not path.exists(): - return 0.0 - return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576 - - activities_dir = user_dir / "activities" - merged_dir = user_dir / "_merged" - originals_dir = user_dir / "originals" - uploads_dir = user_dir / "_uploads" - - merged_index = merged_dir / "index.json" - root_index = user_dir / "index.json" - - merged_activity_count: int | None = None - if merged_index.exists(): - try: - idx = json.loads(merged_index.read_text()) - merged_activity_count = len(idx.get("activities", [])) - except Exception: - merged_activity_count = -1 - - root_activity_count: int | None = None - if root_index.exists(): - try: - idx = json.loads(root_index.read_text()) - root_activity_count = len(idx.get("activities", [])) - except Exception: - root_activity_count = -1 - - # Peek at a few filenames in activities/ to understand the actual state - acts_sample: list[str] = [] - acts_symlinks = 0 - if activities_dir.exists(): - for f in sorted(activities_dir.iterdir())[:10]: - acts_sample.append(f.name + (" → symlink" if f.is_symlink() else "")) - if f.is_symlink(): - acts_symlinks += 1 - - # Check _merged/activities/ separately - merged_acts_dir = merged_dir / "activities" - merged_acts_json = _count(merged_acts_dir, "*.json") - merged_acts_geojson = _count(merged_acts_dir, "*.geojson") - - # List pending files - pending_files: list[str] = [] - if uploads_dir.exists(): - pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()] - - return JSONResponse({ - "handle": handle, - "user_dir": str(user_dir), - "activities": { - "json_files": _count(activities_dir, "*.json"), - "geojson_files": _count(activities_dir, "*.geojson"), - "size_mb": round(_size_mb(activities_dir), 2), - "sample": acts_sample, - "symlink_count": acts_symlinks, - }, - "originals": { - "exists": originals_dir.exists(), - "size_mb": round(_size_mb(originals_dir), 2), - "strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0, - }, - "merged": { - "exists": merged_dir.exists(), - "activity_count_in_index": merged_activity_count, - "size_mb": round(_size_mb(merged_dir), 2), - "activities_json": merged_acts_json, - "activities_geojson": merged_acts_geojson, - }, - "root_index": { - "exists": root_index.exists(), - "activity_count": root_activity_count, - }, - "pending_uploads": len(pending_files), - "pending_files": pending_files, - "dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(), - "athlete_json_exists": (user_dir / "athlete.json").exists(), - }) - - -def _wipe_user_activities(user_dir: Path) -> int: - """Delete all extracted activity files and caches for a user. - - Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/, - _merged/, index.json, athlete.json, and the dedup cache. - Leaves the user directory itself intact (account remains in the DB). - Returns the number of files deleted. - """ - import shutil - deleted = 0 - - for subdir in ("activities", "edits", "originals"): - d = user_dir / subdir - if d.exists(): - for f in d.rglob("*"): - if f.is_file(): - deleted += 1 - shutil.rmtree(d) - - for name in ("_merged", ): - d = user_dir / name - if d.exists(): - shutil.rmtree(d) - - for name in ("index.json", "athlete.json", ".bincio_cache.json"): - f = user_dir / name - if f.exists(): - f.unlink() - deleted += 1 - - return deleted - - -@app.delete("/api/admin/users/{handle}/activities") -async def admin_delete_activities( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Delete all activity data for a user and wipe the merged cache.""" - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No data directory for user '{handle}'") - - deleted = _wipe_user_activities(user_dir) - _trigger_rebuild(handle) - return JSONResponse({"ok": True, "deleted": deleted}) - - -@app.delete("/api/admin/users/{handle}/directory") -async def admin_delete_user_directory( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Delete the entire user directory from disk (for ghost users not in the DB). - - Refuses if the handle exists as an account in the database — use - DELETE /api/admin/users/{handle}/activities for registered users. - """ - import shutil - _require_admin(bincio_session) - db = _get_db() - from bincio.serve.db import get_user as _get_user - if _get_user(db, handle) is not None: - raise HTTPException( - 400, - f"User '{handle}' is still in the database. Remove the account first, " - "or use 'Reset data' to wipe only activity files.", - ) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No directory for '{handle}'") - shutil.rmtree(user_dir) - # Rebuild root manifest so the ghost shard disappears from the site - from bincio.render.cli import _write_root_manifest - try: - _write_root_manifest(_get_data_dir()) - except Exception: - pass - return JSONResponse({"ok": True}) - - -@app.get("/api/admin/strava-sync") -async def admin_strava_sync_status( - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Return per-user Strava sync status for the admin panel.""" - _require_admin(bincio_session) - root = _get_data_dir() - users = [] - for tf in sorted(root.glob("*/strava_token.json")): - user_dir = tf.parent - handle = user_dir.name - has_creds = (user_dir / "strava_credentials.json").exists() - - last_sync: str | None = None - total_imported = 0 - sync_path = user_dir / "_strava_sync.json" - if sync_path.exists(): - try: - sc = json.loads(sync_path.read_text(encoding="utf-8")) - last_sync = sc.get("last_sync") - total_imported = len(sc.get("imported_ids", [])) - except Exception: - pass - - run_status: str | None = None - run_imported = 0 - run_errors = 0 - run_error_message: str | None = None - last_run: str | None = None - status_path = user_dir / "_strava_sync_status.json" - if status_path.exists(): - try: - ss = json.loads(status_path.read_text(encoding="utf-8")) - run_status = ss.get("status") - run_imported = ss.get("imported", 0) - run_errors = ss.get("errors", 0) - run_error_message = ss.get("error_message") - last_run = ss.get("last_run") - except Exception: - pass - - users.append({ - "handle": handle, - "has_credentials": has_creds, - "last_sync": last_sync, - "total_imported": total_imported, - "run_status": run_status, - "run_imported": run_imported, - "run_errors": run_errors, - "run_error_message": run_error_message, - "last_run": last_run, - }) - - return JSONResponse({"running": _strava_sync_running, "users": users}) - - -@app.post("/api/admin/strava-sync/run") -async def admin_strava_sync_run( - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Trigger an immediate Strava sync for all users (admin only).""" - global _strava_sync_running - _require_admin(bincio_session) - with _strava_sync_lock: - if _strava_sync_running: - raise HTTPException(409, "Sync already running") - _strava_sync_running = True - - def _run() -> None: - global _strava_sync_running - try: - from bincio.sync_strava import sync_all - results = sync_all(_get_data_dir()) - total_new = sum(n for n, _ in results.values()) - if total_new > 0: - _site_rebuild_event.set() - except Exception: - log.exception("admin_strava_sync_run: unexpected error") - finally: - _strava_sync_running = False - - threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start() - return JSONResponse({"ok": True}, status_code=202) - - - -# ── Self-service user settings ──────────────────────────────────────────────── - -@app.get("/api/me/storage") -async def me_storage(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Return per-category disk usage for the logged-in user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - - def _mb(path: Path) -> float: - if not path.exists(): - return 0.0 - total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink()) - return round(total / 1_048_576, 2) - - def _count(path: Path, pattern: str = "*") -> int: - if not path.exists(): - return 0 - return sum(1 for f in path.glob(pattern) if f.is_file()) - - activities_mb = _mb(dd / "activities") - originals_mb = _mb(dd / "originals") - strava_mb = _mb(dd / "originals" / "strava") - images_mb = _mb(dd / "edits" / "images") - total_mb = _mb(dd) - - return JSONResponse({ - "total_mb": total_mb, - "activities_mb": activities_mb, - "activities_count": _count(dd / "activities", "*.json"), - "originals_mb": originals_mb, - "strava_originals_mb": strava_mb, - "strava_originals_count": _count(dd / "originals" / "strava", "*.json"), - "images_mb": images_mb, - }) - - -@app.delete("/api/me/originals") -async def me_delete_originals(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Delete the user's originals/ directory (frees space after re-extraction).""" - user = _require_user(bincio_session) - originals = _get_data_dir() / user.handle / "originals" - if not originals.exists(): - return JSONResponse({"ok": True, "freed_mb": 0.0}) - - freed = round( - sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2 - ) - shutil.rmtree(originals) - return JSONResponse({"ok": True, "freed_mb": freed}) - - -@app.delete("/api/me/activities") -async def me_delete_activities( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON). - - Requires the user's current password in the request body for confirmation. - """ - user = _require_user(bincio_session) - body = await request.json() - password = body.get("password", "") - if not authenticate(_get_db(), user.handle, password): - raise HTTPException(401, "Wrong password") - - user_dir = _get_data_dir() / user.handle - deleted = _wipe_user_activities(user_dir) - _trigger_rebuild(user.handle) - return JSONResponse({"ok": True, "deleted": deleted}) - - -@app.delete("/api/me") -async def me_delete_account( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Delete the account and all data permanently. - - Requires the user's current password. Deletes the DB row, all sessions, - and the entire user data directory. The root shard manifest is updated. - """ - user = _require_user(bincio_session) - body = await request.json() - password = body.get("password", "") - if not authenticate(_get_db(), user.handle, password): - raise HTTPException(401, "Wrong password") - - # Wipe data directory - user_dir = _get_data_dir() / user.handle - if user_dir.is_dir(): - shutil.rmtree(user_dir) - - # Remove from DB (cascades to sessions, invites, reset_codes) - from bincio.serve.db import delete_user as _delete_user - _delete_user(_get_db(), user.handle) - - # Update root manifest so the shard disappears - from bincio.render.cli import _write_root_manifest - try: - _write_root_manifest(_get_data_dir()) - except Exception: - pass - - resp = JSONResponse({"ok": True}) - resp.delete_cookie(_SESSION_COOKIE) - return resp - - -@app.put("/api/me/display-name") -async def me_update_display_name( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Update the logged-in user's display name.""" - user = _require_user(bincio_session) - body = await request.json() - display_name = str(body.get("display_name", "")).strip() - if len(display_name) > 60: - raise HTTPException(400, "Display name too long (max 60 characters)") - db = _get_db() - db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle)) - db.commit() - return JSONResponse({"ok": True, "display_name": display_name}) - - -@app.get("/api/me/prefs") -async def me_get_prefs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Return all user preferences as a key→value dict.""" - user = _require_user(bincio_session) - return JSONResponse(get_user_prefs(_get_db(), user.handle)) - - -@app.put("/api/me/prefs") -async def me_set_prefs( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Upsert one or more user preferences. Body: {key: value, ...} (all strings).""" - user = _require_user(bincio_session) - body = await request.json() - if not isinstance(body, dict): - raise HTTPException(400, "Body must be a JSON object") - # Coerce all values to strings; ignore unknown keys silently - prefs = {str(k): str(v) for k, v in body.items()} - set_user_prefs(_get_db(), user.handle, prefs) - return JSONResponse({"ok": True}) - - -@app.get("/api/me/strava-credentials") -async def me_get_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Return whether per-user Strava credentials are configured (never returns the secret).""" - user = _require_user(bincio_session) - creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE - has_user_creds = False - client_id_hint = "" - if creds_path.exists(): - try: - d = json.loads(creds_path.read_text(encoding="utf-8")) - cid = str(d.get("client_id", "")).strip() - csec = str(d.get("client_secret", "")).strip() - if cid and csec: - has_user_creds = True - client_id_hint = cid - except Exception: - pass - return JSONResponse({ - "has_user_creds": has_user_creds, - "client_id": client_id_hint, - "instance_configured": bool(strava_client_id), - }) - - -@app.put("/api/me/strava-credentials") -async def me_set_strava_credentials( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Save per-user Strava credentials. Body: {client_id, client_secret}.""" - user = _require_user(bincio_session) - body = await request.json() - cid = str(body.get("client_id", "")).strip() - csec = str(body.get("client_secret", "")).strip() - if not cid: - raise HTTPException(400, "client_id is required") - creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE - # If client_secret is omitted, preserve existing secret (if any) - if not csec: - if creds_path.exists(): - try: - existing = json.loads(creds_path.read_text(encoding="utf-8")) - csec = str(existing.get("client_secret", "")).strip() - except Exception: - pass - if not csec: - raise HTTPException(400, "client_secret is required (no existing secret to preserve)") - - # If the client_id changed, the existing token belongs to a different OAuth - # app and will fail on refresh — delete it so the user must re-authenticate. - token_path = _get_data_dir() / user.handle / "strava_token.json" - if creds_path.exists() and token_path.exists(): - try: - old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip() - if old_cid and old_cid != cid: - token_path.unlink(missing_ok=True) - except Exception: - pass - - creds_path.write_text( - json.dumps({"client_id": cid, "client_secret": csec}, indent=2), - encoding="utf-8", - ) - return JSONResponse({"ok": True}) - - -@app.delete("/api/me/strava-credentials") -async def me_delete_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Remove per-user Strava credentials (falls back to instance credentials).""" - user = _require_user(bincio_session) - creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE - creds_path.unlink(missing_ok=True) - return JSONResponse({"ok": True}) - - -@app.put("/api/me/password") -async def me_change_password( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Change the logged-in user's password. Requires current password.""" - from bincio.serve.db import change_password as _change_password - user = _require_user(bincio_session) - body = await request.json() - current = body.get("current_password", "") - new_pw = body.get("new_password", "") - if not authenticate(_get_db(), user.handle, current): - raise HTTPException(401, "Current password is wrong") - if len(new_pw) < 8: - raise HTTPException(400, "New password must be at least 8 characters") - _change_password(_get_db(), user.handle, new_pw) - return JSONResponse({"ok": True}) - - -# ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── - -def _user_data_dir(handle: str) -> Path: - """Return the merged data dir for a user, for reading activity files.""" - dd = _get_data_dir() - merged = dd / handle / "_merged" - return merged if merged.exists() else dd / handle - - -def _require_owns(activity_id: str, user: User) -> Path: - """Verify the user owns this activity (it lives in their data dir).""" - activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json" - if not activity_path.exists(): - raise HTTPException(404, "Activity not found") - return activity_path - - -@app.get("/api/activity/{activity_id}") -async def get_activity( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - _check_id(activity_id) - path = _require_owns(activity_id, user) - detail = json.loads(path.read_text()) - # Normalise for EditDrawer: add `private` bool so the drawer works regardless - # of whether the raw JSON uses the old "private" or the new "unlisted" value. - detail["private"] = detail.get("privacy") in ("private", "unlisted") - return JSONResponse(detail) - - -@app.post("/api/activity/{activity_id}", response_model=ActivityEditResponse) -async def post_activity( - activity_id: str, - edit_req: ActivityEditRequest, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - # Verify the activity belongs to this user before writing - if not (dd / "activities" / f"{activity_id}.json").exists(): - raise HTTPException(404, "Activity not found") - - from bincio.edit.ops import apply_sidecar_edit - body = edit_req.model_dump(exclude_none=True) - # apply_sidecar_edit already calls merge_one internally — no full rebuild needed. - apply_sidecar_edit(activity_id, body, dd) - return JSONResponse({"ok": True}) - - -@app.post("/api/activity/{activity_id}/recalculate-elevation/dem") -async def recalculate_elevation_dem_endpoint( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Replace GPS altitude with DEM terrain elevation and recompute gain/loss. - - Requires --dem-url to be set when starting bincio serve. - """ - user = _require_user(bincio_session) - _check_id(activity_id) - if not dem_url: - raise HTTPException(503, "DEM URL not configured.") - dd = _get_data_dir() / user.handle - if not (dd / "activities" / f"{activity_id}.json").exists(): - raise HTTPException(404, "Activity not found") - try: - from bincio.extract.dem import recalculate_elevation - from bincio.render.merge import merge_one - result = recalculate_elevation(dd, activity_id, dem_url) - merge_one(dd, activity_id) - _trigger_rebuild(user.handle) - return JSONResponse(result) - except FileNotFoundError as e: - raise HTTPException(404, str(e)) - except ValueError as e: - raise HTTPException(422, str(e)) - - -@app.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis") -async def recalculate_elevation_hysteresis_endpoint( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Recompute gain/loss from original recorded elevation using source-aware hysteresis.""" - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - if not (dd / "activities" / f"{activity_id}.json").exists(): - raise HTTPException(404, "Activity not found") - try: - from bincio.extract.dem import recalculate_elevation_hysteresis - from bincio.render.merge import merge_one - result = recalculate_elevation_hysteresis(dd, activity_id) - merge_one(dd, activity_id) - _trigger_rebuild(user.handle) - return JSONResponse(result) - except FileNotFoundError as e: - raise HTTPException(404, str(e)) - except ValueError as e: - raise HTTPException(422, str(e)) - - -@app.post("/api/admin/users/{handle}/recompute-elevation") -async def admin_recompute_elevation( - handle: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Recompute elevation gain/loss for all activities of a user from stored timeseries. - - Skips activities with altitude_source == 'dem' (already DEM-corrected). - Applies the leading-zero no-fix fix and source-aware hysteresis. - Returns patched/skipped/error counts. - """ - _require_admin(bincio_session) - user_dir = _get_data_dir() / handle - if not user_dir.is_dir(): - raise HTTPException(404, f"No data directory for '{handle}'") - - from bincio.extract.dem import recalculate_elevation_hysteresis - from bincio.render.merge import merge_one - - patched = skipped = errors = 0 - acts_dir = user_dir / "activities" - for json_path in sorted(acts_dir.glob("*.json")): - if json_path.name.endswith(".timeseries.json"): - continue - activity_id = json_path.stem - try: - detail = json.loads(json_path.read_text(encoding="utf-8")) - if detail.get("altitude_source") == "dem": - skipped += 1 - continue - ts_path = acts_dir / f"{activity_id}.timeseries.json" - if not ts_path.exists(): - skipped += 1 - continue - ts = json.loads(ts_path.read_text(encoding="utf-8")) - ele_arr = ts.get("elevation_m") or [] - if not any(e for e in ele_arr if e is not None): - skipped += 1 - continue - recalculate_elevation_hysteresis(user_dir, activity_id) - merge_one(user_dir, activity_id) - patched += 1 - except Exception as exc: - log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc) - errors += 1 - - if patched > 0: - _trigger_rebuild(handle) - - return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors}) - - -@app.delete("/api/activity/{activity_id}", response_model=GenericResponse) -async def delete_activity( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Delete a single activity and all associated files for the logged-in user.""" - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - acts_dir = dd / "activities" - - json_path = acts_dir / f"{activity_id}.json" - if not json_path.exists(): - raise HTTPException(404, "Activity not found") - - import shutil - - # Remove the source files (activities dir) - for suffix in (".json", ".geojson", ".timeseries.json"): - p = acts_dir / f"{activity_id}{suffix}" - p.unlink(missing_ok=True) - - # Remove sidecar edit and images - sidecar = dd / "edits" / f"{activity_id}.md" - sidecar.unlink(missing_ok=True) - images_dir = dd / "edits" / "images" / activity_id - if images_dir.exists(): - shutil.rmtree(images_dir) - - # Remove from the extract-level flat index so merge_all doesn't re-add - # the summary even though the detail file is gone. - index_path = dd / "index.json" - if index_path.exists(): - try: - idx = json.loads(index_path.read_text(encoding="utf-8")) - idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id] - index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False)) - except Exception: - pass # corrupt index — merge_all will clean up on next run - - # Remove from dedup cache so the file can be re-uploaded if needed - cache_path = dd / ".bincio_cache.json" - if cache_path.exists(): - try: - cache = json.loads(cache_path.read_text(encoding="utf-8")) - if isinstance(cache, dict) and "activities" in cache: - cache["activities"] = [ - a for a in cache["activities"] if a.get("id") != activity_id - ] - cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False)) - except Exception: - pass # corrupt cache — leave it; next extract will rebuild - - # Full merge needed: activity removed from index - from bincio.render.merge import merge_all - merge_all(dd) - _trigger_rebuild(user.handle) - - return JSONResponse({"ok": True}) - - -@app.get("/api/activity/{activity_id}/images") -async def list_images( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - images_dir = dd / "edits" / "images" / activity_id - images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else [] - return JSONResponse({"images": images}) - - -@app.post("/api/activity/{activity_id}/images") -async def upload_image( - activity_id: str, - file: UploadFile = File(...), - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - if not (dd / "activities" / f"{activity_id}.json").exists(): - raise HTTPException(404, "Activity not found") - if not file.filename: - raise HTTPException(400, "No filename") - ct = file.content_type or "" - if ct not in _ALLOWED_IMAGE_TYPES: - raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") - contents = await file.read() - if len(contents) > _MAX_IMAGE_BYTES: - raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)") - images_dir = dd / "edits" / "images" / activity_id - images_dir.mkdir(parents=True, exist_ok=True) - safe_name = _unique_image_name(images_dir, Path(file.filename).name) - (images_dir / safe_name).write_bytes(contents) - from bincio.render.merge import merge_one - merge_one(dd, activity_id) - return JSONResponse({"ok": True, "filename": safe_name}) - - -@app.delete("/api/activity/{activity_id}/images/{filename}") -async def delete_image( - activity_id: str, - filename: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - _check_id(activity_id) - dd = _get_data_dir() / user.handle - import shutil - safe_name = Path(filename).name - target = dd / "edits" / "images" / activity_id / safe_name - if target.exists() and target.is_file(): - target.unlink() - if target.parent.exists() and not any(target.parent.iterdir()): - shutil.rmtree(target.parent) - from bincio.render.merge import merge_one - merge_one(dd, activity_id) - return JSONResponse({"ok": True}) - - -@app.get("/api/athlete") -async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - athlete_path = dd / "athlete.json" - data: dict = {} - if athlete_path.exists(): - data = json.loads(athlete_path.read_text(encoding="utf-8")) - # Layer edits/athlete.yaml on top - edits_path = dd / "edits" / "athlete.yaml" - if edits_path.exists(): - try: - import yaml - edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {} - for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"): - if k in edits: - data[k] = edits[k] - except Exception: - pass - return JSONResponse(data) - - -@app.post("/api/athlete") -async def save_athlete( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - athlete_path = dd / "athlete.json" - if not athlete_path.exists(): - from datetime import datetime, timezone - athlete_path.write_text(json.dumps({ - "bas_version": "1.0", - "generated_at": datetime.now(timezone.utc).isoformat(), - "power_curve": {}, - }), encoding="utf-8") - payload = await request.json() - edits_dir = dd / "edits" - edits_dir.mkdir(exist_ok=True) - overrides: dict[str, Any] = {} - if payload.get("max_hr") is not None: - overrides["max_hr"] = int(payload["max_hr"]) - if payload.get("ftp_w") is not None: - overrides["ftp_w"] = int(payload["ftp_w"]) - if payload.get("hr_zones") is not None: - overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]] - if payload.get("power_zones") is not None: - overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]] - if payload.get("seasons") is not None: - overrides["seasons"] = [ - {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])} - for s in payload["seasons"] - ] - if payload.get("gear") is not None: - overrides["gear"] = payload["gear"] - import yaml - (edits_dir / "athlete.yaml").write_text( - yaml.dump(overrides, allow_unicode=True, default_flow_style=False), - encoding="utf-8", - ) - from bincio.render.merge import merge_all - merge_all(dd) - _trigger_rebuild(user.handle) - return JSONResponse({"ok": True}) - - -_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} - - -def _file_suffix(name: str) -> str: - """Return the effective suffix, including .gz double-extension.""" - p = Path(name.lower()) - if p.suffix == ".gz": - return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz" - return p.suffix - - -@app.post("/api/upload") -async def upload_activity( - files: list[UploadFile] = File(...), - store_original: bool = Form(False), - overwrite: bool = Form(False), - bincio_session: Optional[str] = Cookie(default=None), -) -> StreamingResponse: - """Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing. - - activities.csv (Strava export format) can be included in the batch to: - - Enrich activity files in the same batch (matched by filename) - - Retroactively update sidecars for existing activities (matched by strava_id) - - SSE events: - {"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"} - {"type": "csv", "updates": N} -- only when CSV was included - {"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N} - """ - from bincio.extract.ingest import ingest_parsed - from bincio.extract.parsers.factory import parse_file - from bincio.extract.writer import make_activity_id - from bincio.render.merge import merge_all - - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - staging = dd / "_uploads" - staging.mkdir(exist_ok=True) - - # Read all files into memory now (async), then process synchronously in the generator - csv_bytes_list: list[bytes] = [] - activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes) - - for f in files: - fname = Path(f.filename or "").name - raw = await f.read() - if fname.lower().endswith(".csv"): - csv_bytes_list.append(raw) - else: - activity_items.append((fname, raw)) - - # Build metadata from the first CSV - metadata = None - if csv_bytes_list: - from bincio.extract.strava_csv import StravaMetadata - import tempfile - with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp: - tmp.write(csv_bytes_list[0]) - tmp_path = Path(tmp.name) - try: - metadata = StravaMetadata(tmp_path) - finally: - tmp_path.unlink(missing_ok=True) - - total_files = len(activity_items) - job_id = _job_start(user.handle, total_files) if total_files > 0 else None - - def event_stream(): - added = 0 - overwritten = 0 - duplicates = 0 - errors = 0 - any_added = False - - for n, (name, contents) in enumerate(activity_items, 1): - if job_id: - _job_update(job_id, n - 1, name) - - suffix = _file_suffix(name) - if suffix not in _SUPPORTED_SUFFIXES: - errors += 1 - yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n" - continue - - if len(contents) > 50 * 1024 * 1024: - errors += 1 - yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n" - continue - - staged = staging / name - staged.write_bytes(contents) - kept = False - try: - activity = parse_file(staged) - if metadata is not None: - metadata.enrich(name, activity) - activity_id = make_activity_id(activity) - was_overwrite = False - if (dd / "activities" / f"{activity_id}.json").exists(): - if not overwrite: - duplicates += 1 - yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n" - continue - # Overwrite: delete existing files before re-ingesting. - for ext in (".json", ".geojson", ".timeseries.json"): - (dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True) - # Remove stale summary from index so ingest_parsed writes a clean one - index_path = dd / "index.json" - if index_path.exists(): - idx = json.loads(index_path.read_text(encoding="utf-8")) - idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id] - index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False)) - # Remove from dedup hash cache so the new file isn't blocked - cache_path = dd / ".bincio_cache.json" - if cache_path.exists(): - try: - cache = json.loads(cache_path.read_text(encoding="utf-8")) - cache.pop(activity_id, None) - cache_path.write_text(json.dumps(cache, ensure_ascii=False)) - except Exception: - pass - # Remove merged copies (merge_all will regenerate them after ingest) - merged_acts = dd / "_merged" / "activities" - if merged_acts.exists(): - for ext in (".json", ".geojson", ".timeseries.json"): - p = merged_acts / f"{activity_id}{ext}" - if p.exists() or p.is_symlink(): - p.unlink(missing_ok=True) - was_overwrite = True - ingest_parsed(activity, dd, privacy="public") - if store_original: - originals_dir = dd / "originals" - originals_dir.mkdir(exist_ok=True) - staged.rename(originals_dir / name) - kept = True - if was_overwrite: - overwritten += 1 - else: - added += 1 - any_added = True - status = 'overwritten' if was_overwrite else 'imported' - yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n" - except Exception as exc: - errors += 1 - log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True) - yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n" - finally: - if not kept: - staged.unlink(missing_ok=True) - - # Retroactively apply CSV metadata to existing activities - csv_updates = 0 - if metadata is not None: - from bincio.extract.strava_csv import apply_csv_to_data_dir - csv_updates = apply_csv_to_data_dir(dd, metadata) - if csv_updates: - yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n" - - if any_added or csv_updates: - merge_all(dd) - if any_added: - _trigger_rebuild(user.handle) - - yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n" - - if job_id: - _job_finish(job_id) - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -@app.post("/api/upload/strava-zip") -async def upload_strava_zip( - file: UploadFile = File(...), - private: str = Form(default="false"), - bincio_session: Optional[str] = Cookie(default=None), -) -> StreamingResponse: - """Accept a Strava bulk export ZIP and stream SSE progress while processing. - - The ZIP is written to a temp file, processed activity-by-activity, then deleted. - Originals are never kept — the UI informs the user of this upfront. - """ - user = _require_user(bincio_session) - if not file.filename or not file.filename.lower().endswith(".zip"): - raise HTTPException(400, "Please upload a .zip file") - - privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public" - - dd = _get_data_dir() / user.handle - import tempfile - tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) - zip_path = Path(tmp.name) - try: - while chunk := await file.read(1024 * 1024): # 1 MB chunks - tmp.write(chunk) - finally: - tmp.close() - - from bincio.extract.strava_zip import strava_zip_iter - from bincio.render.merge import merge_all - - log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy) - - def event_stream(): - any_imported = False - imported_count = 0 - error_count = 0 - try: - for event in strava_zip_iter(zip_path, dd, privacy=privacy): - yield f"data: {json.dumps(event)}\n\n" - if event.get("type") == "progress": - status = event.get("status") - if status == "imported": - any_imported = True - imported_count += 1 - elif status == "error": - error_count += 1 - log.warning("strava-zip[%s]: error on %s: %s", - user.handle, event.get("name"), event.get("detail", "")) - if event.get("type") == "done": - log.info("strava-zip[%s]: done — imported=%d errors=%d", - user.handle, imported_count, error_count) - if any_imported: - merge_all(dd) - _trigger_rebuild(user.handle) - except Exception as exc: - log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True) - yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" - finally: - zip_path.unlink(missing_ok=True) - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -# ── Segments ────────────────────────────────────────────────────────────────── - -@app.get("/api/segments") -async def get_segments( - bbox: Optional[str] = None, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """List segments, optionally filtered to a map viewport bbox (lon_min,lat_min,lon_max,lat_max).""" - _require_user(bincio_session) - parsed_bbox: Optional[list[float]] = None - if bbox: - try: - parts = [float(x) for x in bbox.split(",")] - if len(parts) == 4: - parsed_bbox = parts - except ValueError: - raise HTTPException(400, "bbox must be four comma-separated floats") - dd = _get_data_dir() - segs = _seg_store.list_segments(dd, parsed_bbox) - return JSONResponse([{ - "id": s.id, - "name": s.name, - "sport": s.sport, - "distance_m": s.distance_m, - "bbox": s.bbox, - "polyline": s.polyline, - "created_by": s.created_by, - "created_at": _seg_store._iso(s.created_at), - } for s in segs]) - - -@app.get("/api/segments/{segment_id}") -async def get_segment(segment_id: str) -> JSONResponse: - """Return metadata for a single segment.""" - dd = _get_data_dir() - seg = _seg_store.load_segment(dd, segment_id) - if seg is None: - raise HTTPException(404, "Segment not found") - return JSONResponse({ - "id": seg.id, - "name": seg.name, - "sport": seg.sport, - "polyline": seg.polyline, - "distance_m": seg.distance_m, - "bbox": seg.bbox, - "created_by": seg.created_by, - "created_at": _seg_store._iso(seg.created_at), - }) - - -@app.post("/api/segments") -async def create_segment( - body: CreateSegmentRequest, - background_tasks: BackgroundTasks, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - if len(body.polyline) < 2: - raise HTTPException(400, "polyline must have at least 2 points") - if body.distance_m < 500: - raise HTTPException(400, "segment must be at least 500 m long") - - lats = [p[0] for p in body.polyline] - lons = [p[1] for p in body.polyline] - bbox = [min(lons), min(lats), max(lons), max(lats)] - - seg_id = _seg_store.make_segment_id(body.name) - from datetime import datetime, timezone as _tz - seg = _seg_models.Segment( - id=seg_id, - name=body.name, - sport=body.sport or None, - polyline=body.polyline, - distance_m=body.distance_m, - bbox=bbox, - created_by=user.handle, - created_at=datetime.now(_tz.utc), - ) - dd = _get_data_dir() - _seg_store.save_segment(dd, seg) - background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id) - return JSONResponse({"id": seg_id}, status_code=201) - - -@app.delete("/api/segments/{segment_id}") -async def delete_segment( - segment_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() - seg = _seg_store.load_segment(dd, segment_id) - if seg is None: - raise HTTPException(404, "Segment not found") - if seg.created_by != user.handle and not user.is_admin: - raise HTTPException(403, "Not allowed") - _seg_store.delete_segment(dd, segment_id) - return JSONResponse({"ok": True}) - - -@app.get("/api/segments/{segment_id}/efforts") -async def get_segment_efforts( - segment_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Return all efforts on a segment for the logged-in user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() - seg = _seg_store.load_segment(dd, segment_id) - if seg is None: - raise HTTPException(404, "Segment not found") - efforts = _seg_store.load_efforts(dd, user.handle, segment_id) - return JSONResponse([ - { - "activity_id": e.activity_id, - "started_at": _seg_store._iso(e.started_at), - "elapsed_s": e.elapsed_s, - "avg_speed_kmh": e.avg_speed_kmh, - "avg_hr_bpm": e.avg_hr_bpm, - "avg_power_w": e.avg_power_w, - "np_power_w": e.np_power_w, - } - for e in efforts - ]) - - -def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int: - """Scan all of a user's activities against one segment. Returns effort count.""" - from datetime import datetime as _datetime - from bincio.segments.detect import track_from_timeseries_json, detect_one - - seg = _seg_store.load_segment(dd, segment_id) - if seg is None: - return 0 - user_dir = dd / handle - acts_dir = user_dir / "activities" - total = 0 - for detail_path in sorted(acts_dir.glob("*.json")): - if ".timeseries." in detail_path.name: - continue - try: - detail = json.loads(detail_path.read_text(encoding="utf-8")) - except Exception: - continue - ts_url = detail.get("timeseries_url") - if not ts_url: - continue - ts_path = user_dir / ts_url - if not ts_path.exists(): - continue - try: - ts = json.loads(ts_path.read_text(encoding="utf-8")) - except Exception: - continue - started_raw = detail.get("started_at") - if not started_raw: - continue - try: - started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00")) - except Exception: - continue - track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem), - detail.get("sport", "other"), started_at) - if track is None: - continue - efforts = detect_one(track, seg) - for effort in efforts: - _seg_store.add_effort(dd, handle, segment_id, effort) - total += len(efforts) - return total - - -@app.post("/api/segments/{segment_id}/detect") -async def trigger_detect( - segment_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Retroactively detect efforts on a segment for the logged-in user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() - if _seg_store.load_segment(dd, segment_id) is None: - raise HTTPException(404, "Segment not found") - _seg_store.save_efforts(dd, user.handle, segment_id, []) - total = _scan_segment_for_user(dd, user.handle, segment_id) - return JSONResponse({"ok": True, "efforts_found": total}) - - -@app.post("/api/me/segment-rescan") -async def me_segment_rescan( - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Retroactively detect efforts for ALL segments across ALL activities for the logged-in user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() - user_dir = dd / user.handle - acts_dir = user_dir / "activities" - - from datetime import datetime as _datetime - from bincio.segments.detect import track_from_timeseries_json, detect_one - import json as _json - - segments = _seg_store.list_segments(dd) - if not segments: - return JSONResponse({"ok": True, "efforts_found": 0}) - - for seg in segments: - _seg_store.save_efforts(dd, user.handle, seg.id, []) - - total = 0 - for detail_path in sorted(acts_dir.glob("*.json")): - if ".timeseries." in detail_path.name: - continue - try: - detail = _json.loads(detail_path.read_text(encoding="utf-8")) - except Exception: - continue - ts_url = detail.get("timeseries_url") - if not ts_url: - continue - ts_path = user_dir / ts_url - if not ts_path.exists(): - continue - try: - ts = _json.loads(ts_path.read_text(encoding="utf-8")) - except Exception: - continue - started_raw = detail.get("started_at") - if not started_raw: - continue - try: - started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00")) - except Exception: - continue - track = track_from_timeseries_json( - ts, detail.get("id", detail_path.stem), - detail.get("sport", "other"), started_at, - ) - if track is None: - continue - for seg in segments: - efforts = detect_one(track, seg) - for effort in efforts: - _seg_store.add_effort(dd, user.handle, seg.id, effort) - total += len(efforts) - - return JSONResponse({"ok": True, "efforts_found": total}) - - -@app.get("/api/activities/{activity_id}/segment_efforts") -async def activity_segment_efforts( - activity_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Return segment efforts that belong to a specific activity for the logged-in user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() - efforts_dir = dd / user.handle / "segment_efforts" - result = [] - if efforts_dir.exists(): - import json as _json - for ef_file in sorted(efforts_dir.glob("*.json")): - seg_id = ef_file.stem - all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id) - matching = [e for e in all_efforts if e.activity_id == activity_id] - if not matching: - continue - seg = _seg_store.load_segment(dd, seg_id) - if not seg: - continue - pr_elapsed = min(e.elapsed_s for e in all_efforts) - for eff in matching: - result.append({ - "segment_id": seg.id, - "segment_name": seg.name, - "segment_distance_m": seg.distance_m, - "elapsed_s": eff.elapsed_s, - "pr_elapsed_s": pr_elapsed, - "started_at": _seg_store._iso(eff.started_at), - }) - return JSONResponse(result) - - -@app.get("/api/users/{handle}/segment_summary") -async def user_segment_summary(handle: str) -> JSONResponse: - """Public endpoint: segments where this user has efforts, with best time and count.""" - dd = _get_data_dir() - efforts_dir = dd / handle / "segment_efforts" - result = [] - if efforts_dir.exists(): - for ef_file in sorted(efforts_dir.glob("*.json")): - seg_id = ef_file.stem - efforts = _seg_store.load_efforts(dd, handle, seg_id) - if not efforts: - continue - seg = _seg_store.load_segment(dd, seg_id) - if not seg: - continue - best = min(efforts, key=lambda e: e.elapsed_s) - result.append({ - "segment": { - "id": seg.id, - "name": seg.name, - "sport": seg.sport, - "distance_m": seg.distance_m, - }, - "best_elapsed_s": best.elapsed_s, - "best_activity_id": best.activity_id, - "effort_count": len(efforts), - }) - result.sort(key=lambda x: x["segment"]["name"].lower()) - return JSONResponse(result) - - -# ── Ideas ──────────────────────────────────────────────────────────────────── - -import fcntl as _fcntl - -def _ideas_dir(data_dir: Path) -> Path: - d = data_dir / "_ideas" - d.mkdir(parents=True, exist_ok=True) - return d - - -class IdeaBody(BaseModel): - title: str - body: str = "" - - -@app.get("/api/ideas") -async def list_ideas( - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() - ideas = [] - for path in sorted(_ideas_dir(dd).glob("*.json")): - try: - idea = json.loads(path.read_text(encoding="utf-8")) - except Exception: - continue - votes = idea.get("votes", []) - idea["vote_count"] = len(votes) - idea["my_vote"] = user.handle in votes - ideas.append(idea) - ideas.sort(key=lambda x: (x.get("status") == "done", -x["vote_count"], -x["created_at"])) - return JSONResponse({"ideas": ideas}) - - -@app.post("/api/ideas") -async def create_idea( - data: IdeaBody, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - title = data.title.strip()[:200] - body = data.body.strip()[:2000] - if not title: - raise HTTPException(400, "Title required") - dd = _get_data_dir() - idea_id = secrets.token_hex(8) - idea = { - "id": idea_id, - "title": title, - "body": body, - "author": user.handle, - "created_at": int(time.time()), - "votes": [], - } - path = _ideas_dir(dd) / f"{idea_id}.json" - path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8") - return JSONResponse({"id": idea_id}) - - -@app.post("/api/ideas/{idea_id}/vote") -async def toggle_idea_vote( - idea_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() - path = _ideas_dir(dd) / f"{idea_id}.json" - if not path.exists(): - raise HTTPException(404, "Not found") - with open(path, "r+", encoding="utf-8") as f: - _fcntl.flock(f, _fcntl.LOCK_EX) - idea = json.load(f) - votes: list = idea.get("votes", []) - if user.handle in votes: - votes.remove(user.handle) - voted = False - else: - votes.append(user.handle) - voted = True - idea["votes"] = votes - f.seek(0) - f.truncate() - json.dump(idea, f, ensure_ascii=False, indent=2) - return JSONResponse({"voted": voted, "votes": len(votes)}) - - -@app.post("/api/ideas/{idea_id}/status") -async def toggle_idea_status( - idea_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - if not user.is_admin: - raise HTTPException(403, "Forbidden") - dd = _get_data_dir() - path = _ideas_dir(dd) / f"{idea_id}.json" - if not path.exists(): - raise HTTPException(404, "Not found") - with open(path, "r+", encoding="utf-8") as f: - _fcntl.flock(f, _fcntl.LOCK_EX) - idea = json.load(f) - idea["status"] = "open" if idea.get("status") == "done" else "done" - f.seek(0) - f.truncate() - json.dump(idea, f, ensure_ascii=False, indent=2) - return JSONResponse({"status": idea["status"]}) - - -@app.patch("/api/ideas/{idea_id}") -async def edit_idea( - idea_id: str, - data: IdeaBody, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() - path = _ideas_dir(dd) / f"{idea_id}.json" - if not path.exists(): - raise HTTPException(404, "Not found") - title = data.title.strip()[:200] - body = data.body.strip()[:2000] - if not title: - raise HTTPException(400, "Title required") - with open(path, "r+", encoding="utf-8") as f: - _fcntl.flock(f, _fcntl.LOCK_EX) - idea = json.load(f) - if not user.is_admin and idea.get("author") != user.handle: - raise HTTPException(403, "Forbidden") - idea["title"] = title - idea["body"] = body - f.seek(0) - f.truncate() - json.dump(idea, f, ensure_ascii=False, indent=2) - return JSONResponse({"ok": True, "title": title, "body": body}) - - -@app.delete("/api/ideas/{idea_id}") -async def delete_idea( - idea_id: str, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - dd = _get_data_dir() - path = _ideas_dir(dd) / f"{idea_id}.json" - if not path.exists(): - raise HTTPException(404, "Not found") - try: - idea = json.loads(path.read_text(encoding="utf-8")) - except Exception: - raise HTTPException(500, "Could not read idea") - if not user.is_admin and idea.get("author") != user.handle: - raise HTTPException(403, "Forbidden") - path.unlink() - return JSONResponse({"ok": True}) - - -# ── Feedback ────────────────────────────────────────────────────────────────── - -_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} -_FEEDBACK_MAX_IMAGES = 3 -_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB - - -@app.post("/api/feedback") -async def submit_feedback( - text: str = Form(""), - images: list[UploadFile] = File(default=[]), - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - user = _require_user(bincio_session) - - text = text.strip() - if not text and not any(f.filename for f in images): - raise HTTPException(400, "Feedback must include text or at least one image") - if len(images) > _FEEDBACK_MAX_IMAGES: - raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission") - - feedback_dir = _get_data_dir() / "_feedback" - feedback_dir.mkdir(exist_ok=True) - images_dir = feedback_dir / user.handle - images_dir.mkdir(exist_ok=True) - - now = int(time.time()) - submission_id = f"{now}_{secrets.token_hex(4)}" - saved_images: list[str] = [] - - for img in images: - if not img.filename: - continue - suffix = Path(img.filename).suffix.lower() - if suffix not in _FEEDBACK_IMAGE_SUFFIXES: - raise HTTPException(400, f"Unsupported image type '{suffix}'") - contents = await img.read() - if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES: - raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit") - safe_name = f"{submission_id}_{Path(img.filename).name}" - (images_dir / safe_name).write_bytes(contents) - saved_images.append(safe_name) - - from datetime import datetime, timezone - entry = { - "id": submission_id, - "handle": user.handle, - "submitted_at": datetime.now(timezone.utc).isoformat(), - "text": text, - "images": saved_images, - } - - log_file = feedback_dir / f"{user.handle}.json" - existing: list[dict] = [] - if log_file.exists(): - try: - existing = json.loads(log_file.read_text()) - except Exception: - existing = [] - existing.append(entry) - log_file.write_text(json.dumps(existing, indent=2)) - - return JSONResponse({"ok": True, "id": submission_id}) - - -# ── Strava ──────────────────────────────────────────────────────────────────── - -_strava_oauth_states: set[str] = set() - - -@app.get("/api/strava/status") -async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _require_user(bincio_session) - cid, _ = _strava_creds(user.handle) - if not cid: - return JSONResponse({"configured": False, "connected": False, "last_sync": None}) - dd = _get_data_dir() / user.handle - from bincio.extract.strava_api import load_token - token = load_token(dd) - return JSONResponse({ - "configured": True, - "connected": token is not None, - "last_sync": token.get("last_sync_at") if token else None, - }) - - -@app.post("/api/strava/disconnect") -async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Remove the stored Strava token, forcing a fresh OAuth on next connect.""" - user = _require_user(bincio_session) - token_path = _get_data_dir() / user.handle / "strava_token.json" - token_path.unlink(missing_ok=True) - return JSONResponse({"ok": True}) - - -@app.post("/api/strava/reset") -async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Reset last_sync_at so the next sync re-fetches from a chosen point. - - mode=soft — set to the started_at of the most recent activity on disk - (next sync only fetches activities newer than the last known one) - mode=hard — clear last_sync_at entirely - (next sync re-downloads full Strava history, skipping existing files) - """ - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - from bincio.extract.strava_api import load_token, save_token - token = load_token(dd) - if token is None: - raise HTTPException(400, "Not connected to Strava") - - body = await request.json() - mode = body.get("mode", "soft") - - if mode == "hard": - token.pop("last_sync_at", None) - save_token(dd, token) - return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None}) - - # soft: find the most recent started_at across the user's merged index - from datetime import datetime, timezone - last_ts: int | None = None - for index_path in [dd / "_merged" / "index.json", dd / "index.json"]: - if not index_path.exists(): - continue - try: - index_data = json.loads(index_path.read_text(encoding="utf-8")) - started_ats = [ - a.get("started_at") for a in index_data.get("activities", []) - if a.get("started_at") - ] - if started_ats: - latest = max(started_ats) - dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) - last_ts = int(dt.astimezone(timezone.utc).timestamp()) - break - except Exception: - continue - - if last_ts is None: - token.pop("last_sync_at", None) - else: - token["last_sync_at"] = last_ts - save_token(dd, token) - return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts}) - - -@app.get("/api/strava/auth-url") -async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _require_user(bincio_session) - cid, _ = _strava_creds(user.handle) - if not cid: - raise HTTPException(400, "Strava client ID not configured on this server") - state = secrets.token_urlsafe(16) - _strava_oauth_states.add(state) - if public_url: - redirect_uri = public_url.rstrip("/") + "/api/strava/callback" - else: - redirect_uri = str(request.url_for("strava_callback")) - from bincio.extract.strava_api import auth_url - return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)}) - - -@app.get("/api/strava/callback", name="strava_callback") -async def strava_callback( - request: Request, - code: str = "", - error: str = "", - state: str = "", - bincio_session: Optional[str] = Cookie(default=None), -) -> RedirectResponse: - site_origin = public_url.rstrip("/") if public_url else str(request.base_url).rstrip("/") - if error or not code: - return RedirectResponse(f"{site_origin}/?strava=error") - if state not in _strava_oauth_states: - return RedirectResponse(f"{site_origin}/?strava=error") - _strava_oauth_states.discard(state) - user = _current_user(bincio_session) - if not user: - return RedirectResponse(f"{site_origin}/?strava=error") - cid, csec = _strava_creds(user.handle) - if not cid or not csec: - return RedirectResponse(f"{site_origin}/?strava=error") - dd = _get_data_dir() / user.handle - from bincio.extract.strava_api import StravaError, exchange_code, save_token - try: - token = exchange_code(cid, csec, code) - except StravaError: - return RedirectResponse(f"{site_origin}/?strava=error") - save_token(dd, token) - return RedirectResponse(f"{site_origin}/?strava=connected") - - -@app.get("/api/strava/sync/stream") -async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: - """SSE endpoint — streams per-activity progress then a final summary event.""" - user = _require_user(bincio_session) - cid, csec = _strava_creds(user.handle) - if not cid or not csec: - raise HTTPException(400, "Strava not configured on this server") - dd = _get_data_dir() / user.handle - store_orig_setting = get_setting(_get_db(), "store_originals") - store_orig = store_orig_setting == "true" - originals_dir = (dd / "originals" / "strava") if store_orig else None - if originals_dir: - originals_dir.mkdir(parents=True, exist_ok=True) - - from bincio.extract.ingest import strava_sync_iter - - def event_stream(): - try: - for event in strava_sync_iter(dd, cid, csec, originals_dir): - if event["type"] == "done": - _trigger_rebuild(user.handle) # start before client closes connection - yield f"data: {json.dumps(event)}\n\n" - except Exception as exc: - yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -@app.post("/api/strava/sync") -async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - user = _require_user(bincio_session) - cid, csec = _strava_creds(user.handle) - if not cid or not csec: - raise HTTPException(400, "Strava not configured on this server") - dd = _get_data_dir() / user.handle - store_orig_setting = get_setting(_get_db(), "store_originals") - store_orig = store_orig_setting == "true" - originals_dir = (dd / "originals" / "strava") if store_orig else None - if originals_dir: - originals_dir.mkdir(parents=True, exist_ok=True) - from bincio.edit.ops import run_strava_sync - try: - result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir) - except RuntimeError as e: - raise HTTPException(502, str(e)) - _trigger_rebuild(user.handle) - return JSONResponse(result) - - -# ── Garmin Connect endpoints ────────────────────────────────────────────────── - -def _garmin_user_message(exc: Exception) -> str: - """Return a human-friendly error message for common Garmin login failures.""" - msg = str(exc) - fallback = ( - " In the meantime, you can export your activities from Garmin Connect " - "(garmin.com → Activities → Export) or Garmin Express as FIT files " - "and upload them directly." - ) - if "429" in msg or "rate limit" in msg.lower(): - return ( - "Garmin is rate-limiting this server's IP address (HTTP 429). " - "Wait a few hours and try again." + fallback - ) - if "403" in msg: - return ( - "Cloudflare is blocking the login request (HTTP 403). " - "This is a known upstream issue — try again later or update garminconnect " - "(uv sync --extra garmin)." + fallback - ) - if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower(): - return ( - "Garmin's login page returned a CAPTCHA or MFA challenge that " - "cannot be completed automatically. Try again later, or disable " - "two-factor authentication on your Garmin account." + fallback - ) - return f"Login failed: {exc}" + fallback - -@app.get("/api/garmin/status") -async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Return whether Garmin credentials are stored for the current user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - from bincio.extract.garmin_api import has_credentials - from bincio.extract.garmin_sync import _load_sync_state - connected = has_credentials(dd) - last_sync = None - if connected: - state = _load_sync_state(dd) - last_sync = state.get("last_sync_at") - return JSONResponse({"connected": connected, "last_sync": last_sync}) - - -@app.post("/api/garmin/connect") -async def garmin_connect( - request: Request, - bincio_session: Optional[str] = Cookie(default=None), -) -> JSONResponse: - """Test Garmin login with the supplied credentials and save them on success.""" - user = _require_user(bincio_session) - body = await request.json() - email = (body.get("email") or "").strip() - password = body.get("password") or "" - if not email or not password: - raise HTTPException(400, "email and password are required") - - data_dir = _get_data_dir() - user_dir = data_dir / user.handle - from bincio.extract.garmin_api import GarminError, test_login - try: - info = test_login(data_dir, user_dir, email, password) - except GarminError as exc: - raise HTTPException(400, _garmin_user_message(exc)) - return JSONResponse({"ok": True, **info}) - - -@app.post("/api/garmin/disconnect") -async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: - """Remove stored Garmin credentials and session for the current user.""" - user = _require_user(bincio_session) - dd = _get_data_dir() / user.handle - from bincio.extract.garmin_api import delete_credentials - delete_credentials(dd) - return JSONResponse({"ok": True}) - - -@app.get("/api/garmin/sync/stream") -async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: - """SSE endpoint — streams per-activity Garmin sync progress.""" - user = _require_user(bincio_session) - data_dir = _get_data_dir() - user_dir = data_dir / user.handle - - from bincio.extract.garmin_api import GarminError, has_credentials - if not has_credentials(user_dir): - raise HTTPException(400, "No Garmin credentials stored — connect first") - - from bincio.extract.garmin_sync import garmin_sync_iter - - def event_stream(): - try: - for event in garmin_sync_iter(data_dir, user_dir): - if event["type"] == "done": - _trigger_rebuild(user.handle) - yield f"data: {json.dumps(event)}\n\n" - except GarminError as exc: - yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" - except Exception as exc: - yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) +for _router in [ + feed.router, + auth.router, + me.router, + admin.router, + activities.router, + uploads.router, + segments.router, + strava.router, + garmin.router, + ideas.router, +]: + app.include_router(_router) diff --git a/bincio/serve/tasks.py b/bincio/serve/tasks.py new file mode 100644 index 0000000..640a96f --- /dev/null +++ b/bincio/serve/tasks.py @@ -0,0 +1,136 @@ +"""Background workers and job tracker for bincio.serve.""" +from __future__ import annotations + +import logging +import shutil +import subprocess +import threading +import time +import uuid +from pathlib import Path + +from bincio.serve import deps + +log = logging.getLogger("bincio.serve") + +# ── Job tracker ─────────────────────────────────────────────────────────────── + +_jobs_lock = threading.Lock() +_active_jobs: dict[str, dict] = {} + + +def _job_start(user_handle: str, total_files: int) -> str: + job_id = uuid.uuid4().hex[:8] + with _jobs_lock: + _active_jobs[job_id] = { + "id": job_id, + "user": user_handle, + "started_at": int(time.time()), + "total": total_files, + "done": 0, + "current": "", + } + return job_id + + +def _job_update(job_id: str, done: int, current: str) -> None: + with _jobs_lock: + if job_id in _active_jobs: + _active_jobs[job_id]["done"] = done + _active_jobs[job_id]["current"] = current + + +def _job_finish(job_id: str) -> None: + with _jobs_lock: + _active_jobs.pop(job_id, None) + + +# ── Post-write rebuild ──────────────────────────────────────────────────────── + +_rebuild_lock = threading.Lock() +_site_rebuild_event = threading.Event() + + +def _site_rebuild_worker() -> None: + """Single background thread: debounced Astro build + rsync after uploads. + + Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle, + then runs one full build. Uploads that arrive during the build set the + event again, so a follow-up build starts after the current one finishes. + """ + _webroot = str(deps.webroot) + _data_dir = str(deps.data_dir) + _site_dir = str(deps.site_dir) + uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") + while True: + _site_rebuild_event.wait() + _site_rebuild_event.clear() + time.sleep(60) + _site_rebuild_event.clear() + log.info("site-rebuild: starting full build + rsync to %s", _webroot) + try: + result = subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir], + capture_output=True, + text=True, + ) + if result.returncode != 0: + log.error("site-rebuild: build failed (rc=%d):\n%s\n%s", + result.returncode, result.stdout, result.stderr) + continue + dist_data = Path(_site_dir) / "dist" / "data" + if dist_data.exists(): + shutil.rmtree(dist_data) + rsync = subprocess.run( + ["rsync", "-a", "--delete", "--exclude=data/", + f"{_site_dir}/dist/", _webroot + "/"], + capture_output=True, + text=True, + ) + if rsync.returncode != 0: + log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s", + rsync.returncode, rsync.stdout, rsync.stderr) + else: + log.info("site-rebuild: done") + except Exception: + log.exception("site-rebuild: unexpected error") + + +def _trigger_rebuild(handle: str) -> None: + """Merge sidecars for handle asynchronously; signal the site-rebuild worker.""" + if deps.site_dir is None: + return + if not deps._VALID_HANDLE.match(handle): + return + + uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") + _data_dir = str(deps.data_dir) + _site_dir = str(deps.site_dir) + _handle = handle + + def _run() -> None: + try: + log.info("rebuild[%s]: merge-only", _handle) + with _rebuild_lock: + result = subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir, + "--handle", _handle, + "--no-build"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s", + _handle, result.returncode, result.stdout, result.stderr) + else: + log.info("rebuild[%s]: merge done", _handle) + if deps.webroot is not None: + _site_rebuild_event.set() + except Exception: + log.exception("rebuild[%s]: unexpected error", _handle) + + threading.Thread(target=_run, daemon=True).start() diff --git a/refactoring.md b/refactoring.md index 267baa0..f134de2 100644 --- a/refactoring.md +++ b/refactoring.md @@ -491,7 +491,7 @@ def test_activity_geojson_missing_geometry(client, tmp_path, authenticated_sessi |---|---|---| | 1 | Extract shared image utilities → `bincio/shared/images.py` | Done | | 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done | -| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Not started | +| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Done | | 4 | Narrow broad `except Exception:` catches | Not started | > **Note on dependency pinning**: not included. `uv.lock` already pins every dependency (including transitives) to exact versions, which is strictly stronger than switching `>=` to `~=` in `pyproject.toml`. The lockfile is the right mechanism for this concern. diff --git a/tests/serve/__init__.py b/tests/serve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/serve/conftest.py b/tests/serve/conftest.py new file mode 100644 index 0000000..139f19d --- /dev/null +++ b/tests/serve/conftest.py @@ -0,0 +1,81 @@ +"""Shared fixtures for serve/ router tests. + +The fixture patches data_dir on whichever module owns it — works against +both the pre-split monolith (bincio.serve.server) and the post-split +layout (bincio.serve.deps). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from bincio.serve.db import create_session, create_user, open_db + + +def _set_data_dir(path: Path) -> None: + try: + import bincio.serve.deps as deps + deps.data_dir = path + deps._db = None + except (ImportError, AttributeError): + import bincio.serve.server as srv + srv.data_dir = path + srv._db = None + + +def _get_data_dir() -> Path | None: + try: + import bincio.serve.deps as deps + return deps.data_dir + except (ImportError, AttributeError): + import bincio.serve.server as srv + return srv.data_dir + + +@pytest.fixture() +def tmp_data(tmp_path: Path) -> Path: + """Return a tmp_path with a valid instance.db.""" + open_db(tmp_path) # creates schema + return tmp_path + + +@pytest.fixture() +def client(tmp_data: Path) -> TestClient: + from bincio.serve.server import app + _set_data_dir(tmp_data) + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture() +def admin_client(tmp_data: Path) -> TestClient: + """Client with an admin session cookie pre-set.""" + from bincio.serve.server import app + _set_data_dir(tmp_data) + + db = open_db(tmp_data) + create_user(db, "admin", "Admin", "adminpass1", is_admin=True, + wiki_access=True, activity_access=True) + token = create_session(db, "admin") + + c = TestClient(app, raise_server_exceptions=False) + c.cookies.set("bincio_session", token) + return c + + +@pytest.fixture() +def user_client(tmp_data: Path) -> TestClient: + """Client with a regular (non-admin) session cookie pre-set.""" + from bincio.serve.server import app + _set_data_dir(tmp_data) + + db = open_db(tmp_data) + create_user(db, "alice", "Alice", "alicepass1", is_admin=False, + wiki_access=True, activity_access=True) + (tmp_data / "alice" / "activities").mkdir(parents=True, exist_ok=True) + token = create_session(db, "alice") + + c = TestClient(app, raise_server_exceptions=False) + c.cookies.set("bincio_session", token) + return c diff --git a/tests/serve/test_activities_router.py b/tests/serve/test_activities_router.py new file mode 100644 index 0000000..723f30e --- /dev/null +++ b/tests/serve/test_activities_router.py @@ -0,0 +1,85 @@ +"""Pre-split regression tests for /api/activity/* routes.""" +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + + +AID = "2024-01-01T080000Z-test-ride" + + +def _make_activity(tmp_data, activity_id: str = AID) -> None: + acts = tmp_data / "alice" / "activities" + acts.mkdir(parents=True, exist_ok=True) + detail = { + "id": activity_id, + "title": "Test Ride", + "sport": "cycling", + "started_at": "2024-01-01T08:00:00Z", + "distance_m": 10000.0, + "duration_s": 3600, + "elevation_gain_m": 100.0, + } + (acts / f"{activity_id}.json").write_text(json.dumps(detail)) + + +class TestGetActivity: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get(f"/api/activity/{AID}").status_code == 401 + + def test_missing_activity_returns_404(self, user_client: TestClient): + assert user_client.get(f"/api/activity/{AID}").status_code == 404 + + def test_returns_activity_data(self, user_client: TestClient, tmp_data): + _make_activity(tmp_data) + r = user_client.get(f"/api/activity/{AID}") + assert r.status_code == 200 + assert r.json()["id"] == AID + + def test_invalid_id_returns_400(self, user_client: TestClient): + assert user_client.get("/api/activity/../../evil").status_code in (400, 404, 422) + + +class TestEditActivity: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.post(f"/api/activity/{AID}", json={}).status_code == 401 + + def test_missing_activity_returns_404(self, user_client: TestClient): + assert user_client.post(f"/api/activity/{AID}", json={}).status_code == 404 + + def test_edit_title(self, user_client: TestClient, tmp_data): + _make_activity(tmp_data) + r = user_client.post(f"/api/activity/{AID}", json={"title": "New Title"}) + assert r.status_code == 200 + + +class TestDeleteActivity: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.delete(f"/api/activity/{AID}").status_code == 401 + + +class TestActivityImages: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get(f"/api/activity/{AID}/images").status_code == 401 + assert client.delete(f"/api/activity/{AID}/images/photo.jpg").status_code == 401 + + def test_get_images_empty(self, user_client: TestClient, tmp_data): + _make_activity(tmp_data) + r = user_client.get(f"/api/activity/{AID}/images") + assert r.status_code == 200 + assert r.json() == {"images": []} + + +class TestGeojsonTimeseries: + def test_geojson_unauthenticated_returns_401(self, client: TestClient): + assert client.get(f"/api/activity/{AID}/geojson").status_code == 401 + + def test_timeseries_unauthenticated_returns_401(self, client: TestClient): + assert client.get(f"/api/activity/{AID}/timeseries").status_code == 401 + + def test_geojson_missing_returns_404(self, user_client: TestClient): + assert user_client.get(f"/api/activity/{AID}/geojson").status_code == 404 + + def test_timeseries_missing_returns_404(self, user_client: TestClient): + assert user_client.get(f"/api/activity/{AID}/timeseries").status_code == 404 diff --git a/tests/serve/test_admin_router.py b/tests/serve/test_admin_router.py new file mode 100644 index 0000000..04d871c --- /dev/null +++ b/tests/serve/test_admin_router.py @@ -0,0 +1,71 @@ +"""Pre-split regression tests for /api/admin/* routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestAdminUsers: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/admin/users").status_code == 401 + + def test_non_admin_returns_403(self, user_client: TestClient): + assert user_client.get("/api/admin/users").status_code == 403 + + def test_admin_returns_user_list(self, admin_client: TestClient): + r = admin_client.get("/api/admin/users") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +class TestAdminJobs: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/admin/jobs").status_code == 401 + + def test_non_admin_returns_403(self, user_client: TestClient): + assert user_client.get("/api/admin/jobs").status_code == 403 + + def test_admin_returns_jobs_list(self, admin_client: TestClient): + r = admin_client.get("/api/admin/jobs") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +class TestAdminDisk: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/admin/disk").status_code == 401 + + def test_non_admin_returns_403(self, user_client: TestClient): + assert user_client.get("/api/admin/disk").status_code == 403 + + def test_admin_returns_disk_info(self, admin_client: TestClient): + r = admin_client.get("/api/admin/disk") + assert r.status_code == 200 + data = r.json() + assert "users" in data + assert "total_gb" in data.get("disk", {}) + + +class TestAdminUserOps: + def test_reset_password_code_requires_admin(self, client: TestClient, user_client: TestClient): + assert client.post("/api/admin/users/alice/reset-password-code").status_code == 401 + assert user_client.post("/api/admin/users/admin/reset-password-code").status_code == 403 + + def test_suspend_requires_admin(self, client: TestClient): + assert client.post("/api/admin/users/alice/suspend").status_code == 401 + + def test_unsuspend_requires_admin(self, client: TestClient): + assert client.post("/api/admin/users/alice/unsuspend").status_code == 401 + + def test_delete_account_requires_admin(self, client: TestClient): + assert client.delete("/api/admin/users/alice/account").status_code == 401 + + def test_admin_reset_password_code(self, admin_client: TestClient, tmp_data): + from bincio.serve.db import create_user, open_db + db = open_db(tmp_data) + try: + create_user(db, "target", "Target", "targetpass1") + except Exception: + pass + r = admin_client.post("/api/admin/users/target/reset-password-code") + assert r.status_code == 200 + assert "code" in r.json() diff --git a/tests/serve/test_auth_router.py b/tests/serve/test_auth_router.py new file mode 100644 index 0000000..cb8ddab --- /dev/null +++ b/tests/serve/test_auth_router.py @@ -0,0 +1,98 @@ +"""Pre-split regression tests for auth/register/invites routes.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +class TestLogin: + def test_missing_body_returns_422(self, client: TestClient): + r = client.post("/api/auth/login", json={}) + assert r.status_code == 422 + + def test_wrong_credentials_returns_401(self, client: TestClient): + r = client.post("/api/auth/login", json={"handle": "nobody", "password": "x"}) + assert r.status_code == 401 + + def test_valid_login_sets_cookie(self, user_client: TestClient, tmp_data): + from bincio.serve.db import open_db, authenticate + db = open_db(tmp_data) + assert authenticate(db, "alice", "alicepass1") is not None + r = user_client.post("/api/auth/login", + json={"handle": "alice", "password": "alicepass1"}) + assert r.status_code == 200 + assert r.json()["handle"] == "alice" + + +class TestToken: + def test_missing_body_returns_422(self, client: TestClient): + r = client.post("/api/auth/token", json={}) + assert r.status_code == 422 + + def test_wrong_credentials_returns_401(self, client: TestClient): + r = client.post("/api/auth/token", json={"handle": "nobody", "password": "x"}) + assert r.status_code == 401 + + def test_valid_token_in_body(self, user_client: TestClient): + r = user_client.post("/api/auth/token", + json={"handle": "alice", "password": "alicepass1"}) + assert r.status_code == 200 + assert "token" in r.json() + + +class TestLogout: + def test_logout_unauthenticated_returns_200(self, client: TestClient): + r = client.post("/api/auth/logout") + assert r.status_code == 200 + + def test_logout_authenticated_returns_200(self, user_client: TestClient): + r = user_client.post("/api/auth/logout") + assert r.status_code == 200 + + +class TestResetPassword: + def test_missing_body_returns_422(self, client: TestClient): + r = client.post("/api/auth/reset-password", json={}) + assert r.status_code == 422 + + def test_invalid_code_returns_400(self, client: TestClient): + r = client.post("/api/auth/reset-password", + json={"handle": "nobody", "code": "BADCODE", "password": "newpass123"}) + assert r.status_code == 400 + + def test_short_password_returns_400(self, client: TestClient): + r = client.post("/api/auth/reset-password", + json={"handle": "nobody", "code": "BADCODE", "password": "short"}) + assert r.status_code == 400 + + +class TestRegister: + def test_missing_body_returns_422(self, client: TestClient): + r = client.post("/api/register", json={}) + assert r.status_code == 422 + + def test_invalid_invite_returns_400(self, client: TestClient): + r = client.post("/api/register", + json={"code": "BADCODE", "handle": "bob", "password": "bobpass123"}) + assert r.status_code == 400 + + def test_invalid_handle_returns_400(self, client: TestClient): + r = client.post("/api/register", + json={"code": "BADCODE", "handle": "BOB INVALID", "password": "bobpass123"}) + assert r.status_code in (400, 422) + + +class TestInvites: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/invites").status_code == 401 + assert client.post("/api/invites", json={}).status_code == 401 + + def test_authenticated_get_returns_list(self, user_client: TestClient): + r = user_client.get("/api/invites") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + def test_create_invite(self, user_client: TestClient): + r = user_client.post("/api/invites", json={"grants_activity": False}) + assert r.status_code == 200 + assert "code" in r.json() diff --git a/tests/serve/test_feed_router.py b/tests/serve/test_feed_router.py new file mode 100644 index 0000000..50c9b0e --- /dev/null +++ b/tests/serve/test_feed_router.py @@ -0,0 +1,28 @@ +"""Pre-split regression tests for feed, stats, and wheel routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestFeed: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/feed").status_code == 401 + + def test_authenticated_returns_activities(self, user_client: TestClient): + r = user_client.get("/api/feed") + assert r.status_code == 200 + assert "activities" in r.json() + + +class TestStats: + def test_public_returns_200(self, client: TestClient): + r = client.get("/api/stats") + assert r.status_code == 200 + assert "user_count" in r.json() + + +class TestWheelVersion: + def test_public_returns_version(self, client: TestClient): + r = client.get("/api/wheel/version") + assert r.status_code == 200 + assert "version" in r.json() diff --git a/tests/serve/test_garmin_router.py b/tests/serve/test_garmin_router.py new file mode 100644 index 0000000..c21f854 --- /dev/null +++ b/tests/serve/test_garmin_router.py @@ -0,0 +1,24 @@ +"""Pre-split regression tests for /api/garmin/* routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestGarminRoutes: + def test_status_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/garmin/status").status_code == 401 + + def test_connect_unauthenticated_returns_401(self, client: TestClient): + assert client.post("/api/garmin/connect", json={}).status_code == 401 + + def test_disconnect_unauthenticated_returns_401(self, client: TestClient): + assert client.post("/api/garmin/disconnect").status_code == 401 + + def test_status_authenticated_returns_connected(self, user_client: TestClient): + r = user_client.get("/api/garmin/status") + assert r.status_code == 200 + assert "connected" in r.json() + + def test_connect_missing_fields_returns_400(self, user_client: TestClient): + r = user_client.post("/api/garmin/connect", json={}) + assert r.status_code == 400 diff --git a/tests/serve/test_ideas_router.py b/tests/serve/test_ideas_router.py new file mode 100644 index 0000000..128e204 --- /dev/null +++ b/tests/serve/test_ideas_router.py @@ -0,0 +1,46 @@ +"""Pre-split regression tests for /api/ideas/* and /api/feedback routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestIdeas: + def test_list_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/ideas").status_code == 401 + + def test_create_unauthenticated_returns_4xx(self, client: TestClient): + # Pydantic validates the body before auth runs, so empty body → 422; + # a valid body → 401. Either is an auth/validation rejection. + r = client.post("/api/ideas", json={"title": "test", "body": ""}) + assert r.status_code == 401 + + def test_authenticated_list_returns_ideas(self, user_client: TestClient): + r = user_client.get("/api/ideas") + assert r.status_code == 200 + assert "ideas" in r.json() + + def test_create_idea(self, user_client: TestClient): + r = user_client.post("/api/ideas", json={"title": "My idea", "body": "Details"}) + assert r.status_code == 200 + assert "id" in r.json() + + def test_create_idea_empty_title_returns_400(self, user_client: TestClient): + r = user_client.post("/api/ideas", json={"title": "", "body": ""}) + assert r.status_code == 400 + + def test_vote_on_missing_idea_returns_404(self, user_client: TestClient): + assert user_client.post("/api/ideas/no-such-id/vote").status_code == 404 + + def test_vote_toggle(self, user_client: TestClient): + create_r = user_client.post("/api/ideas", json={"title": "Votable", "body": ""}) + idea_id = create_r.json()["id"] + r = user_client.post(f"/api/ideas/{idea_id}/vote") + assert r.status_code == 200 + assert r.json()["voted"] is True + r2 = user_client.post(f"/api/ideas/{idea_id}/vote") + assert r2.json()["voted"] is False + + +class TestFeedback: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.post("/api/feedback", data={}).status_code == 401 diff --git a/tests/serve/test_me_router.py b/tests/serve/test_me_router.py new file mode 100644 index 0000000..a73f90f --- /dev/null +++ b/tests/serve/test_me_router.py @@ -0,0 +1,77 @@ +"""Pre-split regression tests for /api/me/* routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestMeEndpoint: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/me").status_code == 401 + + def test_authenticated_returns_user(self, user_client: TestClient): + r = user_client.get("/api/me") + assert r.status_code == 200 + data = r.json() + assert data["handle"] == "alice" + assert "is_admin" in data + + +class TestMeStorage: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/me/storage").status_code == 401 + + def test_authenticated_returns_storage(self, user_client: TestClient): + r = user_client.get("/api/me/storage") + assert r.status_code == 200 + assert "total_mb" in r.json() + + +class TestMePrefs: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/me/prefs").status_code == 401 + assert client.put("/api/me/prefs", json={}).status_code == 401 + + def test_get_prefs_empty(self, user_client: TestClient): + r = user_client.get("/api/me/prefs") + assert r.status_code == 200 + assert isinstance(r.json(), dict) + + def test_set_and_get_prefs(self, user_client: TestClient): + user_client.put("/api/me/prefs", json={"theme": "dark"}) + r = user_client.get("/api/me/prefs") + assert r.json().get("theme") == "dark" + + +class TestMePassword: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.put("/api/me/password", json={}).status_code == 401 + + def test_wrong_current_password_returns_401(self, user_client: TestClient): + r = user_client.put("/api/me/password", + json={"current_password": "wrong", "new_password": "newpass123"}) + assert r.status_code == 401 + + def test_short_new_password_returns_400(self, user_client: TestClient): + r = user_client.put("/api/me/password", + json={"current_password": "alicepass1", "new_password": "short"}) + assert r.status_code == 400 + + +class TestMeDisplayName: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.put("/api/me/display-name", json={}).status_code == 401 + + def test_update_display_name(self, user_client: TestClient): + r = user_client.put("/api/me/display-name", json={"display_name": "Alice Smith"}) + assert r.status_code == 200 + assert r.json()["display_name"] == "Alice Smith" + + +class TestMeStravaCredentials: + def test_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/me/strava-credentials").status_code == 401 + + def test_authenticated_returns_status(self, user_client: TestClient): + r = user_client.get("/api/me/strava-credentials") + assert r.status_code == 200 + assert "has_user_creds" in r.json() diff --git a/tests/serve/test_segments_router.py b/tests/serve/test_segments_router.py new file mode 100644 index 0000000..cfd603d --- /dev/null +++ b/tests/serve/test_segments_router.py @@ -0,0 +1,38 @@ +"""Pre-split regression tests for /api/segments/* routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestSegments: + def test_list_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/segments").status_code == 401 + + def test_create_unauthenticated_returns_401(self, client: TestClient): + # Pydantic validates required fields before auth; send a valid body + r = client.post("/api/segments", json={ + "name": "Test", "polyline": [[0, 0], [1, 1]], "distance_m": 600.0, + }) + assert r.status_code == 401 + + def test_delete_unauthenticated_returns_401(self, client: TestClient): + assert client.delete("/api/segments/some-id").status_code == 401 + + def test_get_efforts_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/segments/some-id/efforts").status_code == 401 + + def test_authenticated_list_returns_list(self, user_client: TestClient): + r = user_client.get("/api/segments") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + def test_create_short_segment_returns_400(self, user_client: TestClient): + r = user_client.post("/api/segments", json={ + "name": "Too short", + "polyline": [[45.0, 7.0], [45.001, 7.001]], + "distance_m": 100.0, + }) + assert r.status_code == 400 + + def test_missing_segment_returns_404(self, user_client: TestClient): + assert user_client.get("/api/segments/no-such-segment").status_code == 404 diff --git a/tests/serve/test_strava_router.py b/tests/serve/test_strava_router.py new file mode 100644 index 0000000..c47d835 --- /dev/null +++ b/tests/serve/test_strava_router.py @@ -0,0 +1,23 @@ +"""Pre-split regression tests for /api/strava/* routes.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +class TestStravaRoutes: + def test_status_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/strava/status").status_code == 401 + + def test_disconnect_unauthenticated_returns_401(self, client: TestClient): + assert client.post("/api/strava/disconnect").status_code == 401 + + def test_auth_url_unauthenticated_returns_401(self, client: TestClient): + assert client.get("/api/strava/auth-url").status_code == 401 + + def test_sync_unauthenticated_returns_401(self, client: TestClient): + assert client.post("/api/strava/sync").status_code == 401 + + def test_status_authenticated_returns_connected_field(self, user_client: TestClient): + r = user_client.get("/api/strava/status") + assert r.status_code == 200 + assert "connected" in r.json()