Compare commits

..
Author SHA1 Message Date
Typosaurus 456689317b ticket #95 attempt 2 2026-07-19 21:15:04 +00:00
Typosaurus 4be01644b5 ticket #95 attempt 1 2026-07-19 20:23:31 +00:00
22 changed files with 76 additions and 177 deletions
File diff suppressed because one or more lines are too long
-13
View File
@@ -1070,7 +1070,6 @@ def init_db():
("planted_at", ""), ("planted_at", ""),
("ready_at", ""), ("ready_at", ""),
("watered_by", "[]"), ("watered_by", "[]"),
("cooldown_until", ""),
("created_at", ""), ("created_at", ""),
("updated_at", ""), ("updated_at", ""),
): ):
@@ -1078,18 +1077,6 @@ def init_db():
game_plots.create_column_by_example(column, example) game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"]) _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, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index( _index(
db, db,
-31
View File
@@ -1,12 +1,10 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import db, get_table
from devplacepy.models import GameSlotForm from devplacepy.models import GameSlotForm
from devplacepy.responses import json_error, respond, wants_json from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameFarmViewOut from devplacepy.schemas import GameFarmViewOut
@@ -20,33 +18,6 @@ from devplacepy.utils import (
from ._shared import game_seo, notify_farm, owner_by_username from ._shared import game_seo, notify_farm, owner_by_username
_MAX_ACTIONS_PER_MINUTE = 30
_RATE_WINDOW_SECONDS = 60
def _check_farm_rate_limit(user_uid: str) -> None:
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.",
)
get_table("rate_limit_log").insert({
"user_uid": user_uid,
"action": "",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
router = APIRouter() router = APIRouter()
@@ -82,7 +53,6 @@ async def water_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()] request: Request, username: str, data: Annotated[GameSlotForm, Form()]
): ):
viewer = require_user(request) viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username) owner = owner_by_username(username)
if not owner: if not owner:
raise HTTPException(status_code=404, detail="Farm not found") raise HTTPException(status_code=404, detail="Farm not found")
@@ -107,7 +77,6 @@ async def steal_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()] request: Request, username: str, data: Annotated[GameSlotForm, Form()]
): ):
viewer = require_user(request) viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username) owner = owner_by_username(username)
if not owner: if not owner:
raise HTTPException(status_code=404, detail="Farm not found") raise HTTPException(status_code=404, detail="Farm not found")
+2 -31
View File
@@ -1,9 +1,8 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import ( from devplacepy.models import (
@@ -13,7 +12,7 @@ from devplacepy.models import (
GameQuestForm, GameQuestForm,
GameSlotForm, GameSlotForm,
) )
from devplacepy.database import db, get_table, mark_notifications_read_by_target from devplacepy.database import mark_notifications_read_by_target
from devplacepy.responses import json_error, respond, wants_json from devplacepy.responses import json_error, respond, wants_json
from devplacepy.schemas import GameLeaderboardOut, GameStateOut from devplacepy.schemas import GameLeaderboardOut, GameStateOut
from devplacepy.services.game import GameError, store from devplacepy.services.game import GameError, store
@@ -21,33 +20,6 @@ from devplacepy.utils import award_rewards, get_current_user, require_user, trac
from ._shared import game_seo, notify_farm, state_payload from ._shared import game_seo, notify_farm, state_payload
_MAX_ACTIONS_PER_MINUTE = 30
_RATE_WINDOW_SECONDS = 60
def _check_rate_limit(user_uid: str) -> None:
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.",
)
get_table("rate_limit_log").insert({
"user_uid": user_uid,
"action": "",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
router = APIRouter() router = APIRouter()
@@ -86,7 +58,6 @@ async def game_leaderboard(request: Request):
async def _respond_action(request: Request, user: dict, fn, on_success=None): async def _respond_action(request: Request, user: dict, fn, on_success=None):
_check_rate_limit(user["uid"])
try: try:
result = fn() result = fn()
except GameError as exc: except GameError as exc:
@@ -60,18 +60,6 @@ CONFIRM_REQUIRED = {
"gateway_model_delete", "gateway_model_delete",
"email_account_delete", "email_account_delete",
"email_delete_message", "email_delete_message",
"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 = { CONDITIONAL_CONFIRM = {
@@ -39,9 +39,6 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
plot = _plot_at(farm["uid"], slot) plot = _plot_at(farm["uid"], slot)
if not plot: if not plot:
raise GameError("That plot does not exist.") raise GameError("That plot does not exist.")
cooldown = plot.get("cooldown_until", "")
if cooldown and _now() < _parse_date(cooldown):
raise GameError("This plot is on cooldown. Wait a moment before planting.")
if plot.get("crop_key"): if plot.get("crop_key"):
raise GameError("That plot is already in use.") raise GameError("That plot is already in use.")
coins = int(farm.get("coins", 0)) coins = int(farm.get("coins", 0))
@@ -88,7 +85,6 @@ def harvest(user: dict, slot: int) -> dict:
"planted_at": "", "planted_at": "",
"ready_at": "", "ready_at": "",
"watered_by": "[]", "watered_by": "[]",
"cooldown_until": _iso(now + timedelta(seconds=5)),
"updated_at": _iso(now), "updated_at": _iso(now),
}, },
["uid"], ["uid"],
+18
View File
@@ -513,6 +513,24 @@ img {
display: inline-block; display: inline-block;
} }
.post-action-btn.is-loading,
.comment-vote-btn.is-loading,
.comment-action-btn.is-loading,
.comment-form-submit.is-loading,
.vote-star.is-loading,
.poll-option.is-loading {
pointer-events: none;
}
.post-action-btn.is-loading .btn-spinner,
.comment-vote-btn.is-loading .btn-spinner,
.comment-action-btn.is-loading .btn-spinner,
.comment-form-submit.is-loading .btn-spinner,
.vote-star.is-loading .btn-spinner,
.poll-option.is-loading .btn-spinner {
display: inline-block;
}
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+1 -1
View File
@@ -22,6 +22,6 @@ export class BookmarkManager extends OptimisticAction {
if (label) { if (label) {
label.textContent = result.saved ? "Saved" : "Save"; label.textContent = result.saved ? "Saved" : "Save";
} }
}); }, button);
} }
} }
+20 -5
View File
@@ -23,7 +23,10 @@ export class CommentManager {
const wrapper = form.closest(".comment"); const wrapper = form.closest(".comment");
if (!wrapper) return; if (!wrapper) return;
const button = form.querySelector("button[type='submit']"); const button = form.querySelector("button[type='submit']");
if (button) button.disabled = true; if (button) {
button.disabled = true;
button.classList.add("is-loading");
}
const anchor = wrapper.previousElementSibling const anchor = wrapper.previousElementSibling
&& wrapper.previousElementSibling.classList.contains("comment") && wrapper.previousElementSibling.classList.contains("comment")
@@ -38,7 +41,10 @@ export class CommentManager {
wrapper.remove(); wrapper.remove();
window.scrollTo({ top: Math.max(anchorTop, 0) }); window.scrollTo({ top: Math.max(anchorTop, 0) });
} catch (err) { } catch (err) {
if (button) button.disabled = false; if (button) {
button.disabled = false;
button.classList.remove("is-loading");
}
} }
} }
@@ -118,7 +124,10 @@ export class CommentManager {
async submitEdit(form, text) { async submitEdit(form, text) {
const save = form.querySelector("button[type='submit']"); const save = form.querySelector("button[type='submit']");
if (save) save.disabled = true; if (save) {
save.disabled = true;
save.classList.add("is-loading");
}
const content = form.querySelector("textarea").value; const content = form.querySelector("textarea").value;
try { try {
const result = await Http.send(form.action, { content }); const result = await Http.send(form.action, { content });
@@ -132,7 +141,10 @@ export class CommentManager {
form.remove(); form.remove();
text.style.display = ""; text.style.display = "";
} catch (err) { } catch (err) {
if (save) save.disabled = false; if (save) {
save.disabled = false;
save.classList.remove("is-loading");
}
} }
} }
@@ -205,7 +217,10 @@ export class CommentManager {
} }
form.addEventListener("submit", () => { form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']"); const btn = form.querySelector("button[type='submit']");
if (btn) btn.disabled = true; if (btn) {
btn.disabled = true;
btn.classList.add("is-loading");
}
}); });
} }
} }
+20 -2
View File
@@ -4,7 +4,11 @@ import { Http } from "./Http.js";
import { Toast } from "./Toast.js"; import { Toast } from "./Toast.js";
export class OptimisticAction { export class OptimisticAction {
async submit(url, params, errorTarget, render) { async submit(url, params, errorTarget, render, button = null) {
if (button) {
button.disabled = true;
button.classList.add("is-loading");
}
try { try {
const result = await Http.sendForm(url, params, { silent: true }); const result = await Http.sendForm(url, params, { silent: true });
if (render) render(result); if (render) render(result);
@@ -13,10 +17,19 @@ export class OptimisticAction {
console.error("optimistic action failed", error); console.error("optimistic action failed", error);
if (errorTarget) Toast.flash(errorTarget, "Error", 1500); if (errorTarget) Toast.flash(errorTarget, "Error", 1500);
return null; return null;
} finally {
if (button) {
button.disabled = false;
button.classList.remove("is-loading");
}
} }
} }
async submitOptimistic(url, params, errorTarget, apply, revert, reconcile) { async submitOptimistic(url, params, errorTarget, apply, revert, reconcile, button = null) {
if (button) {
button.disabled = true;
button.classList.add("is-loading");
}
apply(); apply();
try { try {
const result = await Http.sendForm(url, params, { silent: true }); const result = await Http.sendForm(url, params, { silent: true });
@@ -27,6 +40,11 @@ export class OptimisticAction {
revert(); revert();
if (errorTarget) Toast.flash(errorTarget, "Error", 1500); if (errorTarget) Toast.flash(errorTarget, "Error", 1500);
return null; return null;
} finally {
if (button) {
button.disabled = false;
button.classList.remove("is-loading");
}
} }
} }
} }
+1 -1
View File
@@ -42,7 +42,7 @@ export class PollManager extends OptimisticAction {
} }
const pollUid = poll.dataset.pollUid; const pollUid = poll.dataset.pollUid;
const optionUid = option.dataset.optionUid; const optionUid = option.dataset.optionUid;
await this.submit(`/polls/${pollUid}/vote`, { option_uid: optionUid }, null, (result) => this.render(poll, result)); await this.submit(`/polls/${pollUid}/vote`, { option_uid: optionUid }, null, (result) => this.render(poll, result), option);
} }
render(poll, result) { render(poll, result) {
+1 -1
View File
@@ -75,7 +75,7 @@ export class ReactionBar extends OptimisticAction {
const type = bar.dataset.reactionType; const type = bar.dataset.reactionType;
const uid = bar.dataset.reactionUid; const uid = bar.dataset.reactionUid;
const emoji = trigger.dataset.reactionEmoji; const emoji = trigger.dataset.reactionEmoji;
await this.submit(`/reactions/${type}/${uid}`, { emoji }, null, (result) => this.render(bar, result)); await this.submit(`/reactions/${type}/${uid}`, { emoji }, null, (result) => this.render(bar, result), trigger);
this.closeAllPalettes(); this.closeAllPalettes();
} }
+1
View File
@@ -34,6 +34,7 @@ export class VoteManager extends OptimisticAction {
() => this.render(action, { net: predictedNet, value: newValue }), () => this.render(action, { net: predictedNet, value: newValue }),
() => this.render(action, { net: prevNet, value: prevValue }), () => this.render(action, { net: prevNet, value: prevValue }),
(result) => this.render(action, result), (result) => this.render(action, result),
button,
); );
} }
+1 -1
View File
@@ -1,3 +1,3 @@
<button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="Save" aria-label="Save" aria-pressed="{% if _bookmarked %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}> <button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="Save" aria-label="Save" aria-pressed="{% if _bookmarked %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>
<span class="bookmark-icon">&#x1F516;</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span> <span class="btn-spinner" aria-hidden="true"></span><span class="bookmark-icon">&#x1F516;</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
</button> </button>
+5 -5
View File
@@ -3,12 +3,12 @@
<div class="comment-votes" role="group" aria-label="Comment votes"> <div class="comment-votes" role="group" aria-label="Comment votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span>+</button>
</form> </form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span> <span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button> <button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span>-</button>
</form> </form>
</div> </div>
@@ -25,11 +25,11 @@
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
<div class="comment-actions"> <div class="comment-actions">
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span><span class="label"> Reply</span></button> <button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span><span class="icon">💬</span><span class="label"> Reply</span></button>
{% if owns(item.comment, user) %} {% if owns(item.comment, user) %}
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span><span class="label"> Edit</span></button> <button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="btn-spinner" aria-hidden="true"></span><span class="icon">✏️</span><span class="label"> Edit</span></button>
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}"> <form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span><span class="label"> Delete</span></button> <button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="btn-spinner" aria-hidden="true"></span><span class="icon">🗑️</span><span class="label"> Delete</span></button>
</form> </form>
{% endif %} {% endif %}
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %} {% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
+1 -1
View File
@@ -9,7 +9,7 @@
max-size="{{ max_upload_size_mb() }}" max-size="{{ max_upload_size_mb() }}"
max-files="{{ max_attachments_per_resource() }}" max-files="{{ max_attachments_per_resource() }}"
allowed-types="{{ allowed_file_types() }}"></dp-upload> allowed-types="{{ allowed_file_types() }}"></dp-upload>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span><span class="label"> Post</span></button> <button type="submit" class="comment-form-submit"><span class="btn-spinner" aria-hidden="true"></span><span class="icon">&#x1F4E4;</span><span class="label"> Post</span></button>
</div> </div>
</form> </form>
{% else %} {% else %}
+1
View File
@@ -4,6 +4,7 @@
<div class="poll-options" role="group" aria-label="Poll options"> <div class="poll-options" role="group" aria-label="Poll options">
{% for opt in _poll.options %} {% for opt in _poll.options %}
<button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}> <button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>
<span class="btn-spinner" aria-hidden="true"></span>
<span class="poll-option-bar" style="--poll-pct: {{ opt.pct }}%;"></span> <span class="poll-option-bar" style="--poll-pct: {{ opt.pct }}%;"></span>
<span class="poll-option-label">{{ render_title(opt.label) }}</span> <span class="poll-option-label">{{ render_title(opt.label) }}</span>
<span class="poll-option-meta"> <span class="poll-option-meta">
+2 -2
View File
@@ -1,11 +1,11 @@
<div class="post-votes" role="group" aria-label="Post votes"> <div class="post-votes" role="group" aria-label="Post votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form"> <form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>−</button> <button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span>−</button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span> <span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form"> <form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span>+</button>
</form> </form>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}> <form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button> <button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="btn-spinner" aria-hidden="true"></span><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
</form> </form>
-20
View File
@@ -177,23 +177,3 @@ def test_prestige_below_level_returns_400(app_server, seeded_db):
_reset_farm(name) _reset_farm(name)
response = session.post(f"{BASE_URL}/game/prestige", headers=JSON) response = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
assert response.status_code == 400 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)}"
-25
View File
@@ -212,28 +212,3 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db):
f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON
) )
assert second.status_code == 400 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
@@ -119,23 +119,3 @@ def test_game_actions_registered_with_correct_auth():
def test_game_read_actions_are_read_only(): def test_game_read_actions_are_read_only():
for name in ("game_state", "game_leaderboard", "game_view_farm"): for name in ("game_state", "game_leaderboard", "game_view_farm"):
assert BY_NAME[name].is_read_only is True 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"