Files
ad/molodetz/main.py
T
retoor 9fb4d65787 Member invites, gallery resync, content and operator docs
- Invite flow: admin issue/revoke on join requests, public single-use
  claim links (hash-only tokens, 7-day expiry, no state reveal), claim
  creates the Member account and marks the request accepted
- Gallery: admin status page plus resync endpoint; sync refreshes
  thumbnails whose content changed; tools/gallery contract and checker
- Docs: public content page, admin-only operator runbook, api.md
  invite/gallery sections, all routes in the live API docs, docs
  reachability gate test
- Screenshots cover the new pages; version 1.0.18
2026-10-06 02:56:08 +02:00

463 lines
18 KiB
Python

# retoor <retoor@molodetz.nl>
import fcntl
import logging
import math
import time
import traceback
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from molodetz import config
from molodetz.attachments import INLINE_EXTENSIONS, extension_of
from molodetz.constants import (
CRAWLER_MARKERS,
CRAWLER_PRIVATE_PREFIXES,
GZIP_SKIP_PREFIXES,
MAINTENANCE_ALLOWED_PREFIXES,
RATE_LIMIT_EXEMPT_PREFIXES,
TERMS_EXEMPT_PREFIXES,
)
from molodetz.database import flush_visits, get_int_setting, get_setting, init_db, refresh_snapshot
from molodetz.emoji_builder import GENERATED_JS, write_js_module
from molodetz.friendly404 import friendly_message
from molodetz.html_reformat import reformat_html
from molodetz.responses import json_error, wants_json
from molodetz.routers import (
auth,
avatar,
docs,
flyers,
health,
home,
invite,
join,
legacy,
memes,
notifications,
people,
posts,
presence,
privacy,
profile,
roll,
seo,
standard,
terms,
)
from molodetz.routers import admin
from molodetz.bootstrap import bootstrap_content
from molodetz.services.base import manager
from molodetz.services.queue import background
from molodetz.services.registry import build_services
from molodetz.templating import templates
from molodetz.utils.audit import record
from molodetz.utils.auth import GuardRedirect, get_current_user, is_admin
from molodetz.utils.presence import touch
from molodetz.utils.request_meta import client_ip, safe_next
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("molodetz")
_visit_counts = defaultdict(int)
_visit_state = {"last_flush": time.monotonic()}
_lock_handles = {}
def _take_lock(path, blocking):
handle = open(path, "a+")
flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
try:
fcntl.flock(handle, flags)
except BlockingIOError:
handle.close()
return None
return handle
def _flush_visit_counts():
if not _visit_counts:
return
day = datetime.now(timezone.utc).date().isoformat()
snapshot = dict(_visit_counts)
_visit_counts.clear()
_visit_state["last_flush"] = time.monotonic()
background.submit(flush_visits, day, snapshot)
@asynccontextmanager
async def lifespan(app):
config.ensure_data_dirs()
init_handle = _take_lock(config.INIT_LOCK_FILE, blocking=True)
try:
init_db()
bootstrap_content()
if not GENERATED_JS.exists():
write_js_module()
finally:
fcntl.flock(init_handle, fcntl.LOCK_UN)
init_handle.close()
for service in build_services():
manager.register(service)
if not config.DISABLE_SERVICES:
background.start()
service_handle = _take_lock(config.SERVICE_LOCK_FILE, blocking=False)
if service_handle is not None:
_lock_handles["service"] = service_handle
manager.supervise()
logger.info("service lock acquired; supervising %d services", len(manager.services))
else:
logger.info("service lock held elsewhere; skipping supervision")
yield
if _visit_counts:
_flush_visit_counts()
await manager.shutdown_all()
await background.stop()
for handle in _lock_handles.values():
handle.close()
_lock_handles.clear()
app = FastAPI(
title=config.APP_TITLE,
docs_url="/swagger",
redoc_url=None,
swagger_ui_oauth2_redirect_url=None,
openapi_url="/openapi.json",
lifespan=lifespan,
)
app.include_router(auth.router, prefix="/auth")
app.include_router(admin.router, prefix="/admin")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(profile.router, prefix="/profile")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(presence.router, prefix="/presence")
app.include_router(posts.router, prefix="/posts")
app.include_router(roll.router, prefix="/roll")
app.include_router(standard.router, prefix="/standard")
app.include_router(flyers.router, prefix="/flyers")
app.include_router(memes.router, prefix="/memes")
app.include_router(people.router, prefix="/people")
app.include_router(join.router, prefix="/join")
app.include_router(invite.router, prefix="/invite")
app.include_router(terms.router, prefix="/terms")
app.include_router(privacy.router, prefix="/privacy")
app.include_router(health.router, prefix="/health")
app.include_router(seo.router)
app.include_router(legacy.router)
app.include_router(docs.router)
app.include_router(home.router)
class UploadsStatic(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
disposition = "inline" if extension_of(path) in INLINE_EXTENSIONS else "attachment"
response.headers["Content-Disposition"] = disposition
response.headers["Cache-Control"] = "public, max-age=604800"
return response
class VersionedStatic(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if path.endswith("sw.js"):
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
class FallbackStatic(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-cache" if path.endswith("sw.js") else "public, max-age=3600"
return response
app.mount("/static/uploads", UploadsStatic(directory=str(config.DATA_PATHS["uploads"]), check_dir=False), name="uploads")
app.mount(f"/static/v{config.STATIC_VERSION}", VersionedStatic(directory=str(config.STATIC_DIR)), name="static_versioned")
app.mount("/static", FallbackStatic(directory=str(config.STATIC_DIR)), name="static")
def _is_mutating(request):
return request.method in ("POST", "PUT", "PATCH", "DELETE")
@app.middleware("http")
async def snapshot_middleware(request: Request, call_next):
path = request.url.path
if not path.startswith(("/static", "/avatar")):
refresh_snapshot()
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Robots-Tag", "index, follow")
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
_rate_buckets = defaultdict(list)
_rate_state = {"limit": None, "window": None, "loaded": 0.0, "swept": time.monotonic()}
def _rate_settings():
now = time.monotonic()
if now - _rate_state["loaded"] > 2:
_rate_state["limit"] = max(1, get_int_setting("rate_limit_per_minute", config.RATE_LIMIT))
_rate_state["window"] = max(1, get_int_setting("rate_limit_window_seconds", 60))
_rate_state["loaded"] = now
per_worker = max(1, math.ceil(_rate_state["limit"] / max(1, config.WEB_WORKERS)))
return per_worker, _rate_state["window"]
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if config.DISABLE_RATE_LIMIT or not _is_mutating(request):
return await call_next(request)
if RATE_LIMIT_EXEMPT_PREFIXES and request.url.path.startswith(RATE_LIMIT_EXEMPT_PREFIXES):
return await call_next(request)
limit, window = _rate_settings()
now = time.monotonic()
if now - _rate_state["swept"] > 60:
for key in list(_rate_buckets):
_rate_buckets[key] = [stamp for stamp in _rate_buckets[key] if now - stamp < window]
if not _rate_buckets[key]:
del _rate_buckets[key]
_rate_state["swept"] = now
key = client_ip(request)
bucket = [stamp for stamp in _rate_buckets[key] if now - stamp < window]
if len(bucket) >= max(1, limit):
_rate_buckets[key] = bucket
retry = max(1, int(window - (now - bucket[0])))
record(request, "security.ratelimit.exceeded", result="denied", payload={"ip": key})
if wants_json(request):
response = json_error(429, "Too many requests", retry_after=retry)
else:
response = PlainTextResponse("Too many requests. Try again in a moment.", status_code=429)
response.headers["Retry-After"] = str(retry)
return response
bucket.append(now)
_rate_buckets[key] = bucket
return await call_next(request)
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if get_setting("maintenance_mode", "0") != "1":
return await call_next(request)
if request.url.path.startswith(MAINTENANCE_ALLOWED_PREFIXES) or is_admin(get_current_user(request)):
return await call_next(request)
message = get_setting("maintenance_message", "Maintenance")
record(request, "security.maintenance.blocked", result="denied")
if wants_json(request):
return json_error(503, message)
return templates.TemplateResponse(
request,
"error.html",
{"status_code": 503, "message": message, "seo": {"title": "Maintenance", "robots": "noindex,nofollow"}},
status_code=503,
)
@app.middleware("http")
async def terms_middleware(request: Request, call_next):
if not _is_mutating(request) or request.url.path.startswith(TERMS_EXEMPT_PREFIXES):
return await call_next(request)
user = get_current_user(request)
version = get_setting("terms_version", "1")
if user is not None and str(user.get("terms_version") or "") != str(version):
target = f"/terms?next={request.url.path}"
if wants_json(request):
return json_error(403, "Accept the terms first", code="terms_required", redirect=target, terms_version=version)
return RedirectResponse(target, status_code=303)
return await call_next(request)
@app.middleware("http")
async def crawler_block_middleware(request: Request, call_next):
if request.method == "GET" and request.url.path.startswith(CRAWLER_PRIVATE_PREFIXES):
agent = (request.headers.get("user-agent") or "").lower()
if any(marker in agent for marker in CRAWLER_MARKERS):
record(request, "security.crawler.blocked", result="denied", payload={"agent": agent[:120]})
if wants_json(request):
return json_error(403, "Crawlers are not allowed here")
return PlainTextResponse("Forbidden", status_code=403)
return await call_next(request)
@app.middleware("http")
async def presence_middleware(request: Request, call_next):
if not request.url.path.startswith(("/static", "/avatar")):
user = get_current_user(request)
if user:
touch(user["uid"])
return await call_next(request)
@app.middleware("http")
async def visits_middleware(request: Request, call_next):
response = await call_next(request)
path = request.url.path
if not path.startswith(("/static", "/avatar")):
_visit_counts[(path[:200], response.status_code)] += 1
if time.monotonic() - _visit_state["last_flush"] > 30:
_flush_visit_counts()
return response
@app.middleware("http")
async def html_reformat_middleware(request: Request, call_next):
response = await call_next(request)
content_type = response.headers.get("content-type", "")
if not content_type.startswith("text/html") or response.headers.get("content-encoding"):
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
try:
body = reformat_html(body.decode("utf-8")).encode("utf-8")
except Exception:
logger.warning("html reformat failed; serving original body")
headers = dict(response.headers)
headers["content-length"] = str(len(body))
return Response(content=body, status_code=response.status_code, headers=headers, media_type=None)
@app.middleware("http")
async def timing_middleware(request: Request, call_next):
request.state.request_start = time.perf_counter()
response = await call_next(request)
response.headers["X-Response-Time"] = f"{(time.perf_counter() - request.state.request_start) * 1000:.1f}ms"
return response
class SelectiveGZipMiddleware(GZipMiddleware):
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and GZIP_SKIP_PREFIXES and scope["path"].startswith(GZIP_SKIP_PREFIXES):
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
app.add_middleware(SelectiveGZipMiddleware, minimum_size=512, compresslevel=5)
def _error_page(request, status_code, message, headers=None):
seo = {"title": f"{status_code}", "robots": "noindex,nofollow", "description": message}
return templates.TemplateResponse(
request,
"error.html",
{"status_code": status_code, "message": message, "seo": seo},
status_code=status_code,
headers=headers,
)
@app.exception_handler(GuardRedirect)
async def guard_redirect_handler(request: Request, exc: GuardRedirect):
return RedirectResponse(exc.url, status_code=exc.status_code)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if wants_json(request) or request.headers.get("x-api-key") or request.headers.get("authorization"):
return json_error(exc.status_code, str(exc.detail))
if exc.status_code == 404:
message = friendly_message() if get_setting("friendly_404", "1") == "1" else "Not found."
return _error_page(request, 404, message)
return _error_page(request, exc.status_code, str(exc.detail), headers=getattr(exc, "headers", None))
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
return await http_exception_handler(request, HTTPException(404, "Not found"))
@app.exception_handler(500)
async def server_error_handler(request: Request, exc):
logger.error("unhandled error: %s", "".join(traceback.format_exception(exc)))
if wants_json(request):
return json_error(500, "Internal error")
return _error_page(request, 500, "Something broke on our side.")
AUTH_TEMPLATES = {"/auth/login": "auth/login.html"}
def _friendly_messages(errors):
fields, messages = [], []
for error in errors:
loc = [str(part) for part in error.get("loc", ()) if part not in ("body", "query")]
field = loc[-1] if loc else "body"
message = str(error.get("msg", "Invalid input")).replace("Value error, ", "")
if field == "password" and "at least" in message:
message = "Password must be 6 to 128 characters."
fields.append(field)
messages.append(f"{field}: {message}" if field != "body" else message)
return fields, messages
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
fields, messages = _friendly_messages(exc.errors())
if wants_json(request):
return JSONResponse({"error": "validation", "fields": fields, "messages": messages}, status_code=422)
template = AUTH_TEMPLATES.get(request.url.path)
if template:
form = await request.form()
echoed = {key: value for key, value in form.items() if isinstance(value, str) and key != "password"}
return templates.TemplateResponse(
request,
template,
{"errors": messages, "form": echoed, "next": echoed.get("next"), "seo": {"title": "Log in", "robots": "noindex,nofollow"}},
status_code=400,
)
if request.url.path == "/join":
form = await request.form()
echoed = {key: value for key, value in form.items() if isinstance(value, str)}
return templates.TemplateResponse(
request,
"join.html",
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Join", "robots": "index,follow"}},
status_code=400,
)
if request.url.path.startswith("/invite/"):
from molodetz.database import get_invite_by_token
form = await request.form()
echoed = {key: value for key, value in form.items() if isinstance(value, str) and not key.startswith("password")}
invite = get_invite_by_token(request.url.path.rsplit("/", 1)[-1])
return templates.TemplateResponse(
request,
"invite.html",
{
"valid": True,
"errors": messages,
"form": echoed,
"email_locked": bool(invite and invite.get("email")),
"seo": {"title": "Accept invite", "robots": "noindex,nofollow"},
},
status_code=400,
)
return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303)