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>
274 lines
10 KiB
Python
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()
|