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
+121
-72
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user