This commit is contained in:
Andras Schmelczer 2026-05-31 09:39:34 +01:00
parent ad7968dadd
commit 5bf8e752e7
22 changed files with 81 additions and 112 deletions

View file

@ -2,11 +2,10 @@
from __future__ import annotations
import uuid
from fastapi import HTTPException, Request
from .db import db_connection
from .models import _canonical_uuidv4
# Single generic detail used for ALL 401 responses. Per spec, the response
# must not distinguish between missing / malformed / unknown tokens — that
@ -21,26 +20,28 @@ def _unauthorized() -> HTTPException:
)
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
def extract_bearer_token(request: Request) -> str | None:
"""Return the raw Bearer token from the Authorization header, or None."""
auth_header = request.headers.get("Authorization") or request.headers.get(
"authorization"
)
if not auth_header:
raise _unauthorized()
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise _unauthorized()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return None
token = parts[1]
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
token = extract_bearer_token(request)
if token is None:
raise _unauthorized()
try:
u = uuid.UUID(token)
if u.version != 4:
raise ValueError("Not v4")
token = str(u)
except (ValueError, AttributeError):
token = _canonical_uuidv4(token)
except ValueError:
raise _unauthorized()
with db_connection() as conn:

View file

@ -8,6 +8,8 @@ from fastapi import Request, Response
from slowapi import Limiter
from slowapi.util import get_remote_address
from .auth import extract_bearer_token
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
_TOO_LARGE_BODY = json.dumps(
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
def _get_token_or_ip(request: Request) -> str:
"""Key function for rate limiting: use Bearer token if present, else IP."""
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return get_remote_address(request)
return extract_bearer_token(request) or get_remote_address(request)
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])

View file

@ -9,6 +9,8 @@ from hashlib import sha256
import structlog
from fastapi import Request, Response
from .auth import extract_bearer_token
def configure_logging() -> None:
"""Configure structlog for JSON output."""
@ -38,12 +40,8 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
structlog.contextvars.bind_contextvars(request_id=request_id)
# Extract user_id from Authorization header for logging (no DB call here)
auth = request.headers.get("Authorization") or request.headers.get("authorization")
user_id: str | None = None
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
user_id = token_log_id(parts[1])
token = extract_bearer_token(request)
user_id = token_log_id(token) if token else None
start = time.monotonic()
response = await call_next(request)

View file

@ -98,7 +98,7 @@ def create_app() -> FastAPI:
if field
)
if fields:
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
detail_str = "Validation failed for: " + ", ".join(fields)
else:
detail_str = "Validation failed"
return JSONResponse(
@ -116,7 +116,7 @@ def create_app() -> FastAPI:
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
detail = {"error": code, "detail": str(exc.detail)}
headers = getattr(exc, "headers", None) or {}
headers = exc.headers or {}
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
# Generic 500 handler

View file

@ -1,10 +1,8 @@
-- Life Towers v4 initial schema.
-- SQLite with WAL mode and foreign keys enabled at connection time.
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
-- db._apply_pragmas(), so they are not (and need not be) set here.
-- All timestamps are unix epoch seconds (INTEGER).
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,

View file

@ -51,17 +51,6 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
db_module._DB_PATH = None
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health(client: AsyncClient) -> None:
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_spa_index_injects_absolute_open_graph_urls(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch