From 85cf634e605bbcd3d9dd9d23858c6c8222b1aeb1 Mon Sep 17 00:00:00 2001 From: Typosaurus Date: Sun, 19 Jul 2026 21:21:46 +0000 Subject: [PATCH] ticket #88 attempt 2 --- devplacepy/database/schema.py | 12 ++++++++ devplacepy/routers/game/farm.py | 27 ++++++++++++------ devplacepy/routers/game/index.py | 28 ++++++++++++------- .../services/devii/actions/dispatcher.py | 13 +++++++-- tests/api/game/index.py | 20 +++++++++++++ tests/api/game/mutations.py | 25 +++++++++++++++++ tests/unit/services/devii/actions/catalog.py | 20 +++++++++++++ 7 files changed, 124 insertions(+), 21 deletions(-) diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 5fe496b0..740da1db 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -1078,6 +1078,18 @@ def init_db(): game_plots.create_column_by_example(column, example) _index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"]) + _drop_index(db, "idx_rate_limit_log_user_ts") + rate_limit_log = get_table("rate_limit_log") + for column, example in ( + ("uid", ""), + ("user_uid", ""), + ("action", ""), + ("timestamp", ""), + ): + if not rate_limit_log.has_column(column): + rate_limit_log.create_column_by_example(column, example) + _index(db, "rate_limit_log", "idx_rate_limit_log_user_ts", ["user_uid", "timestamp"]) + _index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"]) _index( db, diff --git a/devplacepy/routers/game/farm.py b/devplacepy/routers/game/farm.py index eb5718ed..c7da3bf6 100644 --- a/devplacepy/routers/game/farm.py +++ b/devplacepy/routers/game/farm.py @@ -1,11 +1,12 @@ # retoor -import time +from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from devplacepy.database import db, get_table from devplacepy.models import GameSlotForm from devplacepy.responses import json_error, respond, wants_json from devplacepy.schemas import GameFarmViewOut @@ -19,23 +20,31 @@ from devplacepy.utils import ( from ._shared import game_seo, notify_farm, owner_by_username -_farm_action_rate: dict[str, list[float]] = {} - _MAX_ACTIONS_PER_MINUTE = 30 _RATE_WINDOW_SECONDS = 60 def _check_farm_rate_limit(user_uid: str) -> None: - now = time.time() - window_start = now - _RATE_WINDOW_SECONDS - times = _farm_action_rate.setdefault(user_uid, []) - times[:] = [t for t in times if t > window_start] - if len(times) >= _MAX_ACTIONS_PER_MINUTE: + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat() + result = list( + db.query( + "SELECT COUNT(*) AS c FROM rate_limit_log" + " WHERE user_uid = :uid AND timestamp >= :cutoff", + uid=user_uid, + cutoff=cutoff, + ) + ) + count = result[0]["c"] if result else 0 + if count >= _MAX_ACTIONS_PER_MINUTE: raise HTTPException( status_code=429, detail="Rate limit exceeded. Max 30 actions per minute.", ) - times.append(now) + get_table("rate_limit_log").insert({ + "user_uid": user_uid, + "action": "", + "timestamp": datetime.now(timezone.utc).isoformat(), + }) router = APIRouter() diff --git a/devplacepy/routers/game/index.py b/devplacepy/routers/game/index.py index fc2cb623..a9263831 100644 --- a/devplacepy/routers/game/index.py +++ b/devplacepy/routers/game/index.py @@ -1,6 +1,6 @@ # retoor -import time +from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Form, HTTPException, Request @@ -13,7 +13,7 @@ from devplacepy.models import ( GameQuestForm, GameSlotForm, ) -from devplacepy.database import mark_notifications_read_by_target +from devplacepy.database import db, get_table, mark_notifications_read_by_target from devplacepy.responses import json_error, respond, wants_json from devplacepy.schemas import GameLeaderboardOut, GameStateOut from devplacepy.services.game import GameError, store @@ -21,23 +21,31 @@ from devplacepy.utils import award_rewards, get_current_user, require_user, trac from ._shared import game_seo, notify_farm, state_payload -_action_rate: dict[str, list[float]] = {} - _MAX_ACTIONS_PER_MINUTE = 30 _RATE_WINDOW_SECONDS = 60 def _check_rate_limit(user_uid: str) -> None: - now = time.time() - window_start = now - _RATE_WINDOW_SECONDS - times = _action_rate.setdefault(user_uid, []) - times[:] = [t for t in times if t > window_start] - if len(times) >= _MAX_ACTIONS_PER_MINUTE: + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_RATE_WINDOW_SECONDS)).isoformat() + result = list( + db.query( + "SELECT COUNT(*) AS c FROM rate_limit_log" + " WHERE user_uid = :uid AND timestamp >= :cutoff", + uid=user_uid, + cutoff=cutoff, + ) + ) + count = result[0]["c"] if result else 0 + if count >= _MAX_ACTIONS_PER_MINUTE: raise HTTPException( status_code=429, detail="Rate limit exceeded. Max 30 actions per minute.", ) - times.append(now) + get_table("rate_limit_log").insert({ + "user_uid": user_uid, + "action": "", + "timestamp": datetime.now(timezone.utc).isoformat(), + }) router = APIRouter() diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index 02c1c5cc..1b53ff7c 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -60,9 +60,18 @@ CONFIRM_REQUIRED = { "gateway_model_delete", "email_account_delete", "email_delete_message", - "game_plant", - "game_harvest", + "game_buy_plot", + "game_claim_quest", + "game_daily", "game_fertilize", + "game_harvest", + "game_plant", + "game_prestige", + "game_steal", + "game_upgrade_ci", + "game_upgrade_legacy", + "game_upgrade_perk", + "game_water", } CONDITIONAL_CONFIRM = { diff --git a/tests/api/game/index.py b/tests/api/game/index.py index 050403e9..27b81497 100644 --- a/tests/api/game/index.py +++ b/tests/api/game/index.py @@ -177,3 +177,23 @@ def test_prestige_below_level_returns_400(app_server, seeded_db): _reset_farm(name) response = session.post(f"{BASE_URL}/game/prestige", headers=JSON) assert response.status_code == 400 + + +def test_rate_limit_blocks_excess_requests(app_server, seeded_db): + session, name = _signup() + _reset_farm(name) + responses = [] + for _ in range(31): + responses.append( + session.post(f"{BASE_URL}/game/buy-plot", headers=JSON) + ) + success_count = sum(1 for r in responses[:30] if r.status_code != 429) + assert success_count >= 1, "expected at least one successful request" + assert responses[-1].status_code == 429, f"expected 429 on 31st request, got {responses[-1].status_code}" + assert "Rate limit exceeded" in responses[-1].json().get("detail", "") + # Verify the rate limit log stored entries for the test user + from devplacepy.database import get_table + user = get_table("users").find_one(username=name) + table = get_table("rate_limit_log") + entries = list(table.find(user_uid=user["uid"])) + assert len(entries) >= 2, f"expected rate log entries, got {len(entries)}" diff --git a/tests/api/game/mutations.py b/tests/api/game/mutations.py index 820ccf05..76584f65 100644 --- a/tests/api/game/mutations.py +++ b/tests/api/game/mutations.py @@ -212,3 +212,28 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db): f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON ) assert second.status_code == 400 + + +def test_harvest_plant_cooldown_blocks_rapid_replant(app_server, seeded_db): + session, name = _signup() + _reset_farm(name) + # Plant and ripen a crop on slot 0 + plant = session.post( + f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON + ) + assert plant.status_code == 200 + _ripen_owner_plot(name) + # Harvest it — this sets a 5-second cooldown on the plot + harvest = session.post(f"{BASE_URL}/game/harvest", data={"slot": 0}, headers=JSON) + assert harvest.status_code == 200 + # Immediately replant on the same slot — must fail + replant = session.post( + f"{BASE_URL}/game/plant", data={"slot": 0, "crop": "shell"}, headers=JSON + ) + assert replant.status_code == 400 + assert "cooldown" in replant.text.lower() + # A different slot should still work + other = session.post( + f"{BASE_URL}/game/plant", data={"slot": 1, "crop": "shell"}, headers=JSON + ) + assert other.status_code == 200 diff --git a/tests/unit/services/devii/actions/catalog.py b/tests/unit/services/devii/actions/catalog.py index 47282bfd..41e2ceb5 100644 --- a/tests/unit/services/devii/actions/catalog.py +++ b/tests/unit/services/devii/actions/catalog.py @@ -119,3 +119,23 @@ def test_game_actions_registered_with_correct_auth(): def test_game_read_actions_are_read_only(): for name in ("game_state", "game_leaderboard", "game_view_farm"): assert BY_NAME[name].is_read_only is True + + +def test_all_game_post_actions_require_confirm(): + mutating_game_actions = { + "game_plant", + "game_harvest", + "game_buy_plot", + "game_upgrade_ci", + "game_water", + "game_steal", + "game_fertilize", + "game_daily", + "game_upgrade_perk", + "game_claim_quest", + "game_prestige", + "game_upgrade_legacy", + } + for name in mutating_game_actions: + assert name in BY_NAME, f"{name} missing from catalog" + assert name in CONFIRM_REQUIRED, f"{name} missing from CONFIRM_REQUIRED"