life-towers/backend/src/life_towers/api.py

371 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import json
import sqlite3
import time
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from . import events
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,
DataOut,
HealthResponse,
HslColor,
PageOut,
PutDataResponse,
RegisterRequest,
RegisterResponse,
TowerOut,
)
router = APIRouter(prefix="/api/v1")
logger = structlog.get_logger(__name__)
# How long the SSE loop waits for a new revision before emitting a keepalive
# comment. Keeps the connection warm through proxies and bounds disconnect
# detection latency.
SSE_KEEPALIVE_SECONDS = 20
def _read_revision(conn: sqlite3.Connection, user_id: str) -> int:
row = conn.execute(
"SELECT revision FROM users WHERE id = ?", (user_id,)
).fetchone()
return int(row["revision"]) if row is not None else 0
def _parse_if_match(value: str | None) -> int | None:
"""Parse the client's CAS base revision from the If-Match header.
Tolerates an ETag-style quoted form (``"5"``). Returns None when the header
is absent or unparseable, in which case the write proceeds unguarded
(last-writer-wins) — this keeps older cached clients working across a deploy.
"""
if value is None:
return None
cleaned = value.strip().strip('"')
try:
return int(cleaned)
except ValueError:
return None
@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_log_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:
revision = _read_revision(conn, user_id)
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, difficulty, 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"]),
difficulty=b["difficulty"],
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, revision=revision)
@router.put("/data", response_model=PutDataResponse)
@limiter.limit("30/minute")
async def put_data(
request: Request,
body: DataIn,
user_id: Annotated[str, Depends(get_current_user)],
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
) -> PutDataResponse:
# 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())
base_revision = _parse_if_match(if_match)
with db_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
current_revision = _read_revision(conn, user_id)
# Compare-and-swap: reject if another client advanced the revision
# under us. The client refetches, merges, and retries with the
# fresh base. An absent If-Match opts out of the guard.
if base_revision is not None and base_revision != current_revision:
conn.rollback()
raise HTTPException(
status_code=409,
detail={
"error": "conflict",
"detail": "Revision is stale; refetch and retry",
"revision": current_revision,
},
)
new_revision = current_revision + 1
# 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, difficulty,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
block.id,
tower.id,
user_id,
block_pos,
block.tag,
block.description,
1 if block.is_done else 0,
block.difficulty,
created_at,
now,
),
)
# Bump the revision and refresh last_seen_at in the same transaction.
conn.execute(
"UPDATE users SET last_seen_at = ?, revision = ? WHERE id = ?",
(now, new_revision, user_id),
)
conn.commit()
except HTTPException:
# Already rolled back (e.g. the CAS 409); just propagate.
raise
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
# Notify other live clients for this user to refetch. Done after commit so
# subscribers never observe a revision that isn't durably stored yet.
events.publish(user_id, new_revision)
logger.info(
"data_replaced",
user_id=token_log_id(user_id),
pages=len(body.pages),
revision=new_revision,
)
return PutDataResponse(revision=new_revision)
def _format_revision_event(revision: int) -> str:
return f"event: revision\ndata: {json.dumps({'revision': revision})}\n\n"
@router.get("/events")
async def events_stream(
request: Request,
user_id: Annotated[str, Depends(get_current_user)],
) -> StreamingResponse:
"""Server-Sent Events stream that notifies a client to refetch.
Authenticated with the same Bearer token as the rest of the API — clients
consume it via fetch()+ReadableStream (EventSource cannot set headers), so
the token never leaks into a URL. Each event carries the current revision;
the client refetches GET /data when it sees a revision newer than its own.
"""
queue = events.subscribe(user_id)
async def event_generator():
try:
# Emit the current revision immediately so a (re)connecting client
# can catch up on anything it missed while disconnected.
with db_connection() as conn:
yield _format_revision_event(_read_revision(conn, user_id))
while True:
try:
revision = await asyncio.wait_for(
queue.get(), timeout=SSE_KEEPALIVE_SECONDS
)
yield _format_revision_event(revision)
except asyncio.TimeoutError:
if await request.is_disconnected():
break
# Comment line: keeps proxies from idling the connection out.
yield ": keepalive\n\n"
finally:
events.unsubscribe(user_id, queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
# Disable nginx response buffering for this stream even if the
# location block forgets `proxy_buffering off`.
"X-Accel-Buffering": "no",
},
)