Refactor: split serve/server.py (3220 lines) into focused modules
serve/server.py is now 69 lines — app factory, middleware, and router
registration only.
New modules:
deps.py (168 lines) — module-level globals + auth dependency functions
models.py (85 lines) — all Pydantic request/response models
tasks.py (136 lines) — background workers and job tracker
routers/ — one file per domain (10 routers, ~2750 lines total)
auth.py, me.py, admin.py, activities.py, uploads.py,
segments.py, strava.py, garmin.py, ideas.py, feed.py
cli.py updated to set globals on deps instead of server.
88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
This commit is contained in:
+14
-13
@@ -37,6 +37,7 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
|||||||
"""
|
"""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import bincio.serve.server as srv
|
import bincio.serve.server as srv
|
||||||
|
from bincio.serve import deps
|
||||||
from bincio.serve.db import open_db, set_setting, get_setting
|
from bincio.serve.db import open_db, set_setting, get_setting
|
||||||
|
|
||||||
dd = Path(data_dir).expanduser().resolve()
|
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))
|
set_setting(db, "max_users", str(max_users))
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
srv.data_dir = dd
|
deps.data_dir = dd
|
||||||
if site_dir:
|
if site_dir:
|
||||||
srv.site_dir = Path(site_dir).expanduser().resolve()
|
deps.site_dir = Path(site_dir).expanduser().resolve()
|
||||||
if strava_client_id:
|
if strava_client_id:
|
||||||
srv.strava_client_id = strava_client_id
|
deps.strava_client_id = strava_client_id
|
||||||
if strava_client_secret:
|
if strava_client_secret:
|
||||||
srv.strava_client_secret = strava_client_secret
|
deps.strava_client_secret = strava_client_secret
|
||||||
if public_url:
|
if public_url:
|
||||||
srv.public_url = public_url
|
deps.public_url = public_url
|
||||||
if webroot and site_dir:
|
if webroot and site_dir:
|
||||||
srv.webroot = Path(webroot).expanduser().resolve()
|
deps.webroot = Path(webroot).expanduser().resolve()
|
||||||
if dem_url:
|
if dem_url:
|
||||||
srv.dem_url = dem_url
|
deps.dem_url = dem_url
|
||||||
if sync_secret:
|
if sync_secret:
|
||||||
srv.sync_secret = sync_secret
|
deps.sync_secret = sync_secret
|
||||||
|
|
||||||
db = open_db(dd)
|
db = open_db(dd)
|
||||||
current_limit = get_setting(db, "max_users")
|
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"[bold]bincio serve[/bold]")
|
||||||
console.print(f" Data: [cyan]{dd}[/cyan]")
|
console.print(f" Data: [cyan]{dd}[/cyan]")
|
||||||
if srv.site_dir:
|
if deps.site_dir:
|
||||||
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
|
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
|
||||||
if srv.webroot:
|
if deps.webroot:
|
||||||
console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
|
console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)")
|
||||||
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
|
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
|
||||||
if current_limit and int(current_limit) > 0:
|
if current_limit and int(current_limit) > 0:
|
||||||
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
||||||
else:
|
else:
|
||||||
console.print(f" Users: [dim]unlimited[/dim]")
|
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()
|
console.print()
|
||||||
|
|
||||||
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 = ""
|
||||||
@@ -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)
|
||||||
@@ -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})
|
||||||
@@ -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})
|
||||||
@@ -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": []})
|
||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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})
|
||||||
@@ -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})
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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 <token>
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
|
||||||
|
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
|
||||||
|
"""
|
||||||
|
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"},
|
||||||
|
)
|
||||||
+30
-3179
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
+1
-1
@@ -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 |
|
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
|
||||||
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | 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 |
|
| 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.
|
> **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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user