Molodetz blogrol volgens DPP-template
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user