Compare commits

..
Author SHA1 Message Date
Typosaurus 3397dfbb64 ticket #97 attempt 1 2026-07-19 20:22:17 +00:00
12 changed files with 138 additions and 79 deletions
+13 -1
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
@@ -55,6 +56,12 @@ 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__)
@@ -198,6 +205,8 @@ 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
@@ -379,6 +388,8 @@ 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
@@ -435,7 +446,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
request, user: dict, target_type: str, target_uid: str, saved: bool, source: str = "manual"
) -> bool:
bookmarks = get_table("bookmarks")
existing = bookmarks.find_one(
@@ -458,6 +469,7 @@ def set_bookmark(
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
"source": source,
}
)
changed = True
+6
View File
@@ -243,6 +243,12 @@ 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"])
@@ -152,6 +152,28 @@ 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",
+1 -18
View File
@@ -4,13 +4,12 @@ import asyncio
import fcntl
import logging
import os
import re
import time
from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware
@@ -612,22 +611,6 @@ async def response_timing(request: Request, call_next):
return response
_KNOWN_CRAWLERS = re.compile(
r"(google.*read.*aloud|googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot)",
re.IGNORECASE,
)
_AUTH_GATED_PATHS = frozenset({"/messages", "/notifications", "/game", "/admin"})
@app.middleware("http")
async def crawler_detection(request: Request, call_next):
if request.method == "GET" and request.url.path in _AUTH_GATED_PATHS:
ua = request.headers.get("user-agent", "")
if _KNOWN_CRAWLERS.search(ua):
return Response(status_code=204)
return await call_next(request)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
+6 -2
View File
@@ -31,10 +31,13 @@ LABEL_BY_TYPE: dict[str, str] = {
@router.get("/saved", response_class=HTMLResponse)
async def saved_page(request: Request, before: str = None):
async def saved_page(request: Request, before: str = None, source: str = None):
user = require_user(request)
bookmarks = get_table("bookmarks")
rows, next_cursor = paginate(bookmarks, before=before, user_uid=user["uid"])
filters: dict = {"user_uid": user["uid"]}
if source:
filters["source"] = source
rows, next_cursor = paginate(bookmarks, before=before, **filters)
uids_by_type: dict[str, list] = {}
for row in rows:
@@ -85,6 +88,7 @@ async def saved_page(request: Request, before: 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)
set_bookmark(request, user, "post", post["uid"], saved, source="manual")
return dr_ok()
-1
View File
@@ -17,7 +17,6 @@ async def robots_txt(request: Request):
Disallow: /auth/
Disallow: /messages/
Disallow: /notifications/
Disallow: /game/
Disallow: /votes/
Disallow: /avatar/
Disallow: /follow/
@@ -65,6 +65,13 @@ 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",
+32 -2
View File
@@ -2,10 +2,40 @@
{% 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">
@@ -14,13 +44,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.</div>
<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>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/bookmarks/saved?before={{ next_cursor }}" class="btn btn-secondary">Load more</a>
<a href="/bookmarks/saved?before={{ next_cursor }}{% if current_source %}&source={{ current_source }}{% endif %}" class="btn btn-secondary">Load more</a>
</div>
{% endif %}
</div>
+47
View File
@@ -76,3 +76,50 @@ 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
+3 -4
View File
@@ -196,8 +196,7 @@ def test_robots_txt_exists(app_server):
assert "Sitemap:" in r.text
def test_robots_disallows_auth_gated_paths(app_server):
def test_robots_disallows_admin_and_uploads(app_server):
r = requests.get(f"{BASE_URL}/robots.txt")
required = ["/auth/", "/game/", "/messages/", "/notifications/", "/admin/", "/uploads/"]
for path in required:
assert f"Disallow: {path}" in r.text, f"Missing Disallow: {path}"
assert "Disallow: /admin/" in r.text
assert "Disallow: /uploads/" in r.text
-50
View File
@@ -1,50 +0,0 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
def test_bot_blocked_on_auth_path(app_server):
headers = {"User-Agent": "Google-Read-Aloud"}
r = requests.get(f"{BASE_URL}/messages", headers=headers)
assert r.status_code == 204
assert r.content == b""
def test_bot_blocked_on_game_path(app_server):
headers = {"User-Agent": "Google-Read-Aloud"}
r = requests.get(f"{BASE_URL}/game", headers=headers)
assert r.status_code == 204
assert r.content == b""
def test_bot_blocked_on_admin_path(app_server):
headers = {"User-Agent": "Google-Read-Aloud"}
r = requests.get(f"{BASE_URL}/admin", headers=headers)
assert r.status_code == 204
assert r.content == b""
def test_bot_not_blocked_on_public_path(app_server):
headers = {"User-Agent": "Google-Read-Aloud"}
r = requests.get(f"{BASE_URL}/", headers=headers)
assert r.status_code == 200
def test_normal_user_not_blocked(app_server):
r = requests.get(f"{BASE_URL}/messages", allow_redirects=False)
assert r.status_code == 303
def test_known_crawler_user_agent_variants(app_server):
ua_list = [
"Googlebot",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"bingbot/2.0; +http://www.bing.com/bingbot.htm",
"Mozilla/5.0 (compatible; DuckDuckBot-Https/1.1; ...)",
"Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)",
]
for ua in ua_list:
headers = {"User-Agent": ua}
r = requests.get(f"{BASE_URL}/admin", headers=headers)
assert r.status_code == 204, f"Expected 204 for UA: {ua}"