Compare commits

..
Author SHA1 Message Date
Typosaurus 3fed6463be ticket #75 attempt 1 2026-07-19 20:08:35 +00:00
10 changed files with 81 additions and 12 deletions
+18 -1
View File
@@ -4,12 +4,13 @@ import asyncio
import fcntl import fcntl
import logging import logging
import os import os
import re
import time import time
from collections import defaultdict from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager from contextlib import asynccontextmanager, contextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
@@ -611,6 +612,22 @@ async def response_timing(request: Request, call_next):
return response 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) app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
+2 -2
View File
@@ -258,13 +258,13 @@ class NotificationDefaultForm(BaseModel):
class AiCorrectionForm(BaseModel): class AiCorrectionForm(BaseModel):
enabled: bool = False enabled: bool = False
sync: bool = False sync: bool = False
prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT) prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=20000)
class AiModifierForm(BaseModel): class AiModifierForm(BaseModel):
enabled: bool = False enabled: bool = False
sync: bool = False sync: bool = False
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT) prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=20000)
class InteractionsForm(BaseModel): class InteractionsForm(BaseModel):
+1
View File
@@ -17,6 +17,7 @@ async def robots_txt(request: Request):
Disallow: /auth/ Disallow: /auth/
Disallow: /messages/ Disallow: /messages/
Disallow: /notifications/ Disallow: /notifications/
Disallow: /game/
Disallow: /votes/ Disallow: /votes/
Disallow: /avatar/ Disallow: /avatar/
Disallow: /follow/ Disallow: /follow/
@@ -61,7 +61,7 @@ AI_CORRECTION_ACTIONS: tuple[Action, ...] = (
), ),
arg( arg(
"prompt", "prompt",
"The correction instruction. Omit to keep the current one.", "The correction instruction (max 20000 chars). Omit to keep the current one.",
), ),
), ),
), ),
@@ -64,7 +64,7 @@ AI_MODIFIER_ACTIONS: tuple[Action, ...] = (
), ),
arg( arg(
"prompt", "prompt",
"The modifier instruction. Omit to keep the current one.", "The modifier instruction (max 20000 chars). Omit to keep the current one.",
), ),
), ),
), ),
@@ -70,7 +70,7 @@ class AiCorrectionController:
if prompt_raw is None: if prompt_raw is None:
prompt = user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT prompt = user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT
else: else:
prompt = str(prompt_raw).strip() or DEFAULT_CORRECTION_PROMPT prompt = str(prompt_raw).strip()[:20000] or DEFAULT_CORRECTION_PROMPT
get_table("users").update( get_table("users").update(
{ {
"uid": self._owner_id, "uid": self._owner_id,
@@ -70,7 +70,7 @@ class AiModifierController:
if prompt_raw is None: if prompt_raw is None:
prompt = user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT prompt = user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT
else: else:
prompt = str(prompt_raw).strip() or DEFAULT_MODIFIER_PROMPT prompt = str(prompt_raw).strip()[:20000] or DEFAULT_MODIFIER_PROMPT
get_table("users").update( get_table("users").update(
{ {
"uid": self._owner_id, "uid": self._owner_id,
+2 -2
View File
@@ -226,7 +226,7 @@
<option value="sync" {% if ai_correction_sync %}selected{% endif %}>Synchronously (wait while saving)</option> <option value="sync" {% if ai_correction_sync %}selected{% endif %}>Synchronously (wait while saving)</option>
</select> </select>
</div> </div>
<textarea data-ai-correction-prompt class="input-sm ai-correction-prompt" rows="3" aria-label="AI content correction prompt">{{ ai_correction_prompt }}</textarea> <textarea data-ai-correction-prompt class="input-sm ai-correction-prompt" maxlength="20000" rows="3" aria-label="AI content correction prompt">{{ ai_correction_prompt }}</textarea>
<div class="customization-row"> <div class="customization-row">
<button type="button" class="btn btn-sm btn-primary" data-ai-correction-save>Save</button> <button type="button" class="btn btn-sm btn-primary" data-ai-correction-save>Save</button>
<span class="ai-correction-status" data-ai-correction-status role="status" aria-live="polite"></span> <span class="ai-correction-status" data-ai-correction-status role="status" aria-live="polite"></span>
@@ -251,7 +251,7 @@
<option value="sync" {% if ai_modifier_sync %}selected{% endif %}>Synchronously (wait while saving)</option> <option value="sync" {% if ai_modifier_sync %}selected{% endif %}>Synchronously (wait while saving)</option>
</select> </select>
</div> </div>
<textarea data-ai-modifier-prompt class="input-sm ai-correction-prompt" rows="3" aria-label="AI modifier prompt">{{ ai_modifier_prompt }}</textarea> <textarea data-ai-modifier-prompt class="input-sm ai-correction-prompt" maxlength="20000" rows="3" aria-label="AI modifier prompt">{{ ai_modifier_prompt }}</textarea>
<div class="customization-row"> <div class="customization-row">
<button type="button" class="btn btn-sm btn-primary" data-ai-modifier-save>Save</button> <button type="button" class="btn btn-sm btn-primary" data-ai-modifier-save>Save</button>
<span class="ai-correction-status" data-ai-modifier-status role="status" aria-live="polite"></span> <span class="ai-correction-status" data-ai-modifier-status role="status" aria-live="polite"></span>
+4 -3
View File
@@ -196,7 +196,8 @@ def test_robots_txt_exists(app_server):
assert "Sitemap:" in r.text assert "Sitemap:" in r.text
def test_robots_disallows_admin_and_uploads(app_server): def test_robots_disallows_auth_gated_paths(app_server):
r = requests.get(f"{BASE_URL}/robots.txt") r = requests.get(f"{BASE_URL}/robots.txt")
assert "Disallow: /admin/" in r.text required = ["/auth/", "/game/", "/messages/", "/notifications/", "/admin/", "/uploads/"]
assert "Disallow: /uploads/" in r.text for path in required:
assert f"Disallow: {path}" in r.text, f"Missing Disallow: {path}"
+50
View File
@@ -0,0 +1,50 @@
# 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}"