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:
co-authored by
Claude Opus 4.6
parent
4d681a92ca
commit
cd1dc9200b
+98
-5
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user