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
+121 -72
View File
@@ -4,6 +4,7 @@
let gameState = null;
let timerInterval = null;
let selectedCells = [];
let feedbackTimeout = null;
// DOM refs
const setupDiv = document.getElementById("game-setup");
@@ -11,12 +12,13 @@
const categorySelect = document.getElementById("category-select");
const boardSizeInput = document.getElementById("board-size");
const sizeDisplay = document.getElementById("size-display");
const wordCountInput = document.getElementById("word-count");
const wordCountDisplay = document.getElementById("word-count-display");
const newGameBtn = document.getElementById("new-game-btn");
const gridTable = document.getElementById("grid-table");
const wordList = document.getElementById("word-list");
const wordInput = document.getElementById("word-input");
const guessBtn = document.getElementById("guess-btn");
const submitSelectionBtn = document.getElementById("submit-selection-btn");
const clearSelectionBtn = document.getElementById("clear-selection-btn");
const guessFeedback = document.getElementById("guess-feedback");
const timerEl = document.getElementById("timer");
@@ -27,32 +29,50 @@
// Load categories
async function loadCategories() {
const resp = await fetch("/api/categories");
const categories = await resp.json();
categorySelect.innerHTML = "";
categories.forEach(function (cat) {
const opt = document.createElement("option");
opt.value = cat.id;
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
categorySelect.appendChild(opt);
});
try {
const resp = await fetch("/api/categories");
if (!resp.ok) throw new Error("Server error");
const categories = await resp.json();
categorySelect.innerHTML = "";
categories.forEach(function (cat) {
const opt = document.createElement("option");
opt.value = cat.id;
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
categorySelect.appendChild(opt);
});
} catch (err) {
showFeedback("Failed to load categories.", "text-danger");
}
}
boardSizeInput.addEventListener("input", function () {
sizeDisplay.textContent = this.value;
});
wordCountInput.addEventListener("input", function () {
wordCountDisplay.textContent = this.value;
});
// New game
newGameBtn.addEventListener("click", async function () {
const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value);
const resp = await fetch("/api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_id: categoryId, board_size: boardSize }),
});
gameState = await resp.json();
showBoard();
try {
const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value);
const wordCount = parseInt(wordCountInput.value);
const resp = await fetch("/api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_id: categoryId, board_size: boardSize, word_count: wordCount }),
});
if (!resp.ok) {
const errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.error || "Failed to create game");
}
gameState = await resp.json();
showBoard();
} catch (err) {
showFeedback(err.message || "Failed to create game.", "text-danger");
}
});
function showBoard() {
@@ -130,63 +150,85 @@
const word = wordInput.value.trim();
if (!word) return;
wordInput.value = "";
await sendGuess({ word: word });
guessBtn.disabled = true;
try {
await sendGuess({ word: word });
} finally {
guessBtn.disabled = false;
}
}
// Click selection
// Click selection — two-click: first letter, then last letter
function onCellClick(row, col, td) {
if (gameState.status === "completed") return;
// Check if already selected — deselect
const idx = selectedCells.findIndex(
function (c) { return c.row === row && c.col === col; }
);
if (idx !== -1) {
selectedCells.splice(idx, 1);
td.classList.remove("cell-selected");
// No start cell yet — this is the first click
if (selectedCells.length === 0) {
selectedCells.push({ row: row, col: col });
td.classList.add("cell-selected");
updateSelectionButtons();
return;
}
// Validate straight line
if (selectedCells.length >= 2) {
if (!isValidExtension(row, col)) {
showFeedback("Selection must form a straight line.", "text-warning");
return;
}
// Clicking the same cell — deselect
if (selectedCells[0].row === row && selectedCells[0].col === col) {
clearSelection();
return;
}
selectedCells.push({ row: row, col: col });
td.classList.add("cell-selected");
updateSelectionButtons();
}
// Second click — compute line from start to end
var startR = selectedCells[0].row;
var startC = selectedCells[0].col;
var dr = row - startR;
var dc = col - startC;
function isValidExtension(row, col) {
if (selectedCells.length < 2) return true;
const dr = selectedCells[1].row - selectedCells[0].row;
const dc = selectedCells[1].col - selectedCells[0].col;
const len = selectedCells.length;
const expectedRow = selectedCells[0].row + dr * len;
const expectedCol = selectedCells[0].col + dc * len;
return row === expectedRow && col === expectedCol;
// Validate straight line
var absDr = Math.abs(dr);
var absDc = Math.abs(dc);
if (absDr !== absDc && absDr !== 0 && absDc !== 0) {
showFeedback("Selection must form a straight line.", "text-warning");
clearSelection();
return;
}
var steps = Math.max(absDr, absDc);
if (steps === 0) return;
// Unit direction
var stepR = dr === 0 ? 0 : dr / absDr;
var stepC = dc === 0 ? 0 : dc / absDc;
// Build full cell list from start to end
var cells = [];
for (var i = 0; i <= steps; i++) {
cells.push({ row: startR + stepR * i, col: startC + stepC * i });
}
// Highlight all cells
cells.forEach(function (c) {
var cell = gridTable.querySelector(
'td[data-row="' + c.row + '"][data-col="' + c.col + '"]'
);
if (cell) cell.classList.add("cell-selected");
});
// Auto-submit
selectedCells = cells;
guessBtn.disabled = true;
sendGuess({ cells: cells }).then(function () {
guessBtn.disabled = false;
clearSelection();
});
}
function updateSelectionButtons() {
if (selectedCells.length > 0) {
submitSelectionBtn.classList.remove("d-none");
clearSelectionBtn.classList.remove("d-none");
} else {
submitSelectionBtn.classList.add("d-none");
clearSelectionBtn.classList.add("d-none");
}
}
submitSelectionBtn.addEventListener("click", async function () {
if (selectedCells.length === 0) return;
await sendGuess({ cells: selectedCells });
clearSelection();
});
clearSelectionBtn.addEventListener("click", clearSelection);
function clearSelection() {
@@ -199,25 +241,30 @@
// Send guess to server
async function sendGuess(payload) {
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
try {
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error("Server error");
const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
if (result.correct) {
highlightFoundCells(result.cells);
renderWordList();
showFeedback("Found: " + result.word + "!", "text-success");
} else {
showFeedback("Not a match.", "text-danger");
}
if (result.correct) {
highlightFoundCells(result.cells);
renderWordList();
showFeedback("Found: " + result.word + "!", "text-success");
} else {
showFeedback("Not a match.", "text-danger");
}
if (result.status === "completed") {
onGameComplete(result.completed_at);
if (result.status === "completed") {
onGameComplete(result.completed_at);
}
} catch (err) {
showFeedback("Request failed. Try again.", "text-danger");
}
}
@@ -231,11 +278,13 @@
}
function showFeedback(msg, cls) {
if (feedbackTimeout) clearTimeout(feedbackTimeout);
guessFeedback.textContent = msg;
guessFeedback.className = "mt-2 " + cls;
setTimeout(function () {
feedbackTimeout = setTimeout(function () {
guessFeedback.textContent = "";
guessFeedback.className = "mt-2";
feedbackTimeout = null;
}, 2000);
}