This commit is contained in:
Andras Schmelczer 2026-05-30 16:57:56 +01:00
parent 3930982bd8
commit ad7968dadd
53 changed files with 564 additions and 1013 deletions

View file

@ -1,8 +1,6 @@
"""APIRouter with all Life Towers endpoints."""
from __future__ import annotations
import json
import sqlite3
import time
from typing import Annotated
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from .auth import get_current_user
from .db import db_connection
from .limits import limiter
from .logging import token_log_id
from .models import (
BlockOut,
DataIn,
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
(now, token),
)
conn.commit()
logger.info("user_registered", user_id=token, new=existing is None)
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
return RegisterResponse(user_id=token)
@ -239,8 +238,14 @@ async def put_data(
)
conn.commit()
except sqlite3.IntegrityError as exc:
conn.rollback()
raise HTTPException(
status_code=409,
detail="Submitted IDs conflict with existing data",
) from exc
except Exception:
conn.rollback()
raise
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))

View file

@ -39,6 +39,7 @@ def get_current_user(request: Request) -> str:
u = uuid.UUID(token)
if u.version != 4:
raise ValueError("Not v4")
token = str(u)
except (ValueError, AttributeError):
raise _unauthorized()

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import time
import uuid as _uuid_mod
from hashlib import sha256
import structlog
from fastapi import Request, Response
@ -26,8 +27,12 @@ def configure_logging() -> None:
)
def token_log_id(token: str) -> str:
return sha256(token.encode("utf-8")).hexdigest()[:12]
async def request_logging_middleware(request: Request, call_next) -> Response:
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
"""Log each request without writing bearer credentials to the log stream."""
request_id = str(_uuid_mod.uuid4())
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
@ -38,7 +43,7 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
user_id = parts[1]
user_id = token_log_id(parts[1])
start = time.monotonic()
response = await call_next(request)

View file

@ -1,8 +1,5 @@
"""ASGI app, lifespan, static files mount, route registration."""
from __future__ import annotations
import json
import os
from contextlib import asynccontextmanager
from html import escape
@ -37,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
413: "payload_too_large",
422: "bad_request",
429: "rate_limited",
507: "quota_exceeded",
500: "server_error",
}
@ -94,7 +90,12 @@ def create_app() -> FastAPI:
request: Request, exc: RequestValidationError
) -> JSONResponse:
fields = sorted(
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
field
for field in {
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
for e in exc.errors()
}
if field
)
if fields:
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)

View file

@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator
import uuid as _uuid_mod
def _is_uuidv4(value: str) -> bool:
def _canonical_uuidv4(value: str) -> str:
try:
u = _uuid_mod.UUID(value)
return u.version == 4
if u.version == 4:
return str(u)
except (ValueError, AttributeError):
return False
pass
raise ValueError("must be a UUIDv4")
class HslColor(BaseModel):
@ -33,9 +35,7 @@ class BlockIn(BaseModel):
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
return _canonical_uuidv4(v)
class BlockOut(BaseModel):
@ -56,9 +56,7 @@ class TowerIn(BaseModel):
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
return _canonical_uuidv4(v)
class TowerOut(BaseModel):
@ -80,9 +78,7 @@ class PageIn(BaseModel):
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
return _canonical_uuidv4(v)
class PageOut(BaseModel):
@ -139,9 +135,7 @@ class RegisterRequest(BaseModel):
@field_validator("token")
@classmethod
def validate_token(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError("token must be a UUIDv4")
return v
return _canonical_uuidv4(v)
class RegisterResponse(BaseModel):