Molodetz blogrol volgens DPP-template

This commit is contained in:
2026-10-05 09:36:20 +02:00
commit f290a4c766
277 changed files with 15758 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+81
View File
@@ -0,0 +1,81 @@
# retoor <retoor@molodetz.nl>
import json
import logging
from molodetz.database import generate_uid, insert_audit, now_iso
from molodetz.services.queue import background
from molodetz.utils.request_meta import client_ip
logger = logging.getLogger(__name__)
CATEGORY_BY_PREFIX = {
"auth": "auth",
"admin": "admin",
"post": "content",
"join": "community",
"security": "security",
"service": "service",
"backup": "service",
"system": "system",
"notification": "system",
"profile": "account",
}
def category_for(event_key):
return CATEGORY_BY_PREFIX.get(event_key.split(".", 1)[0], "other")
def _build(event_key, actor_kind, actor_uid, origin, result, ip, user_agent, message, payload, targets):
uid = generate_uid()
row = {
"uid": uid,
"event_key": event_key,
"category": category_for(event_key),
"actor_kind": actor_kind,
"actor_uid": actor_uid,
"origin": origin,
"result": result,
"ip": ip,
"user_agent": (user_agent or "")[:300] or None,
"message": message,
"payload": json.dumps(payload or {}, default=str),
"created_at": now_iso(),
}
links = [{"audit_uid": uid, "target_type": t, "target_uid": u} for t, u in (targets or [])]
return row, links
def record(request, event_key, result="ok", message=None, payload=None, targets=None, user=None):
try:
if user is None:
user = getattr(request.state, "user_cached", None) if request is not None else None
ip = client_ip(request) if request is not None else None
agent = request.headers.get("user-agent") if request is not None else None
row, links = _build(
event_key,
"user" if user else "guest",
user["uid"] if user else None,
"web",
result,
ip,
agent,
message,
payload,
targets,
)
background.submit(insert_audit, row, links)
return row["uid"]
except Exception:
logger.exception("audit record failed for %s", event_key)
return None
def record_system(event_key, actor_kind="system", origin="system", result="ok", message=None, payload=None, targets=None, actor_uid=None):
try:
row, links = _build(event_key, actor_kind, actor_uid, origin, result, None, None, message, payload, targets)
background.submit(insert_audit, row, links)
return row["uid"]
except Exception:
logger.exception("audit record_system failed for %s", event_key)
return None
+222
View File
@@ -0,0 +1,222 @@
# retoor <retoor@molodetz.nl>
import asyncio
import base64
import hashlib
from datetime import datetime, timezone
from fastapi import HTTPException
from passlib.hash import pbkdf2_sha256
from molodetz.cache import TTLCache
from molodetz.constants import ROLE_ADMIN
from molodetz.database import (
admin_uids_ordered,
bump_cache_version,
get_session,
get_user,
get_user_by_api_key,
get_user_by_email,
get_user_by_username,
hard_delete_session,
parse_iso,
sync_local_cache,
)
from molodetz.responses import wants_json
from molodetz.utils.audit import record
_UNSET = object()
_user_cache = TTLCache(ttl=300, max_size=2000)
USERNAME_PATTERN = r"^[A-Za-z0-9_-]{3,32}$"
class GuardRedirect(Exception):
def __init__(self, url, status_code=303):
self.url = url
self.status_code = status_code
def hash_password(password):
return pbkdf2_sha256.hash(password)
def verify_password(password, password_hash):
if not password_hash:
return False
try:
return pbkdf2_sha256.verify(password, password_hash)
except (ValueError, TypeError):
return False
async def hash_password_async(password):
return await asyncio.to_thread(hash_password, password)
async def verify_password_async(password, password_hash):
return await asyncio.to_thread(verify_password, password, password_hash)
def is_account_active(row):
if not row:
return False
value = row.get("is_active")
return value is None or bool(value)
def is_admin(user):
return bool(user) and user.get("role") == ROLE_ADMIN
def is_primary_admin(user):
if not is_admin(user):
return False
ordered = admin_uids_ordered()
return bool(ordered) and ordered[0] == user["uid"]
def is_senior_admin(actor, target):
if not is_admin(target) or not actor:
return False
ordered = admin_uids_ordered()
if target["uid"] not in ordered or actor["uid"] not in ordered:
return False
return ordered.index(target["uid"]) < ordered.index(actor["uid"])
def is_owner(item, user):
return bool(item) and bool(user) and item.get("user_uid") == user.get("uid")
def avatar_seed(user):
if not user:
return "guest"
return user.get("avatar_seed") or user.get("username") or "guest"
def invalidate_user_cache(propagate=True, key=None):
if propagate:
bump_cache_version("auth", _user_cache)
elif key is not None:
_user_cache.pop(key)
else:
_user_cache.clear()
def _resolve_session(token):
row = get_session(token)
if not row:
return None
expires = parse_iso(row.get("expires_at"))
if expires is None or expires < datetime.now(timezone.utc):
hard_delete_session(row["id"])
return None
user = get_user(row["user_uid"])
if not is_account_active(user):
hard_delete_session(row["id"])
return None
return user
def _resolve_key(credential):
user = get_user_by_api_key(credential)
if is_account_active(user):
return user, f"k:{credential}"
return None, None
def _resolve_basic(header_value):
try:
decoded = base64.b64decode(header_value).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
identifier, _, password = decoded.partition(":")
user = get_user_by_username(identifier) or get_user_by_email(identifier.lower())
if not is_account_active(user) or not verify_password(password, user.get("password_hash")):
return None
return user
def _lookup(key, loader):
cached = _user_cache.get(key, _UNSET)
if cached is not _UNSET:
return cached
user = loader()
_user_cache.set(key, user)
return user
def get_current_user(request):
memo = getattr(request.state, "user_cached", _UNSET)
if memo is not _UNSET:
return memo
sync_local_cache("auth", _user_cache)
user = None
token = request.cookies.get("session")
if token and len(token) == 64:
user = _lookup(token, lambda: _resolve_session(token))
if user is None:
api_key = (request.headers.get("x-api-key") or "").strip()
if api_key:
user = _lookup(f"k:{api_key}", lambda: _resolve_key(api_key)[0])
authorization = request.headers.get("authorization") or ""
if user is None and authorization.lower().startswith("bearer "):
credential = authorization[7:].strip()
user = _lookup(f"k:{credential}", lambda: _resolve_key(credential)[0])
if user is None and authorization.lower().startswith("basic "):
raw = authorization[6:].strip()
key = "b:" + hashlib.sha256(raw.encode()).hexdigest()
user = _lookup(key, lambda: _resolve_basic(raw))
request.state.user_cached = user
return user
def has_credentials(request):
return bool(request.headers.get("x-api-key") or request.headers.get("authorization"))
def refuse_suspended(request, user):
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return
path = request.url.path
if path.startswith("/auth"):
return
until = parse_iso(user.get("suspended_until"))
if until and until > datetime.now(timezone.utc):
reason = user.get("suspend_reason") or ""
record(request, "security.suspension.refused", result="denied", user=user)
raise HTTPException(403, f"Account suspended. {reason}".strip())
def require_user(request):
user = get_current_user(request)
if user is None:
record(request, "security.authorization.denied", result="denied", message="guest")
if has_credentials(request) or wants_json(request):
raise HTTPException(401, "Invalid credentials")
raise GuardRedirect(f"/auth/login?next={request.url.path}", 303)
refuse_suspended(request, user)
return user
def require_admin(request):
user = require_user(request)
if not is_admin(user):
record(request, "security.authorization.denied", result="denied", message="admin required", user=user)
if wants_json(request) or has_credentials(request):
raise HTTPException(403, "Administrator access required")
raise GuardRedirect("/", 303)
return user
def require_user_api(request):
user = get_current_user(request)
if user is None:
record(request, "security.authorization.denied", result="denied", message="api guest")
raise HTTPException(401, "Authentication required")
refuse_suspended(request, user)
return user
def not_found(detail="Niet gevonden"):
raise HTTPException(404, detail)
+13
View File
@@ -0,0 +1,13 @@
# retoor <retoor@molodetz.nl>
from molodetz.database import insert_notification
from molodetz.services.queue import background
from molodetz.utils.audit import record_system
def _persist(user_uid, kind, message, url):
insert_notification(user_uid, kind, message, url)
record_system("notification.create", payload={"kind": kind, "user_uid": user_uid})
def create_notification(user_uid, kind, message, url="/"):
background.submit(_persist, user_uid, kind, message, url)
+53
View File
@@ -0,0 +1,53 @@
# retoor <retoor@molodetz.nl>
import json
import threading
import time
from datetime import datetime, timedelta, timezone
from molodetz import config
from molodetz.database import db, get_service_state, now_iso, update_user
_lock = threading.Lock()
_last_write = {}
def touch(uid):
if not uid:
return
now = time.monotonic()
throttle = max(1, config.PRESENCE_TIMEOUT_SECONDS // 2)
with _lock:
if now - _last_write.get(uid, 0) < throttle:
return
_last_write[uid] = now
update_user(uid, last_seen_at=now_iso())
def compute_roster():
if "users" not in db.tables:
return []
window = config.PRESENCE_TIMEOUT_SECONDS + config.PRESENCE_ONLINE_MARGIN_SECONDS
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=window)).isoformat()
rows = db.query(
"SELECT uid FROM users WHERE last_seen_at >= :cutoff AND deleted_at IS NULL "
"ORDER BY last_seen_at DESC LIMIT :limit",
cutoff=cutoff,
limit=config.PRESENCE_TRACK_LIMIT,
)
return [row["uid"] for row in rows]
def published_roster():
row = get_service_state("presence")
if row and row.get("metrics"):
try:
metrics = json.loads(row["metrics"])
except ValueError:
metrics = {}
if "roster" in metrics:
return metrics["roster"]
return compute_roster()
def online_uids(limit=None):
return published_roster()[: limit or config.PRESENCE_ONLINE_LIMIT]
+39
View File
@@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
from urllib.parse import urlsplit
def client_ip(request):
real = request.headers.get("x-real-ip")
if real:
return real.strip()
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def safe_next(value, default="/"):
if not value or not isinstance(value, str):
return default
if not value.startswith("/") or value.startswith("//") or value.startswith("/\\"):
return default
if "\\" in value or "\r" in value or "\n" in value:
return default
return value
def redirect_back(request, default="/"):
referer = request.headers.get("referer")
if referer:
parts = urlsplit(referer)
if parts.netloc == request.url.netloc:
target = parts.path + (f"?{parts.query}" if parts.query else "")
return safe_next(target, default)
return safe_next(request.query_params.get("next"), default)
def is_secure_request(request):
proto = request.headers.get("x-forwarded-proto")
if proto:
return proto.split(",")[0].strip() == "https"
return request.url.scheme == "https"