improve docs

This commit is contained in:
Davide Scaini
2026-04-15 23:07:52 +02:00
parent bfb6432666
commit 395182649b
13 changed files with 1004 additions and 33 deletions
+96 -28
View File
@@ -49,6 +49,73 @@ from bincio.serve.db import (
use_invite,
)
from pydantic import BaseModel, Field
# ── Pydantic request/response models ─────────────────────────────────────────
class LoginRequest(BaseModel):
handle: str = Field(..., description="User handle (username)")
password: str = Field(..., description="User password")
class LoginResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
handle: str = Field(..., description="User handle")
display_name: str = Field(..., description="User's display name")
class ResetPasswordRequest(BaseModel):
handle: str = Field(..., description="User handle")
code: str = Field(..., description="Reset code (24 hours valid)")
password: str = Field(..., description="New password (min 8 chars)")
class RegisterRequest(BaseModel):
code: str = Field(..., description="Invite code")
handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
password: str = Field(..., description="Password (min 8 characters)")
display_name: str = Field(default="", description="Full name (optional, defaults to handle)")
class RegisterResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
handle: str = Field(..., description="New user's handle")
class CurrentUserResponse(BaseModel):
handle: str = Field(..., description="User handle")
display_name: str = Field(..., description="User's display name")
is_admin: bool = Field(..., description="Whether user is an admin")
store_originals_default: bool = Field(
default=True,
description="Instance-wide default for storing original files"
)
class ActivityEditRequest(BaseModel):
title: str | None = Field(default=None, description="Activity title")
description: str | None = Field(default=None, description="Activity description (markdown)")
sport: str | None = Field(default=None, description="Sport type")
private: bool | None = Field(default=None, description="Hide from public feed")
highlight: bool | None = Field(default=None, description="Mark as favorite")
gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')")
class ActivityEditResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
class ResetPasswordCodeResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
code: str = Field(..., description="One-time reset code")
expires_in_hours: int = Field(24, description="Code validity period in hours")
class GenericResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
# ── Active job tracker ───────────────────────────────────────────────────────
# Tracks in-progress upload/processing jobs so admins can see what's running.
# Jobs are added when a streaming upload starts and removed when it finishes.
@@ -132,7 +199,7 @@ def _get_data_dir() -> Path:
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
app = FastAPI(title="BincioActivity Serve")
@app.on_event("startup")
@@ -326,7 +393,7 @@ def _trigger_rebuild(handle: str) -> None:
# ── Auth endpoints ────────────────────────────────────────────────────────────
@app.get("/api/me")
@app.get("/api/me", response_model=CurrentUserResponse)
async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _current_user(bincio_session)
if not user:
@@ -361,14 +428,16 @@ async def stats() -> JSONResponse:
})
@app.post("/api/auth/login")
async def login(request: Request) -> JSONResponse:
@app.post("/api/auth/login", response_model=LoginResponse)
async def login(
login_req: LoginRequest,
request: Request,
) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
body = await request.json()
handle = body.get("handle", "").strip().lower()
password = body.get("password", "")
handle = login_req.handle.strip().lower()
password = login_req.password
user = authenticate(_get_db(), handle, password)
if not user:
@@ -380,7 +449,7 @@ async def login(request: Request) -> JSONResponse:
return resp
@app.post("/api/auth/logout")
@app.post("/api/auth/logout", response_model=GenericResponse)
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
if bincio_session:
delete_session(_get_db(), bincio_session)
@@ -389,16 +458,13 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp
@app.post("/api/auth/reset-password")
async def reset_password(request: Request) -> JSONResponse:
@app.post("/api/auth/reset-password", response_model=GenericResponse)
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
"""Validate a reset code and set a new password. Public endpoint."""
from bincio.serve.db import use_reset_code, change_password
body = await request.json()
handle = (body.get("handle") or "").strip().lower()
code = (body.get("code") or "").strip().upper()
new_pw = body.get("password") or ""
if not handle or not code or not new_pw:
raise HTTPException(400, "handle, code, and password are required")
handle = reset_req.handle.strip().lower()
code = reset_req.code.strip().upper()
new_pw = reset_req.password
if len(new_pw) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
db = _get_db()
@@ -410,16 +476,18 @@ async def reset_password(request: Request) -> JSONResponse:
# ── Registration ──────────────────────────────────────────────────────────────
@app.post("/api/register")
async def register(request: Request) -> JSONResponse:
@app.post("/api/register", response_model=RegisterResponse)
async def register(
register_req: RegisterRequest,
request: Request,
) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
body = await request.json()
code = body.get("code", "").strip().upper()
handle = body.get("handle", "").strip().lower()
password = body.get("password", "")
display = body.get("display_name", "").strip() or handle
code = register_req.code.strip().upper()
handle = register_req.handle.strip().lower()
password = register_req.password
display = register_req.display_name.strip() or handle
if not _VALID_HANDLE.match(handle):
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
@@ -568,7 +636,7 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS
})
@app.post("/api/admin/users/{handle}/reset-password-code")
@app.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
async def admin_reset_password_code(
handle: str,
bincio_session: Optional[str] = Cookie(default=None),
@@ -1171,10 +1239,10 @@ async def get_activity(
return JSONResponse(json.loads(path.read_text()))
@app.post("/api/activity/{activity_id}")
@app.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
async def post_activity(
activity_id: str,
request: Request,
edit_req: ActivityEditRequest,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
@@ -1185,13 +1253,13 @@ async def post_activity(
raise HTTPException(404, "Activity not found")
from bincio.edit.ops import apply_sidecar_edit
body = await request.json()
body = edit_req.model_dump(exclude_none=True)
# apply_sidecar_edit already calls merge_one internally — no full rebuild needed.
apply_sidecar_edit(activity_id, body, dd)
return JSONResponse({"ok": True})
@app.delete("/api/activity/{activity_id}")
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
activity_id: str,
bincio_session: Optional[str] = Cookie(default=None),