diff --git a/goodnews/api.py b/goodnews/api.py index 08a3eba..fe6a900 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1579,27 +1579,34 @@ def create_app() -> FastAPI: return (game == "word" and variant in games.WORD_VARIANTS) or \ (game == "wordsearch" and variant in games.WS_TIERS) + def _valid_pdate(d: str) -> bool: + return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", d or "")) # plain YYYY-MM-DD, no junk rows + @app.get("/api/games/state") def game_state_get(game: str, variant: str, date: str, request: Request) -> dict: if not _game_ok(game, variant): raise HTTPException(status_code=404, detail="no such game") + if not _valid_pdate(date): + raise HTTPException(status_code=400, detail="bad date") with get_conn() as conn: user = _current_user(conn, request) if not user: return {"state": None} - return {"state": games.load_game_state(conn, user["id"], game, variant, date[:10])} + return {"state": games.load_game_state(conn, user["id"], game, variant, date)} @app.put("/api/games/state") def game_state_put(body: GameStateBody, request: Request) -> dict: if not _game_ok(body.game, body.variant): raise HTTPException(status_code=404, detail="no such game") + if not _valid_pdate(body.date): + raise HTTPException(status_code=400, detail="bad date") if len(json.dumps(body.state)) > 20000: # a real game state is tiny — reject junk raise HTTPException(status_code=413, detail="state too large") with get_conn() as conn: user = _current_user(conn, request) if not user: return {"state": body.state} # signed out → no sync, just echo - merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date[:10], body.state or {}) + merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date, body.state or {}) return {"state": merged} @app.get("/api/games/stats") diff --git a/goodnews/games.py b/goodnews/games.py index 1f8fdad..997fa83 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -592,11 +592,95 @@ def load_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: return None +def _int(x) -> int: + try: + return int(x) + except (TypeError, ValueError): + return 0 + + +def _sanitize_wordsearch(conn: sqlite3.Connection, variant: str, date: str, state: dict) -> dict: + """Trust only finds that are real for THIS puzzle: word in the day's list and + cells that actually spell it in the grid (validated when the puzzle exists, + shape-only otherwise). Dedupes, renumbers colours, and derives completion from + the real word count — never from a client-sent `ms` alone.""" + words: list[str] = [] + grid: list[str] = [] + if variant in WS_TIERS and conn.execute( + "SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,) + ).fetchone(): + try: + p = wordsearch_response(conn, date, variant) # read-only; today's puzzle already exists + words, grid = list(p.get("words") or []), list(p.get("grid") or []) + except Exception: # noqa: BLE001 + words, grid = [], [] + wset = set(words) + clean: list[dict] = [] + seen: set[str] = set() + for fw in (state.get("foundWords") or []): + if not isinstance(fw, dict): + continue + w, cells = fw.get("word"), fw.get("cells") + if not isinstance(w, str) or w in seen or not isinstance(cells, list): + continue + try: + cells = [[int(r), int(c)] for r, c in cells] + except (TypeError, ValueError): + continue + if len(cells) != len(w): + continue + if grid: # validate the find spells the word in the real grid + if w not in wset: + continue + spelled = "".join(grid[r][c] for r, c in cells if 0 <= r < len(grid) and 0 <= c < len(grid[r])) + if spelled != w: + continue + elif not (4 <= len(w) <= 12 and w.isalpha()): # no puzzle to check against → shape only + continue + seen.add(w) + clean.append({"word": w, "cells": cells, "ci": len(clean) % 10}) + done = bool(words) and len(clean) == len(words) + return {"foundWords": clean, "startTime": _int(state.get("startTime")), + "ms": _int(state.get("ms")) if done else 0} + + +_WORD_COLOURS = {"absent", "present", "correct"} + + +def _sanitize_word(variant: str, state: dict) -> dict: + """Validate shapes: status enum, guess count/length, colour rows, terminal fields.""" + n = WORD_VARIANTS[variant]["length"] + maxg = WORD_VARIANTS[variant]["guesses"] + status = state.get("status") if state.get("status") in ("playing", "won", "lost") else "playing" + guesses = [g.lower() for g in (state.get("guesses") or [])[:maxg] + if isinstance(g, str) and len(g) == n and g.isalpha()] + cols = [] + if isinstance(state.get("cols"), list): + for row in state["cols"][:len(guesses)]: + cols.append([c for c in row if c in _WORD_COLOURS][:n] if isinstance(row, list) else []) + out = {"guesses": guesses, "cols": cols, "status": status} + if status in ("won", "lost"): + ans = state.get("answer") + if isinstance(ans, str) and len(ans) == n and ans.isalpha(): + out["answer"] = ans.lower() + if isinstance(state.get("why"), str): + out["why"] = state["why"][:600] + return out + + +def sanitize_game_state(conn: sqlite3.Connection, game: str, variant: str, date: str, state: dict) -> dict: + """Never trust client JSON at the storage layer — normalize before merge/store.""" + if game == "wordsearch": + return _sanitize_wordsearch(conn, variant, date, state or {}) + return _sanitize_word(variant, state or {}) + + def save_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str, date: str, incoming: dict) -> dict: - """Merge incoming with the stored state (server-authoritative) and persist.""" + """Sanitize → merge with the stored state (server-authoritative) → sanitize → persist.""" + clean_in = sanitize_game_state(conn, game, variant, date, incoming or {}) stored = load_game_state(conn, user_id, game, variant, date) - merged = merge_game_state(game, stored, incoming or {}) + merged = sanitize_game_state(conn, game, variant, date, merge_game_state(game, stored, clean_in)) conn.execute( "INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json, updated_at) " "VALUES (?,?,?,?,?,CURRENT_TIMESTAMP) " diff --git a/tests/test_game_sync.py b/tests/test_game_sync.py index c521c1c..8a674ec 100644 --- a/tests/test_game_sync.py +++ b/tests/test_game_sync.py @@ -48,12 +48,17 @@ def test_merge_handles_missing_sides(): # --- persistence convergence --- +def _find(word, row): # a shape-valid find: cells spelling the word along a row + return {"word": word, "cells": [[row, i] for i in range(len(word))], "ci": 0} + + def test_save_converges_across_devices(conn): + # No stored puzzle for this date → shape-only sanitize (words 4-12, cells match). games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12", - {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 100}) + {"foundWords": [_find("BEACH", 0)], "startTime": 100}) merged = games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12", - {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 50}) - assert {f["word"] for f in merged["foundWords"]} == {"CAT", "DOG"} + {"foundWords": [_find("OCEAN", 1)], "startTime": 50}) + assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} # stored state reflects the merge (order-independent) assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["startTime"] == 50 @@ -61,9 +66,9 @@ def test_save_converges_across_devices(conn): # --- derived stats --- def test_word_stats_streak_and_distribution(conn): - games.save_game_state(conn, 1, "word", "5", "2026-06-12", {"status": "won", "guesses": ["a", "b", "c"]}) - games.save_game_state(conn, 1, "word", "5", "2026-06-11", {"status": "won", "guesses": ["a", "b", "c", "d"]}) - games.save_game_state(conn, 1, "word", "5", "2026-06-10", {"status": "lost", "guesses": ["a"] * 6}) + games.save_game_state(conn, 1, "word", "5", "2026-06-12", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc"]}) + games.save_game_state(conn, 1, "word", "5", "2026-06-11", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc", "ddddd"]}) + games.save_game_state(conn, 1, "word", "5", "2026-06-10", {"status": "lost", "guesses": ["aaaaa"] * 6}) st = games.game_stats(conn, 1, "word", "5") assert st["played"] == 3 and st["won"] == 2 assert st["streak"] == 2 # two most-recent wins, then the loss stops it @@ -71,8 +76,13 @@ def test_word_stats_streak_and_distribution(conn): def test_wordsearch_stats_best_time(conn): - games.save_game_state(conn, 1, "wordsearch", "med", "2026-06-12", {"foundWords": [], "ms": 4000}) - games.save_game_state(conn, 1, "wordsearch", "med", "2026-06-11", {"foundWords": [], "ms": 6000}) + import json + # Store completed states directly — game_stats just reads what's persisted + # (the sanitizer that gates `ms` on real completion is covered separately). + for d, ms in (("2026-06-12", 4000), ("2026-06-11", 6000)): + conn.execute("INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json) " + "VALUES (1,'wordsearch','med',?,?)", (d, json.dumps({"foundWords": [], "ms": ms}))) + conn.commit() st = games.game_stats(conn, 1, "wordsearch", "med") assert st["completed"] == 2 and st["best"] == 4000 @@ -86,16 +96,32 @@ def test_game_state_api_roundtrip(tmp_path, monkeypatch): # signed out → no sync, echoes the posted state and GET sees nothing stored anon = TestClient(app) body = {"game": "wordsearch", "variant": "small", "date": "2026-06-12", - "state": {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 9}} - assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "CAT" + "state": {"foundWords": [_find("BEACH", 0)], "startTime": 9}} + assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "BEACH" assert anon.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] is None # signed in: push from "device A", then "device B" → server returns the union tc = _signin(app, api, "p@x.com") tc.put("/api/games/state", json=body) - bodyB = {**body, "state": {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 4}} + bodyB = {**body, "state": {"foundWords": [_find("OCEAN", 1)], "startTime": 4}} merged = tc.put("/api/games/state", json=bodyB).json()["state"] - assert {f["word"] for f in merged["foundWords"]} == {"CAT", "DOG"} and merged["startTime"] == 4 - # GET returns the stored merge; unknown game → 404 + assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} and merged["startTime"] == 4 + # GET returns the stored merge; unknown game → 404; bad date → 400 got = tc.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] - assert {f["word"] for f in got["foundWords"]} == {"CAT", "DOG"} + assert {f["word"] for f in got["foundWords"]} == {"BEACH", "OCEAN"} assert tc.get("/api/games/state?game=nope&variant=x&date=2026-06-12").status_code == 404 + assert tc.get("/api/games/state?game=wordsearch&variant=small&date=notadate").status_code == 400 + + +def test_sanitizers_reject_junk(conn): + # Word: bad status → playing; wrong-length/non-alpha guesses dropped; cols capped to kept guesses + w = games._sanitize_word("5", {"status": "hacked", "guesses": ["abcde", "no", "12345", "fghij"], + "cols": [["correct", "absent", "bogus", "x", "y"], ["absent"] * 5, ["x"], ["y"]]}) + assert w["status"] == "playing" and w["guesses"] == ["abcde", "fghij"] + assert len(w["cols"]) == 2 and w["cols"][0] == ["correct", "absent"] + # Word Search (no stored puzzle → shape-only): bad shapes dropped, no completion without word count + ws = games._sanitize_wordsearch(conn, "small", "2026-06-12", { + "foundWords": [_find("BEACH", 0), # ok + {"word": "CAT", "cells": [[0, 0], [0, 1], [0, 2]]}, # too short (<4) + {"word": "OCEAN", "cells": [[1, 0], [1, 1]]}], # cells != len + "ms": 12345}) + assert [f["word"] for f in ws["foundWords"]] == ["BEACH"] and ws["ms"] == 0