fix: harden API, add rate limiting, disable debug by default

- Add input validation and error handling across all API endpoints
  (malformed JSON, missing fields, invalid types return proper 4xx)
- Add per-IP rate limiting (10/min new games, 60/min other requests)
- Add session rollback on unhandled exceptions
- Default debug=False, add --debug CLI flag to opt in
- Frontend: add fetch error handling, fix feedback timeout stacking,
  disable buttons during requests, allow clicking found cells for
  overlapping word selection
- Expand seed data to 7 categories with 25-35 words each

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-07 00:59:47 -06:00
co-authored by Claude Opus 4.6
parent 4d681a92ca
commit cd1dc9200b
11 changed files with 708 additions and 110 deletions
+98 -5
View File
@@ -1,6 +1,8 @@
"""REST API handlers for game operations."""
import collections
import json
import time
from datetime import datetime, timezone
import tornado.web
@@ -9,6 +11,27 @@ from game.factory import BoardFactory, DIRECTIONS
from models.category import Category
from models.game import Game, Word
# Per-IP rate limit: tracks {ip: deque of timestamps}
_rate_buckets = collections.defaultdict(collections.deque)
# Limits: (max_requests, window_seconds)
RATE_LIMITS = {
"default": (60, 60),
"new_game": (10, 60),
}
def _check_rate(ip, bucket="default"):
max_requests, window = RATE_LIMITS[bucket]
now = time.monotonic()
timestamps = _rate_buckets[f"{bucket}:{ip}"]
while timestamps and timestamps[0] <= now - window:
timestamps.popleft()
if len(timestamps) >= max_requests:
return False
timestamps.append(now)
return True
class BaseAPIHandler(tornado.web.RequestHandler):
"""Base handler for JSON API endpoints."""
@@ -20,6 +43,14 @@ class BaseAPIHandler(tornado.web.RequestHandler):
Session = self.application.settings["session_factory"]
return Session()
def check_rate_limit(self, bucket="default"):
"""Return True if request is allowed, else write 429 and return False."""
if not _check_rate(self.request.remote_ip, bucket):
self.set_status(429)
self.write(json.dumps({"error": "Too many requests"}))
return False
return True
def write_error(self, status_code, **kwargs):
self.write(json.dumps({"error": self._reason}))
@@ -32,6 +63,9 @@ class CategoriesHandler(BaseAPIHandler):
try:
categories = session.query(Category).all()
self.write(json.dumps([{"id": c.id, "name": c.name} for c in categories]))
except Exception:
session.rollback()
raise
finally:
session.close()
@@ -39,28 +73,72 @@ class CategoriesHandler(BaseAPIHandler):
class NewGameHandler(BaseAPIHandler):
"""``POST /api/game/new`` -- create a new game.
Expects JSON body with ``category_id`` and optional ``board_size`` (8-20).
Expects JSON body with ``category_id`` and optional ``board_size`` (8-20)
and ``word_count`` (3-30).
"""
def post(self):
if not self.check_rate_limit("new_game"):
return
session = self.get_session()
try:
data = json.loads(self.request.body)
try:
data = json.loads(self.request.body)
except (json.JSONDecodeError, TypeError):
self.set_status(400)
self.write(json.dumps({"error": "Invalid JSON"}))
return
if "category_id" not in data:
self.set_status(400)
self.write(json.dumps({"error": "category_id is required"}))
return
category_id = data["category_id"]
board_size = data.get("board_size", 10)
try:
board_size = int(data.get("board_size", 10))
except (TypeError, ValueError):
self.set_status(400)
self.write(json.dumps({"error": "board_size must be a number"}))
return
board_size = max(8, min(20, board_size))
try:
word_count = int(data.get("word_count", 8))
except (TypeError, ValueError):
self.set_status(400)
self.write(json.dumps({"error": "word_count must be a number"}))
return
word_count = max(3, min(30, word_count))
category = session.query(Category).filter_by(id=category_id).first()
if not category:
self.set_status(404)
self.write(json.dumps({"error": "Category not found"}))
return
factory = BoardFactory(session)
game, words = factory.create(
category_id=category_id,
board_size=board_size,
word_count=word_count,
)
if not words:
self.set_status(500)
self.write(json.dumps({"error": "Failed to generate board with words"}))
return
session.add(game)
for w in words:
session.add(w)
session.commit()
self.write(json.dumps(self._game_to_dict(game)))
except Exception:
session.rollback()
raise
finally:
session.close()
@@ -108,6 +186,9 @@ class GameStateHandler(BaseAPIHandler):
}
)
)
except Exception:
session.rollback()
raise
finally:
session.close()
@@ -120,6 +201,8 @@ class GuessHandler(BaseAPIHandler):
"""
def post(self, game_id):
if not self.check_rate_limit():
return
session = self.get_session()
try:
game = session.query(Game).filter_by(id=game_id).first()
@@ -128,7 +211,12 @@ class GuessHandler(BaseAPIHandler):
self.write(json.dumps({"error": "Game not found"}))
return
data = json.loads(self.request.body)
try:
data = json.loads(self.request.body)
except (json.JSONDecodeError, TypeError):
self.set_status(400)
self.write(json.dumps({"error": "Invalid JSON"}))
return
if "word" in data:
result = self._handle_text_guess(session, game, data["word"])
@@ -140,10 +228,15 @@ class GuessHandler(BaseAPIHandler):
return
self.write(json.dumps(result))
except Exception:
session.rollback()
raise
finally:
session.close()
def _handle_text_guess(self, session, game, guess_text):
if not isinstance(guess_text, str):
return self._miss_response(game, session)
guess_text = guess_text.upper().strip()
word = (
session.query(Word)
@@ -159,7 +252,7 @@ class GuessHandler(BaseAPIHandler):
grid = game.grid
try:
letters = "".join(grid[c["row"]][c["col"]] for c in cells)
except (IndexError, KeyError):
except (IndexError, KeyError, TypeError):
return self._miss_response(game, session)
word = (