snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
1
backend/src/life_towers/__init__.py
Normal file
1
backend/src/life_towers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Life Towers FastAPI backend
|
||||
243
backend/src/life_towers/api.py
Normal file
243
backend/src/life_towers/api.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""APIRouter with all Life Towers endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from .auth import get_current_user
|
||||
from .db import db_connection
|
||||
from .limits import limiter
|
||||
from .models import (
|
||||
BlockOut,
|
||||
DataIn,
|
||||
DataOut,
|
||||
HealthResponse,
|
||||
HslColor,
|
||||
PageOut,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
TowerOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
@limiter.limit("30/hour", key_func=lambda request: request.client.host if request.client else "unknown")
|
||||
async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
||||
token = body.token
|
||||
now = int(time.time())
|
||||
with db_connection() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?", (token,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, created_at, last_seen_at) VALUES (?, ?, ?)",
|
||||
(token, now, now),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE users SET last_seen_at = ? WHERE id = ?",
|
||||
(now, token),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("user_registered", user_id=token, new=existing is None)
|
||||
return RegisterResponse(user_id=token)
|
||||
|
||||
|
||||
@router.get("/data", response_model=DataOut)
|
||||
@limiter.limit("60/minute")
|
||||
async def get_data(
|
||||
request: Request,
|
||||
user_id: Annotated[str, Depends(get_current_user)],
|
||||
) -> DataOut:
|
||||
with db_connection() as conn:
|
||||
pages_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to
|
||||
FROM pages
|
||||
WHERE user_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
pages_out: list[PageOut] = []
|
||||
for page_row in pages_rows:
|
||||
page_id = page_row["id"]
|
||||
|
||||
tower_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, name, base_color_h, base_color_s, base_color_l
|
||||
FROM towers
|
||||
WHERE page_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
towers_out: list[TowerOut] = []
|
||||
for tower_row in tower_rows:
|
||||
tower_id = tower_row["id"]
|
||||
|
||||
block_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, tag, description, is_done, created_at
|
||||
FROM blocks
|
||||
WHERE tower_id = ?
|
||||
ORDER BY position
|
||||
""",
|
||||
(tower_id,),
|
||||
).fetchall()
|
||||
|
||||
blocks_out = [
|
||||
BlockOut(
|
||||
id=b["id"],
|
||||
tag=b["tag"],
|
||||
description=b["description"],
|
||||
is_done=bool(b["is_done"]),
|
||||
created_at=b["created_at"],
|
||||
)
|
||||
for b in block_rows
|
||||
]
|
||||
|
||||
towers_out.append(
|
||||
TowerOut(
|
||||
id=tower_row["id"],
|
||||
name=tower_row["name"],
|
||||
base_color=HslColor(
|
||||
h=tower_row["base_color_h"],
|
||||
s=tower_row["base_color_s"],
|
||||
l=tower_row["base_color_l"],
|
||||
),
|
||||
blocks=blocks_out,
|
||||
)
|
||||
)
|
||||
|
||||
pages_out.append(
|
||||
PageOut(
|
||||
id=page_row["id"],
|
||||
name=page_row["name"],
|
||||
hide_create_tower_button=bool(
|
||||
page_row["hide_create_tower_button"]
|
||||
),
|
||||
keep_tasks_open=bool(page_row["keep_tasks_open"]),
|
||||
default_date_from=page_row["default_date_from"],
|
||||
default_date_to=page_row["default_date_to"],
|
||||
towers=towers_out,
|
||||
)
|
||||
)
|
||||
|
||||
return DataOut(pages=pages_out)
|
||||
|
||||
|
||||
@router.put("/data", status_code=204)
|
||||
@limiter.limit("30/minute")
|
||||
async def put_data(
|
||||
request: Request,
|
||||
body: DataIn,
|
||||
user_id: Annotated[str, Depends(get_current_user)],
|
||||
) -> None:
|
||||
# Tree-replace semantics mean the request size IS the user's total storage,
|
||||
# so the 2 MiB request cap (enforced by payload_size_middleware) is the only
|
||||
# quota we need.
|
||||
now = int(time.time())
|
||||
|
||||
with db_connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Delete existing data for this user (cascades to towers + blocks)
|
||||
conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,))
|
||||
|
||||
for page_pos, page in enumerate(body.pages):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pages
|
||||
(id, user_id, position, name, hide_create_tower_button,
|
||||
keep_tasks_open, default_date_from, default_date_to,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
page.id,
|
||||
user_id,
|
||||
page_pos,
|
||||
page.name,
|
||||
1 if page.hide_create_tower_button else 0,
|
||||
1 if page.keep_tasks_open else 0,
|
||||
page.default_date_from,
|
||||
page.default_date_to,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for tower_pos, tower in enumerate(page.towers):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO towers
|
||||
(id, page_id, user_id, position, name,
|
||||
base_color_h, base_color_s, base_color_l,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
tower.id,
|
||||
page.id,
|
||||
user_id,
|
||||
tower_pos,
|
||||
tower.name,
|
||||
tower.base_color.h,
|
||||
tower.base_color.s,
|
||||
tower.base_color.l,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for block_pos, block in enumerate(tower.blocks):
|
||||
created_at = block.created_at if block.created_at is not None else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO blocks
|
||||
(id, tower_id, user_id, position, tag,
|
||||
description, is_done, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
block.id,
|
||||
tower.id,
|
||||
user_id,
|
||||
block_pos,
|
||||
block.tag,
|
||||
block.description,
|
||||
1 if block.is_done else 0,
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
# Update last_seen_at
|
||||
conn.execute(
|
||||
"UPDATE users SET last_seen_at = ? WHERE id = ?",
|
||||
(now, user_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
||||
53
backend/src/life_towers/auth.py
Normal file
53
backend/src/life_towers/auth.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Bearer token extraction, UUIDv4 validation, and DB lookup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import db_connection
|
||||
|
||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||
# must not distinguish between missing / malformed / unknown tokens — that
|
||||
# would let an attacker enumerate registered tokens.
|
||||
_UNAUTHORIZED_DETAIL = "Authentication required"
|
||||
|
||||
|
||||
def _unauthorized() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "unauthorized", "detail": _UNAUTHORIZED_DETAIL},
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||
"authorization"
|
||||
)
|
||||
if not auth_header:
|
||||
raise _unauthorized()
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise _unauthorized()
|
||||
|
||||
token = parts[1]
|
||||
|
||||
try:
|
||||
u = uuid.UUID(token)
|
||||
if u.version != 4:
|
||||
raise ValueError("Not v4")
|
||||
except (ValueError, AttributeError):
|
||||
raise _unauthorized()
|
||||
|
||||
with db_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?", (token,)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise _unauthorized()
|
||||
|
||||
return token
|
||||
95
backend/src/life_towers/db.py
Normal file
95
backend/src/life_towers/db.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""SQLite connection factory, WAL/FK pragmas, and migration runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_DB_PATH: Path | None = None
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
global _DB_PATH
|
||||
if _DB_PATH is None:
|
||||
_DB_PATH = Path(os.environ.get("LIFE_TOWERS_DB_PATH", "/data/life-towers.db"))
|
||||
return _DB_PATH
|
||||
|
||||
|
||||
def _apply_pragmas(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Open a new connection with required pragmas applied."""
|
||||
path = get_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_apply_pragmas(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_connection() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Context manager yielding a connection that is closed on exit."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migration_files() -> list[tuple[str, str]]:
|
||||
"""Return [(filename, sql)] for every migration, in lexical order.
|
||||
|
||||
Migrations ship as package data under `life_towers/migrations/`, so they
|
||||
travel with the wheel and are resolvable regardless of cwd or how the
|
||||
package is installed (editable, wheel, or PYTHONPATH).
|
||||
"""
|
||||
pkg_files = resources.files("life_towers").joinpath("migrations")
|
||||
out: list[tuple[str, str]] = []
|
||||
for entry in sorted(pkg_files.iterdir(), key=lambda p: p.name):
|
||||
if entry.name.endswith(".sql"):
|
||||
out.append((entry.name, entry.read_text()))
|
||||
return out
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection) -> None:
|
||||
"""Apply pending SQL migrations in lexical order."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
for filename, sql in _migration_files():
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM schema_migrations WHERE filename = ?",
|
||||
(filename,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
continue
|
||||
|
||||
logger.info("applying_migration", filename=filename)
|
||||
conn.executescript(sql)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
|
||||
(filename, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("migration_applied", filename=filename)
|
||||
86
backend/src/life_towers/limits.py
Normal file
86
backend/src/life_towers/limits.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Payload size middleware and rate-limit setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import Request, Response
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||
|
||||
_TOO_LARGE_BODY = json.dumps(
|
||||
{
|
||||
"error": "payload_too_large",
|
||||
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||
|
||||
|
||||
def _too_large() -> Response:
|
||||
return Response(
|
||||
content=_TOO_LARGE_BODY,
|
||||
status_code=413,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def payload_size_middleware(request: Request, call_next) -> Response:
|
||||
"""Reject requests larger than PAYLOAD_LIMIT_BYTES.
|
||||
|
||||
Two-layer enforcement:
|
||||
1. If `Content-Length` is present, reject up front (cheap and avoids
|
||||
buffering anything).
|
||||
2. Otherwise (chunked encoding / missing header) wrap `request.receive`
|
||||
so the body stream is tallied as it arrives; abort the moment the
|
||||
running total exceeds the cap. This is defense-in-depth against
|
||||
clients that omit Content-Length.
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
length = int(content_length)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length > PAYLOAD_LIMIT_BYTES:
|
||||
return _too_large()
|
||||
# Trust the declared length; the ASGI server enforces it.
|
||||
return await call_next(request)
|
||||
|
||||
# No Content-Length: tally bytes as they arrive.
|
||||
received_total = 0
|
||||
too_large = False
|
||||
original_receive = request.receive
|
||||
|
||||
async def guarded_receive() -> dict:
|
||||
nonlocal received_total, too_large
|
||||
message = await original_receive()
|
||||
if message.get("type") == "http.request":
|
||||
body = message.get("body") or b""
|
||||
received_total += len(body)
|
||||
if received_total > PAYLOAD_LIMIT_BYTES:
|
||||
too_large = True
|
||||
# Tell downstream "no more body" — they may still try to
|
||||
# parse what's been seen, but we'll override the response.
|
||||
return {"type": "http.disconnect"}
|
||||
return message
|
||||
|
||||
request._receive = guarded_receive # type: ignore[attr-defined]
|
||||
response = await call_next(request)
|
||||
if too_large:
|
||||
return _too_large()
|
||||
return response
|
||||
63
backend/src/life_towers/logging.py
Normal file
63
backend/src/life_towers/logging.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""structlog JSON config and request logging middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid as _uuid_mod
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structlog for JSON output."""
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(0),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
||||
request_id = str(_uuid_mod.uuid4())
|
||||
structlog.contextvars.clear_contextvars()
|
||||
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 = parts[1]
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
duration_ms = round((time.monotonic() - start) * 1000, 2)
|
||||
|
||||
# request.client is rewritten by uvicorn's ProxyHeadersMiddleware
|
||||
# (--proxy-headers) to the real client IP when behind a trusted reverse proxy.
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
log = structlog.get_logger("access")
|
||||
log.info(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
user_id=user_id,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
response.headers["X-Request-Id"] = request_id
|
||||
return response
|
||||
187
backend/src/life_towers/main.py
Normal file
187
backend/src/life_towers/main.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""ASGI app, lifespan, static files mount, route registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .api import router
|
||||
from .db import db_connection, run_migrations
|
||||
from .limits import limiter, payload_size_middleware
|
||||
from .logging import configure_logging, request_logging_middleware
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Map HTTP status codes to error code strings
|
||||
STATUS_CODE_MAP: dict[int, str] = {
|
||||
400: "bad_request",
|
||||
401: "unauthorized",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
405: "method_not_allowed",
|
||||
409: "conflict",
|
||||
413: "payload_too_large",
|
||||
422: "bad_request",
|
||||
429: "rate_limited",
|
||||
507: "quota_exceeded",
|
||||
500: "server_error",
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
configure_logging()
|
||||
logger.info("starting_up")
|
||||
with db_connection() as conn:
|
||||
run_migrations(conn)
|
||||
logger.info("migrations_complete")
|
||||
yield
|
||||
logger.info("shutting_down")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Life Towers",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
)
|
||||
|
||||
# Rate limiter state
|
||||
app.state.limiter = limiter
|
||||
|
||||
# CORS (only if env var set)
|
||||
allowed_origin = os.environ.get("LIFE_TOWERS_ALLOWED_ORIGIN")
|
||||
if allowed_origin:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[allowed_origin],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Payload size middleware
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=payload_size_middleware)
|
||||
|
||||
# Request logging middleware
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware)
|
||||
|
||||
# Rate limit exceeded handler
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Pydantic / FastAPI validation errors → 400 bad_request.
|
||||
# We do NOT echo the user-supplied input back in the detail string —
|
||||
# pydantic's default messages include the offending value, which would
|
||||
# reflect arbitrary attacker-controlled content. Instead we just list
|
||||
# the failing field paths.
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
fields = sorted(
|
||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
||||
)
|
||||
if fields:
|
||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||
else:
|
||||
detail_str = "Validation failed"
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "bad_request", "detail": detail_str},
|
||||
)
|
||||
|
||||
# HTTP exception handler → normalized JSON
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
if isinstance(exc.detail, dict):
|
||||
# Already shaped correctly (from auth.py etc.)
|
||||
detail = exc.detail
|
||||
else:
|
||||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||
detail = {"error": code, "detail": str(exc.detail)}
|
||||
|
||||
headers = getattr(exc, "headers", None) or {}
|
||||
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||
|
||||
# Generic 500 handler
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("unhandled_exception", exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "detail": "Internal server error"},
|
||||
)
|
||||
|
||||
# Register API router
|
||||
app.include_router(router)
|
||||
|
||||
# Static files
|
||||
static_dir = Path(os.environ.get("LIFE_TOWERS_STATIC_DIR", "/app/static"))
|
||||
if static_dir.exists() and static_dir.is_dir():
|
||||
_mount_static(app, static_dir)
|
||||
else:
|
||||
logger.warning("static_dir_missing", path=str(static_dir))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _mount_static(app: FastAPI, static_dir: Path) -> None:
|
||||
"""Mount static files with SPA fallback and proper cache headers."""
|
||||
|
||||
import re
|
||||
|
||||
# Matches Angular's content-hashed asset filenames, e.g.
|
||||
# main-L5B7PG5E.js, chunk-deadbeef.css, styles-6JWNHNC2.css, font.AbCdEf12.woff2
|
||||
# The hash is at least 8 chars of alphanumerics, separated from the base name
|
||||
# by either '-' (modern Angular) or '.' (older Angular / generic).
|
||||
HASHED_PATTERN = re.compile(
|
||||
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
|
||||
)
|
||||
|
||||
def _serve_file(file_path: Path) -> FileResponse:
|
||||
resp = FileResponse(str(file_path))
|
||||
if HASHED_PATTERN.search(file_path.name):
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str) -> Response:
|
||||
# API routes are handled by the API router (registered before this);
|
||||
# if execution reaches here for an /api/* path, it really is unknown.
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Real file? Serve it (with cache headers based on hash detection).
|
||||
# Guard against path traversal by resolving and re-checking containment.
|
||||
candidate = (static_dir / full_path).resolve()
|
||||
try:
|
||||
candidate.relative_to(static_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if candidate.is_file():
|
||||
return _serve_file(candidate)
|
||||
|
||||
# SPA fallback to index.html.
|
||||
index = static_dir / "index.html"
|
||||
if index.is_file():
|
||||
return _serve_file(index)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
app = create_app()
|
||||
54
backend/src/life_towers/migrations/001_initial.sql
Normal file
54
backend/src/life_towers/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- Life Towers v4 initial schema.
|
||||
-- SQLite with WAL mode and foreign keys enabled at connection time.
|
||||
-- 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,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)),
|
||||
default_date_from INTEGER,
|
||||
default_date_to INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_user_position ON pages(user_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS towers (
|
||||
id TEXT PRIMARY KEY,
|
||||
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
base_color_h REAL NOT NULL CHECK (base_color_h BETWEEN 0 AND 1),
|
||||
base_color_s REAL NOT NULL CHECK (base_color_s BETWEEN 0 AND 1),
|
||||
base_color_l REAL NOT NULL CHECK (base_color_l BETWEEN 0 AND 1),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_towers_page_position ON towers(page_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_towers_user ON towers(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
tower_id TEXT NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_tower_position ON blocks(tower_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_user ON blocks(user_id);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (keep_tasks_open IN (0, 1));
|
||||
150
backend/src/life_towers/models.py
Normal file
150
backend/src/life_towers/models.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Pydantic v2 models matching the API spec exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
import uuid as _uuid_mod
|
||||
|
||||
|
||||
def _is_uuidv4(value: str) -> bool:
|
||||
try:
|
||||
u = _uuid_mod.UUID(value)
|
||||
return u.version == 4
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
class HslColor(BaseModel):
|
||||
h: float = Field(ge=0.0, le=1.0)
|
||||
s: float = Field(ge=0.0, le=1.0)
|
||||
l: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class BlockIn(BaseModel):
|
||||
id: str
|
||||
tag: str = Field(max_length=200)
|
||||
description: str = Field(max_length=10_000)
|
||||
is_done: bool
|
||||
created_at: Optional[int] = None
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class BlockOut(BaseModel):
|
||||
id: str
|
||||
tag: str
|
||||
description: str
|
||||
is_done: bool
|
||||
created_at: int
|
||||
|
||||
|
||||
class TowerIn(BaseModel):
|
||||
id: str
|
||||
name: str = Field(max_length=200)
|
||||
base_color: HslColor
|
||||
blocks: list[BlockIn] = Field(max_length=1000)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class TowerOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
base_color: HslColor
|
||||
blocks: list[BlockOut]
|
||||
|
||||
|
||||
class PageIn(BaseModel):
|
||||
id: str
|
||||
name: str = Field(max_length=200)
|
||||
hide_create_tower_button: bool = False
|
||||
keep_tasks_open: bool = False
|
||||
default_date_from: Optional[int] = None
|
||||
default_date_to: Optional[int] = None
|
||||
towers: list[TowerIn] = Field(max_length=100)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class PageOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
hide_create_tower_button: bool
|
||||
keep_tasks_open: bool
|
||||
default_date_from: Optional[int]
|
||||
default_date_to: Optional[int]
|
||||
towers: list[TowerOut]
|
||||
|
||||
|
||||
class DataIn(BaseModel):
|
||||
pages: list[PageIn] = Field(max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_unique_ids(self) -> "DataIn":
|
||||
page_ids: set[str] = set()
|
||||
tower_ids: set[str] = set()
|
||||
block_ids: set[str] = set()
|
||||
total_blocks = 0
|
||||
|
||||
for page in self.pages:
|
||||
if page.id in page_ids:
|
||||
raise ValueError(f"Duplicate page id: {page.id}")
|
||||
page_ids.add(page.id)
|
||||
|
||||
for tower in page.towers:
|
||||
if tower.id in tower_ids:
|
||||
raise ValueError(f"Duplicate tower id: {tower.id}")
|
||||
tower_ids.add(tower.id)
|
||||
|
||||
for block in tower.blocks:
|
||||
if block.id in block_ids:
|
||||
raise ValueError(f"Duplicate block id: {block.id}")
|
||||
block_ids.add(block.id)
|
||||
total_blocks += 1
|
||||
|
||||
if total_blocks > 50_000:
|
||||
raise ValueError(
|
||||
f"Total blocks ({total_blocks}) exceeds maximum of 50,000"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class DataOut(BaseModel):
|
||||
pages: list[PageOut]
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
Loading…
Add table
Add a link
Reference in a new issue