Files
devplacepy/devplacepy/middleware/guards.py
T
2026-10-17 07:33:08 +02:00

139 lines
4.6 KiB
Python

# retoor <retoor@molodetz.nl>
import time
from fastapi import Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_setting
from devplacepy.middleware import state
from devplacepy.responses import json_error, wants_json
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.templating import templates
from devplacepy.utils import client_ip, get_current_user
async def rate_limit_middleware(request: Request, call_next):
if state.RATE_LIMIT_DISABLED:
return await call_next(request)
if request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
settings = state._hot_settings()
limit = state._worker_rate_limit(settings["rate_limit_per_minute"])
window = settings["rate_limit_window_seconds"]
ip = client_ip(request, default="unknown")
now = time.time()
window_start = now - window
if now - state._last_rate_sweep >= state.RATE_SWEEP_INTERVAL:
state._sweep_rate_limit_store(window_start)
state._last_rate_sweep = now
timestamps = [t for t in state._rate_limit_store.get(ip, ()) if t > window_start]
if len(timestamps) >= limit:
state._rate_limit_store[ip] = timestamps
audit.record(
request,
"security.rate_limit.block",
result="denied",
summary=f"request from {ip} blocked by rate limit",
metadata={"ip": ip, "limit": limit, "window_seconds": window},
)
retry_after = {"Retry-After": str(window)}
if wants_json(request):
response = json_error(429, "Rate limit exceeded. Try again later.")
response.headers["Retry-After"] = str(window)
return response
return HTMLResponse(
"Rate limit exceeded. Try again later.",
status_code=429,
headers=retry_after,
)
timestamps.append(now)
state._rate_limit_store[ip] = timestamps
return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")
async def maintenance_middleware(request: Request, call_next):
if state._hot_settings()["maintenance_mode"] != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting(
"maintenance_message",
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
)
audit.record(
request,
"security.maintenance.block",
user=user,
result="denied",
summary="non-admin request blocked by maintenance mode",
)
if wants_json(request):
return json_error(503, message)
seo_ctx = base_seo_context(
request, title="Maintenance - DevPlace", description=message, robots="noindex"
)
return templates.TemplateResponse(
request,
"error.html",
{**seo_ctx, "request": request, "error_code": 503, "error_message": message},
status_code=503,
)
_TERMS_ALLOWED_PREFIXES = (
"/static",
"/avatar",
"/auth",
"/docs",
"/reports",
"/block",
"/mute",
"/openai",
)
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
def _terms_exempt(path: str) -> bool:
if path.startswith(_TERMS_ALLOWED_PREFIXES):
return True
return path.startswith("/profile/") and (
path.endswith("/delete") or path.endswith("/consent")
)
async def terms_acceptance_gate(request: Request, call_next):
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
return await call_next(request)
from devplacepy.routers.auth.terms import (
TERMS_ACCEPTANCE_CODE,
current_terms_version,
needs_acceptance,
)
user = get_current_user(request)
if not needs_acceptance(user):
return await call_next(request)
message = "Accept the updated Terms of Service to continue."
if wants_json(request):
return json_error(
403,
message,
code=TERMS_ACCEPTANCE_CODE,
redirect="/auth/accept-terms",
terms_version=current_terms_version(),
)
return RedirectResponse(url="/auth/accept-terms", status_code=303)