Files
pirats/src/pirats/main.py
Tim McCarthy 122e66b817 Defensive coding: sanitize free text, constrain inputs
Player-authored free text is scrubbed at the API boundary before storage via a
new validation.py: drop control characters and unpaired surrogates, trim
whitespace, and cap length (names 120, other text 2000). Applied to crew/player
names, the character sheet, like/hate answers, techniques, recruit techniques,
the renamed Name, the Gat description, and challenge stakes. Values matched
against stored text (swap offers, face-card assignments) and identifiers/enums
are left untouched so they still compare equal.

Constrained set-role to the two real roles so it can't stash an arbitrary string
in the column. Audited the rest: queries are parameterized by SQLModel (the lone
f-string SQL in database.py is the hardcoded legacy-migration list, no user
input); objective-type and rollback writes already whitelist their keys; no
endpoint accepts a generic (field, value) write.

Tests cover the sanitizer (control chars, surrogates, emoji, caps) and the
create/join HTTP path end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:54:21 -07:00

274 lines
10 KiB
Python

import asyncio
import contextlib
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
import logging
from logging.handlers import RotatingFileHandler
import re
import sys
import uvicorn
import os
from pathlib import Path
from . import crud
from . import maintenance
from .database import run_migrations, get_session, engine
from .validation import sanitize_name
from .ws import manager
logger = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 50
BASE_DIR = Path(__file__).parent
_logging_configured = False
def configure_logging() -> None:
"""Set up backend logging: always to stderr, plus a rotating file when
PIRATS_LOG_FILE is set (the NixOS service points it at /var/log/pirats/).
Level comes from PIRATS_LOG_LEVEL (default INFO). Idempotent so it's safe to
call from both the CLI entrypoint and the app's startup lifespan."""
global _logging_configured
if _logging_configured:
return
_logging_configured = True
level_name = os.environ.get("PIRATS_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
root = logging.getLogger()
root.setLevel(level)
fmt = logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setFormatter(fmt)
root.addHandler(stderr_handler)
log_file = os.environ.get("PIRATS_LOG_FILE")
if log_file:
try:
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(fmt)
root.addHandler(file_handler)
logger.info("Logging to %s", log_file)
except OSError:
# A bad/unwritable path mustn't take the server down — keep stderr.
logger.exception("Could not open log file %s; logging to stderr only", log_file)
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
logger.info(
"Starting Rats with Gats backend (db=%s, dev_mode_default=%s)",
engine.url.render_as_string(hide_password=True),
crud.dev_mode_default(),
)
run_migrations()
logger.info("Database migrations applied")
purge_cfg = maintenance.purge_config()
purge_task = None
if purge_cfg.enabled:
purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg))
else:
logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)")
yield
if purge_task is not None:
purge_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await purge_task
logger.info("Shutting down")
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)
api = APIRouter()
# Every state mutation is a POST under /api/game/{game_id}/..., so one
# middleware can notify a game's websocket clients after any successful write.
GAME_PATH_RE = re.compile(r"^/api/game/([^/]+)/")
# The full-state rollback endpoint manages its own history, so it's the one mutation
# we don't checkpoint. Matched precisely (not a bare "/rollback" suffix) so it stays
# unambiguous if other "*/rollback" endpoints are ever added.
STATE_ROLLBACK_PATH_RE = re.compile(r"/player/[^/]+/rollback$")
def _checkpoint_after_mutation(game_id: str):
"""After a successful mutation, maintain rollback history: snapshot the state
while a scene is in progress, otherwise clear any scene's history. Runs in its own
session (the request's has already committed and closed by the time this fires)."""
with Session(engine) as db:
game = crud.get_game(db, game_id)
if game:
crud.maintain_rollback_history(db, game)
@app.middleware("http")
async def broadcast_state_changes(request, call_next):
response = await call_next(request)
if request.method == "POST" and response.status_code < 400:
match = GAME_PATH_RE.match(request.url.path)
if match:
game_id = match.group(1)
# The full-state rollback endpoint manages its own state; don't checkpoint it.
if not STATE_ROLLBACK_PATH_RE.search(request.url.path):
try:
await run_in_threadpool(_checkpoint_after_mutation, game_id)
except Exception:
# A checkpointing failure must not fail the user's action, which
# has already committed; the next mutation will checkpoint again.
logger.exception("Checkpoint capture failed for game %s", game_id)
await manager.broadcast(game_id, {"type": "state_changed"})
return response
@app.websocket("/api/game/{game_id}/ws")
async def game_websocket(websocket: WebSocket, game_id: str):
await manager.connect(game_id, websocket)
try:
while True:
# Clients don't send anything meaningful; this just detects disconnects.
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.disconnect(game_id, websocket)
# --- API Core Route Handlers ---
@api.post("/game")
def create_game_route(
crew_name: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.create_game(db, crew_name=sanitize_name(crew_name))
# admin_key is only revealed here, to the creator; the state endpoint hides it
return {"id": game.id, "admin_key": game.admin_key}
@api.post("/game/{game_id}/join")
def join_game_submit(
game_id: str,
name: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# First player is automatically the creator
is_creator = len(game.players) == 0
player = crud.add_player(db, game_id, sanitize_name(name), is_creator=is_creator)
return {"id": player.id}
@api.post("/game/{game_id}/player/{player_id}/leave")
def leave_game_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
"""A player voluntarily removes themselves from the game (counterpart to join)."""
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player or player.game_id != game.id:
raise HTTPException(status_code=404, detail="Game or Player not found")
ok, msg = crud.leave_game(db, game, player)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
@api.get("/game/{game_id}/player/{player_id}/state")
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Game or Player not found")
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
return {
# admin_key stays hidden except from Admins, who need it to open the admin panel
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
"player": player.model_dump(),
# A swap offer must reveal nothing but its existence: only the offerer
# gets to see which technique they put on the table.
"players": [p.model_dump(exclude=(set() if p.id == player.id else {"swap_offer_technique"})) for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes],
"events": [e.model_dump() for e in events],
"events_have_more": events_have_more,
# The newest checkpoint id, so the log can tell which event is "you are here".
"latest_checkpoint_id": crud.latest_checkpoint_id(db, game_id),
}
@api.get("/game/{game_id}/events")
def get_game_events(
game_id: str,
before: Optional[float] = None,
limit: int = EVENT_PAGE_SIZE,
db: Session = Depends(get_session),
):
"""Pages back through the event log: returns the newest `limit` events older than `before`."""
game = crud.get_game(db, game_id)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
limit = max(1, min(limit, 200))
events, have_more = crud.get_events_page(db, game_id, before=before, limit=limit)
return {
"events": [e.model_dump() for e in events],
"have_more": have_more,
}
# --- Register Modular Phase Routers ---
from .routes_lobby import router as lobby_router
from .routes_character import router as character_router
from .routes_scene import router as scene_router
from .routes_challenge import router as challenge_router
from .routes_upkeep import router as upkeep_router
from .routes_admin import router as admin_router
from .routes_rollback import router as rollback_router
api.include_router(lobby_router)
api.include_router(character_router)
api.include_router(scene_router)
api.include_router(challenge_router)
api.include_router(upkeep_router)
api.include_router(admin_router)
api.include_router(rollback_router)
# Mount API at /api
app.include_router(api, prefix="/api")
# Static rulebook page (registered before the SPA mount so it takes precedence)
@app.get("/rules", include_in_schema=False)
def rules_page():
return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
# Mount SPA
static_dir = BASE_DIR / "static"
if os.path.exists(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
def main():
configure_logging()
import argparse
parser = argparse.ArgumentParser(description="Run the Rats with Gats web app")
parser.add_argument("--host", default="0.0.0.0", help="Host address to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
args = parser.parse_args()
if args.reload:
uvicorn.run("pirats.main:app", host=args.host, port=args.port, reload=True)
else:
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()