Compare commits

..
Author SHA1 Message Date
Typosaurus 85cf634e60 ticket #88 attempt 2 2026-07-19 21:21:46 +00:00
Typosaurus aa178a8c62 ticket #88 attempt 1 2026-07-19 21:05:14 +00:00
16 changed files with 163 additions and 136 deletions
File diff suppressed because one or more lines are too long
+1 -13
View File
@@ -1,6 +1,5 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
@@ -56,12 +55,6 @@ CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "stat
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
NOTE_PATTERN = re.compile(r"(^|\s)@note(\s|$)")
def has_note(text: str) -> bool:
return bool(text and NOTE_PATTERN.search(text))
logger = logging.getLogger(__name__)
@@ -205,8 +198,6 @@ def create_content_item(
schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request)
schedule_seo_meta_for_table(table_name, uid)
if request and has_note(mention_text):
set_bookmark(request, user, target_type, uid, saved=True, source="note")
return uid, slug
@@ -388,8 +379,6 @@ def create_comment_record(
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
links=comment_links,
)
if request and target_type in BOOKMARKABLE_TYPES and has_note(content):
set_bookmark(request, user, target_type, target_uid, saved=True, source="note")
return comment_uid, comment_url
@@ -446,7 +435,7 @@ def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]
def set_bookmark(
request, user: dict, target_type: str, target_uid: str, saved: bool, source: str = "manual"
request, user: dict, target_type: str, target_uid: str, saved: bool
) -> bool:
bookmarks = get_table("bookmarks")
existing = bookmarks.find_one(
@@ -469,7 +458,6 @@ def set_bookmark(
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": source,
}
)
changed = True
+13 -6
View File
@@ -243,12 +243,6 @@ def init_db():
)
_index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"])
_index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"])
if "bookmarks" in tables:
bookmarks_table = get_table("bookmarks")
if not bookmarks_table.has_column("source"):
bookmarks_table.create_column_by_example("source", "manual")
_index(db, "bookmarks", "idx_bookmarks_source", ["source"])
_index(db, "bookmarks", "idx_bookmarks_user_source", ["user_uid", "source"])
_index(db, "polls", "idx_polls_post", ["post_uid"])
_index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"])
@@ -1076,6 +1070,7 @@ def init_db():
("planted_at", ""),
("ready_at", ""),
("watered_by", "[]"),
("cooldown_until", ""),
("created_at", ""),
("updated_at", ""),
):
@@ -1083,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,
@@ -152,28 +152,6 @@ four ways to sign requests.
"Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
],
),
endpoint(
id="notes-listing",
method="GET",
path="/bookmarks/saved?source=note",
title="View personal notes",
summary="Render your personal notes (items tagged with @note). Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (created_at of the last item).",
)
],
notes=[
"Notes are auto-created when you include @note in a post or comment body."
],
),
endpoint(
id="polls-vote",
method="POST",
+2 -6
View File
@@ -31,13 +31,10 @@ LABEL_BY_TYPE: dict[str, str] = {
@router.get("/saved", response_class=HTMLResponse)
async def saved_page(request: Request, before: str = None, source: str = None):
async def saved_page(request: Request, before: str = None):
user = require_user(request)
bookmarks = get_table("bookmarks")
filters: dict = {"user_uid": user["uid"]}
if source:
filters["source"] = source
rows, next_cursor = paginate(bookmarks, before=before, **filters)
rows, next_cursor = paginate(bookmarks, before=before, user_uid=user["uid"])
uids_by_type: dict[str, list] = {}
for row in rows:
@@ -88,7 +85,6 @@ async def saved_page(request: Request, before: str = None, source: str = None):
"user": user,
"items": items,
"next_cursor": next_cursor,
"current_source": source or "",
},
model=SavedOut,
)
+1 -1
View File
@@ -211,7 +211,7 @@ async def _set_favorite(request: Request, rant_id: str, saved: bool):
post = post_by_id(rant_id)
if not post:
return dr_error("This rant does not exist.")
set_bookmark(request, user, "post", post["uid"], saved, source="manual")
set_bookmark(request, user, "post", post["uid"], saved)
return dr_ok()
+31
View File
@@ -1,10 +1,12 @@
# retoor <retoor@molodetz.nl>
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
@@ -18,6 +20,33 @@ from devplacepy.utils import (
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()
@@ -53,6 +82,7 @@ async def water_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
@@ -77,6 +107,7 @@ async def steal_farm(
request: Request, username: str, data: Annotated[GameSlotForm, Form()]
):
viewer = require_user(request)
_check_farm_rate_limit(viewer["uid"])
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
+31 -2
View File
@@ -1,8 +1,9 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import (
@@ -12,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
@@ -20,6 +21,33 @@ from devplacepy.utils import award_rewards, get_current_user, require_user, trac
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()
@@ -58,6 +86,7 @@ async def game_leaderboard(request: Request):
async def _respond_action(request: Request, user: dict, fn, on_success=None):
_check_rate_limit(user["uid"])
try:
result = fn()
except GameError as exc:
@@ -65,13 +65,6 @@ ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
),
),
),
Action(
name="list_notes",
method="GET",
path="/bookmarks/saved?source=note",
summary="List personal notes (items tagged with @note)",
params=(query("before", "Pagination cursor."),),
),
Action(
name="vote_poll",
method="POST",
@@ -60,6 +60,18 @@ CONFIRM_REQUIRED = {
"gateway_model_delete",
"email_account_delete",
"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 = {
@@ -39,6 +39,9 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
plot = _plot_at(farm["uid"], slot)
if not plot:
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"):
raise GameError("That plot is already in use.")
coins = int(farm.get("coins", 0))
@@ -85,6 +88,7 @@ def harvest(user: dict, slot: int) -> dict:
"planted_at": "",
"ready_at": "",
"watered_by": "[]",
"cooldown_until": _iso(now + timedelta(seconds=5)),
"updated_at": _iso(now),
},
["uid"],
+2 -32
View File
@@ -2,40 +2,10 @@
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<style>
.tab-bar {
display: flex;
gap: 0;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color, #ddd);
}
.tab-btn {
padding: 0.5rem 1.25rem;
text-decoration: none;
color: var(--text-muted, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
font-weight: 500;
}
.tab-btn:hover {
color: var(--text-color, #333);
}
.tab-btn.active {
color: var(--accent-color, #0066cc);
border-bottom-color: var(--accent-color, #0066cc);
}
</style>
{% endblock %}
{% block content %}
<div class="saved-page">
<h1 class="saved-title">Saved</h1>
<div class="tab-bar">
<a href="/bookmarks/saved" class="tab-btn {% if not current_source %}active{% endif %}">All</a>
<a href="/bookmarks/saved?source=note" class="tab-btn {% if current_source == 'note' %}active{% endif %}">Notes</a>
</div>
<div class="saved-list">
{% for item in items %}
<a href="{{ item.url }}" class="saved-item">
@@ -44,13 +14,13 @@
<span class="saved-item-time">{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}</span>
</a>
{% else %}
<div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news, or add <code>@note</code> to your post or comment to auto-save it.</div>
<div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news.</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/bookmarks/saved?before={{ next_cursor }}{% if current_source %}&source={{ current_source }}{% endif %}" class="btn btn-secondary">Load more</a>
<a href="/bookmarks/saved?before={{ next_cursor }}" class="btn btn-secondary">Load more</a>
</div>
{% endif %}
</div>
-47
View File
@@ -76,50 +76,3 @@ def test_saved_page_lists_post_and_gist(app_server):
html = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html
assert gist_title in html
def test_saved_page_source_filter_notes(app_server):
s, name = _session_bookmarks()
owner_uid = _uid_bookmarks(name)
post_title = f"Note post {int(time.time() * 1000)}"
manual_title = f"Manual bookmark {int(time.time() * 1000)}"
post_uid = _make_post_bookmarks(owner_uid, post_title)
manual_uid = _make_post_bookmarks(owner_uid, manual_title)
# Create a note bookmark (source=note) directly via DB
bookmarks = get_table("bookmarks")
note_uid = generate_uid()
bookmarks.insert(
{
"uid": note_uid,
"user_uid": owner_uid,
"target_uid": post_uid,
"target_type": "post",
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": "note",
}
)
# Create a manual bookmark (source=manual)
bookmarks.insert(
{
"uid": generate_uid(),
"user_uid": owner_uid,
"target_uid": manual_uid,
"target_type": "post",
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": "manual",
}
)
html = s.get(f"{BASE_URL}/bookmarks/saved?source=note").text
assert post_title in html
assert manual_title not in html
html_all = s.get(f"{BASE_URL}/bookmarks/saved").text
assert post_title in html_all
assert manual_title in html_all
+20
View File
@@ -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)}"
+25
View File
@@ -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
@@ -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"