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:
Davide Scaini
2026-05-13 23:47:19 +02:00
parent 2ec4d9157c
commit 8380b1d2cc
28 changed files with 3982 additions and 3193 deletions
+14 -13
View File
@@ -37,6 +37,7 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
"""
import uvicorn
import bincio.serve.server as srv
from bincio.serve import deps
from bincio.serve.db import open_db, set_setting, get_setting
dd = Path(data_dir).expanduser().resolve()
@@ -50,21 +51,21 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
set_setting(db, "max_users", str(max_users))
db.close()
srv.data_dir = dd
deps.data_dir = dd
if site_dir:
srv.site_dir = Path(site_dir).expanduser().resolve()
deps.site_dir = Path(site_dir).expanduser().resolve()
if strava_client_id:
srv.strava_client_id = strava_client_id
deps.strava_client_id = strava_client_id
if strava_client_secret:
srv.strava_client_secret = strava_client_secret
deps.strava_client_secret = strava_client_secret
if public_url:
srv.public_url = public_url
deps.public_url = public_url
if webroot and site_dir:
srv.webroot = Path(webroot).expanduser().resolve()
deps.webroot = Path(webroot).expanduser().resolve()
if dem_url:
srv.dem_url = dem_url
deps.dem_url = dem_url
if sync_secret:
srv.sync_secret = sync_secret
deps.sync_secret = sync_secret
db = open_db(dd)
current_limit = get_setting(db, "max_users")
@@ -72,16 +73,16 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
console.print(f"[bold]bincio serve[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]")
if srv.site_dir:
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
if srv.webroot:
console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
if deps.site_dir:
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
if deps.webroot:
console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)")
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
if current_limit and int(current_limit) > 0:
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
else:
console.print(f" Users: [dim]unlimited[/dim]")
console.print(f" DEM: [cyan]{srv.dem_url}[/cyan]")
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
console.print()
log_config = uvicorn.config.LOGGING_CONFIG.copy()
+168
View File
@@ -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)
+85
View File
@@ -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 = ""
View File
+380
View File
@@ -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)
+637
View File
@@ -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 ~1030 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})
+204
View File
@@ -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})
+129
View File
@@ -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": []})
+119
View File
@@ -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"},
)
+229
View File
@@ -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})
+293
View File
@@ -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})
+293
View File
@@ -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)
+192
View File
@@ -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)
+501
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+136
View File
@@ -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
View File
@@ -491,7 +491,7 @@ def test_activity_geojson_missing_geometry(client, tmp_path, authenticated_sessi
|---|---|---|
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done |
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Not started |
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Done |
| 4 | Narrow broad `except Exception:` catches | Not started |
> **Note on dependency pinning**: not included. `uv.lock` already pins every dependency (including transitives) to exact versions, which is strictly stronger than switching `>=` to `~=` in `pyproject.toml`. The lockfile is the right mechanism for this concern.
View File
+81
View File
@@ -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
+85
View File
@@ -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
+71
View File
@@ -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()
+98
View File
@@ -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()
+28
View File
@@ -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()
+24
View File
@@ -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
+46
View File
@@ -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
+77
View File
@@ -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()
+38
View File
@@ -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
+23
View File
@@ -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()