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>
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
from molodetz.constants import MEDIA_KINDS
from molodetz.gallery import gallery_items
from molodetz.responses import respond
from molodetz.schemas.gallery import GalleryOut
from molodetz.seo import base_seo_context
from molodetz.seo_meta_text import description_for
from molodetz.templating import static_url
def gallery_page(request, kind, slug):
title = MEDIA_KINDS[kind]
items = gallery_items(kind)
for item in items:
item["url"] = static_url(item["source_path"])
first = items[0]["url"] if items else None
seo = base_seo_context(
request,
title,
description_for(slug),
og_image=first,
breadcrumbs=[("Molodetz", "/"), (title, f"/{slug}")],
)
return respond(request, "gallery.html", {"kind": kind, "title": title, "items": items, "seo": seo}, model=GalleryOut)
+25
View File
@@ -0,0 +1,25 @@
# retoor <retoor@molodetz.nl>
from molodetz.constants import TOPICS
from molodetz.database import published_posts
from molodetz.responses import respond
from molodetz.schemas.posts import PostListOut
from molodetz.seo import base_seo_context, website_schema
from molodetz.seo_meta_text import description_for
def topic_listing(request, topic):
before = request.query_params.get("before")
posts, next_cursor = published_posts(topic=topic, before=before, limit=25)
title = TOPICS[topic]
next_url = f"{request.url.path}?before={next_cursor}" if next_cursor else None
seo = base_seo_context(
request,
title,
description_for(topic),
breadcrumbs=[("Molodetz", "/"), (title, f"/{topic}")],
schemas=[website_schema(request)],
next_url=next_url,
robots="noindex,follow" if before else "index,follow",
)
ctx = {"title": title, "topic": topic, "posts": posts, "next_cursor": next_cursor, "next_url": next_url, "seo": seo}
return respond(request, "listing.html", ctx, model=PostListOut)
+13
View File
@@ -0,0 +1,13 @@
# retoor <retoor@molodetz.nl>
from molodetz.routers.admin import audit, backups, joins, posts, services, settings, stats, trash, users
from molodetz.routers.admin.index import router
router.include_router(posts.router)
router.include_router(joins.router)
router.include_router(services.router)
router.include_router(trash.router)
router.include_router(settings.router)
router.include_router(users.router)
router.include_router(audit.router)
router.include_router(backups.router)
router.include_router(stats.router)
+6
View File
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from molodetz.seo import base_seo_context
def admin_seo(request, title):
return base_seo_context(request, title, robots="noindex,nofollow")
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import list_audit
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AuditOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("/audit")
async def audit_index(request: Request):
require_admin(request)
category = request.query_params.get("category") or None
return respond(request, "admin/audit.html", {"rows": list_audit(200, category), "category": category, "seo": admin_seo(request, "Auditlog")}, model=AuditOut)
+60
View File
@@ -0,0 +1,60 @@
# retoor <retoor@molodetz.nl>
import aiofiles
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from molodetz.attachments import format_size
from molodetz.database import enqueue_backup, get_backup, list_backups
from molodetz.dependencies import json_or_form
from molodetz.models import BackupForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import BackupsOut
from molodetz.services.backup import backup_path
from molodetz.utils.audit import record
from molodetz.utils.auth import is_primary_admin, not_found, require_admin
router = APIRouter()
@router.get("/backups")
async def backups_index(request: Request):
user = require_admin(request)
rows = [{**row, "size_label": format_size(row.get("size_bytes"))} for row in list_backups()]
ctx = {"backups": rows, "can_download": is_primary_admin(user), "seo": admin_seo(request, "Back-ups")}
return respond(request, "admin/backups.html", ctx, model=BackupsOut)
@router.post("/backups")
async def backups_enqueue(request: Request):
user = require_admin(request)
data = await json_or_form(request, BackupForm)
row = enqueue_backup(data.target, user["uid"])
record(request, "backup.enqueue", payload={"target": data.target}, targets=[("backup", row["uid"])], user=user)
return action_result(request, "/admin/backups", data={"uid": row["uid"]})
@router.get("/backups/{uid}/download")
async def backups_download(request: Request, uid: str):
user = require_admin(request)
if not is_primary_admin(user):
record(request, "backup.download", result="denied", targets=[("backup", uid)], user=user)
raise HTTPException(403, "Alleen de primaire beheerder")
row = get_backup(uid)
if row is None or row.get("status") != "done":
not_found("Back-up niet gevonden")
path = backup_path(row)
if not path.exists():
not_found("Archief ontbreekt")
record(request, "backup.download", targets=[("backup", uid)], user=user)
return StreamingResponse(
_stream_file(path),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{row["stored_name"]}"', "Content-Length": str(path.stat().st_size)},
)
async def _stream_file(path, chunk_size=1024 * 256):
async with aiofiles.open(path, "rb") as handle:
while chunk := await handle.read(chunk_size):
yield chunk
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database import all_posts, count_deleted, count_open_join_requests, count_users
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminIndexOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("")
async def admin_index(request: Request):
require_admin(request)
ctx = {
"post_count": len(all_posts()),
"open_joins": count_open_join_requests(),
"user_count": count_users(),
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
"seo": admin_seo(request, "Beheer"),
}
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import get_join_request, list_join_requests, update_join_request
from molodetz.dependencies import json_or_form
from molodetz.link_check import check_repo_link
from molodetz.models import JoinStatusForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.joins import JoinListOut
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
@router.get("/joins")
async def joins_index(request: Request):
require_admin(request)
return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Aanmeldingen")}, model=JoinListOut)
@router.post("/joins/{uid}/status")
async def joins_status(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Aanmelding niet gevonden")
data = await json_or_form(request, JoinStatusForm)
update_join_request(uid, status=data.status)
record(request, "join.request.status", payload={"status": data.status}, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data={"uid": uid, "status": data.status})
@router.post("/joins/{uid}/check")
async def joins_check(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Aanmelding niet gevonden")
result = await check_repo_link(row)
record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data=result)
+88
View File
@@ -0,0 +1,88 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.constants import TOPICS
from molodetz.content import create_post, delete_post, edit_post
from molodetz.database import all_posts, get_post
from molodetz.dependencies import json_or_form
from molodetz.models import PostForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminPostsOut
from molodetz.schemas.posts import PostDetailOut
from molodetz.utils.audit import record
from molodetz.utils.auth import is_admin, is_owner, not_found, require_admin
router = APIRouter()
@router.get("/posts")
async def posts_index(request: Request):
require_admin(request)
return respond(request, "admin/posts.html", {"posts": all_posts(), "seo": admin_seo(request, "Berichten")}, model=AdminPostsOut)
@router.get("/posts/new")
async def posts_new(request: Request):
require_admin(request)
ctx = {"post": None, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Nieuw bericht")}
return respond(request, "admin/post_form.html", ctx, model=PostDetailOut)
@router.post("/posts")
async def posts_create(request: Request):
user = require_admin(request)
data = await json_or_form(request, PostForm)
row = create_post(user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request)
return action_result(request, "/admin/posts", data={"uid": row["uid"], "slug": row["slug"]})
@router.get("/posts/{uid}/edit")
async def posts_edit(request: Request, uid: str):
require_admin(request)
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
ctx = {"post": post, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Bericht bewerken")}
return respond(request, "admin/post_form.html", ctx, model=PostDetailOut)
def _owned_post(request, user, uid):
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
if not is_owner(post, user):
record(request, "post.edit.denied", result="denied", targets=[("post", uid)], user=user)
raise HTTPException(403, "Alleen de auteur mag dit bericht bewerken")
return post
@router.post("/posts/{uid}")
async def posts_update(request: Request, uid: str):
user = require_admin(request)
post = _owned_post(request, user, uid)
data = await json_or_form(request, PostForm)
edit_post(post, user, data.title, data.body, data.topic, data.status, data.is_placeholder, request=request)
return action_result(request, "/admin/posts", data={"uid": uid})
@router.post("/posts/{uid}/publish")
async def posts_publish(request: Request, uid: str):
user = require_admin(request)
post = _owned_post(request, user, uid)
status = "draft" if post["status"] == "published" else "published"
edit_post(post, user, post["title"], post["body"], post["topic"], status, post.get("is_placeholder"), request=request)
return action_result(request, "/admin/posts", data={"uid": uid, "status": status})
@router.post("/posts/{uid}/delete")
async def posts_delete(request: Request, uid: str):
user = require_admin(request)
post = get_post(uid)
if post is None:
not_found("Bericht niet gevonden")
if not (is_owner(post, user) or is_admin(user)):
record(request, "post.delete.denied", result="denied", targets=[("post", uid)], user=user)
raise HTTPException(403, "Niet toegestaan")
stamp = delete_post(post, user, request=request)
return action_result(request, "/admin/posts", data={"uid": uid, "stamp": stamp})
+69
View File
@@ -0,0 +1,69 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import ServiceDetailOut, ServicesOut
from molodetz.services.base import manager
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
VERBS = ("start", "stop", "run", "clear")
def _service(name):
service = manager.get(name)
if service is None:
not_found("Onbekende service")
return service
@router.get("/services")
async def services_index(request: Request):
require_admin(request)
return respond(request, "admin/services.html", {"services": manager.describe_all(), "seo": admin_seo(request, "Services")}, model=ServicesOut)
@router.get("/services/{name}")
async def services_detail(request: Request, name: str):
require_admin(request)
service = _service(name)
ctx = {"service": manager.describe(service), "seo": admin_seo(request, service.title)}
return respond(request, "admin/service.html", ctx, model=ServiceDetailOut)
async def _payload(request):
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
return data if isinstance(data, dict) else {}
form = await request.form()
return {key: value for key, value in form.items()}
@router.post("/services/{name}/command")
async def services_command(request: Request, name: str):
user = require_admin(request)
service = _service(name)
verb = (await _payload(request)).get("verb", "")
if verb not in VERBS:
raise HTTPException(400, "Onbekend commando")
if verb in ("start", "stop"):
manager.set_enabled(service.name, verb == "start")
else:
manager.send_command(service.name, verb)
record(request, f"service.command.{verb}", targets=[("service", service.name)], user=user)
return action_result(request, f"/admin/services/{service.name}", data={"verb": verb})
@router.post("/services/{name}/config")
async def services_config(request: Request, name: str):
user = require_admin(request)
service = _service(name)
values = await _payload(request)
try:
updates = manager.save_config(service.name, values)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
record(request, "service.config.save", payload={"keys": sorted(updates)}, targets=[("service", service.name)], user=user)
return action_result(request, f"/admin/services/{service.name}", data={"saved": sorted(updates)})
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import all_settings, get_setting, set_setting
from molodetz.dependencies import json_or_form
from molodetz.models import SettingsForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import SettingsOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
NUMERIC = ("rate_limit_per_minute", "rate_limit_window_seconds", "session_max_age_days", "session_remember_days", "audit_retention_days")
BOOLEAN = ("maintenance_mode", "friendly_404")
@router.get("/settings")
async def settings_index(request: Request):
require_admin(request)
return respond(request, "admin/settings.html", {"settings": all_settings(), "seo": admin_seo(request, "Instellingen")}, model=SettingsOut)
@router.post("/settings")
async def settings_save(request: Request):
user = require_admin(request)
data = await json_or_form(request, SettingsForm)
changed = []
for key, value in data.model_dump(exclude_none=True).items():
if value == "":
continue
if key in NUMERIC:
try:
value = str(max(1, int(value)))
except ValueError:
continue
if key in BOOLEAN and value not in ("0", "1"):
continue
if get_setting(key) != value:
set_setting(key, value)
changed.append(key)
record(request, "admin.settings.save", payload={"keys": changed}, user=user)
return action_result(request, "/admin/settings", data={"changed": changed})
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import daily_totals, top_paths
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import StatsOut
from molodetz.utils.auth import require_admin
router = APIRouter()
@router.get("/stats")
async def stats_index(request: Request):
require_admin(request)
ctx = {"top_paths": [dict(row) for row in top_paths()], "daily": [dict(row) for row in daily_totals()], "seo": admin_seo(request, "Statistieken")}
return respond(request, "admin/stats.html", ctx, model=StatsOut)
+62
View File
@@ -0,0 +1,62 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database import list_deleted, purge_event, restore_event
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import TrashOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
LABEL_FIELDS = ("title", "name", "username", "message", "filename", "token")
def _label(row):
for key in LABEL_FIELDS:
if row.get(key):
return str(row[key])[:24] if key == "token" else str(row[key])[:120]
return row.get("uid", "")
@router.get("/trash")
async def trash_index(request: Request):
require_admin(request)
items = []
for table in SOFT_DELETE_TABLES:
if table == "sessions":
continue
for row in list_deleted(table):
items.append({"table": table, "uid": row.get("uid"), "label": _label(row), "deleted_at": row.get("deleted_at"), "deleted_by": row.get("deleted_by")})
items.sort(key=lambda item: item["deleted_at"] or "", reverse=True)
return respond(request, "admin/trash.html", {"items": items, "seo": admin_seo(request, "Prullenbak")}, model=TrashOut)
async def _stamp(request):
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
stamp = data.get("stamp") if isinstance(data, dict) else None
else:
stamp = (await request.form()).get("stamp")
if not stamp:
raise HTTPException(400, "stamp ontbreekt")
return str(stamp)
@router.post("/trash/restore")
async def trash_restore(request: Request):
user = require_admin(request)
stamp = await _stamp(request)
tables = restore_event(stamp)
record(request, "admin.trash.restore", payload={"stamp": stamp}, user=user)
return action_result(request, "/admin/trash", data={"tables": tables})
@router.post("/trash/purge")
async def trash_purge(request: Request):
user = require_admin(request)
stamp = await _stamp(request)
purged = purge_event(stamp)
record(request, "admin.trash.purge", payload={"stamp": stamp, "tables": [name for name, _ in purged]}, user=user)
return action_result(request, "/admin/trash", data={"purged": [name for name, _ in purged]})
+35
View File
@@ -0,0 +1,35 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, HTTPException, Request
from molodetz.database import get_user, list_users, set_role
from molodetz.dependencies import json_or_form
from molodetz.models import RoleForm
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminUsersOut
from molodetz.utils.audit import record
from molodetz.utils.auth import invalidate_user_cache, is_senior_admin, not_found, require_admin
router = APIRouter()
@router.get("/users")
async def users_index(request: Request):
require_admin(request)
return respond(request, "admin/users.html", {"users": list_users(), "seo": admin_seo(request, "Accounts")}, model=AdminUsersOut)
@router.post("/users/{uid}/role")
async def users_role(request: Request, uid: str):
actor = require_admin(request)
target = get_user(uid)
if target is None:
not_found("Account niet gevonden")
data = await json_or_form(request, RoleForm)
if is_senior_admin(actor, target) or (target["uid"] == actor["uid"] and data.role != "Admin"):
record(request, "admin.user.role", result="denied", targets=[("user", uid)], user=actor)
raise HTTPException(403, "Je kunt een senior beheerder niet beheren")
set_role(uid, data.role)
invalidate_user_cache(propagate=True)
record(request, "admin.user.role", payload={"role": data.role}, targets=[("user", uid)], user=actor)
return action_result(request, "/admin/users", data={"uid": uid, "role": data.role})
+70
View File
@@ -0,0 +1,70 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import create_session, end_session, get_int_setting, get_user_by_email, get_user_by_username
from molodetz.dependencies import json_or_form
from molodetz.models import LoginForm
from molodetz.responses import action_result, json_error, respond, wants_json
from molodetz.schemas.account import LoginPageOut
from molodetz.seo import base_seo_context
from molodetz.templating import templates
from molodetz.utils.audit import record
from molodetz.utils.auth import get_current_user, invalidate_user_cache, is_account_active, verify_password_async
from molodetz.utils.request_meta import is_secure_request, safe_next
router = APIRouter()
def _seo(request):
return base_seo_context(request, "Inloggen", robots="noindex,nofollow")
@router.get("/login")
async def login_page(request: Request):
next_url = safe_next(request.query_params.get("next"), "/admin")
return respond(request, "auth/login.html", {"errors": [], "form": {}, "next": next_url, "seo": _seo(request)}, model=LoginPageOut)
@router.post("/login")
async def login_submit(request: Request):
data = await json_or_form(request, LoginForm)
user = get_user_by_username(data.username) or get_user_by_email(data.username.lower())
valid = user is not None and is_account_active(user) and await verify_password_async(data.password, user.get("password_hash"))
if not valid:
record(request, "auth.login.failed", result="denied", payload={"username": data.username[:64]})
ctx = {
"errors": ["Onbekende combinatie van naam en wachtwoord."],
"form": {"username": data.username},
"next": data.next,
"seo": _seo(request),
}
if wants_json(request):
return json_error(401, "Invalid credentials")
return templates.TemplateResponse(request, "auth/login.html", ctx, status_code=400)
days = get_int_setting("session_remember_days", 30) if data.remember else get_int_setting("session_max_age_days", 7)
max_age = max(1, days) * 86400
token = create_session(user["uid"], max_age)
record(request, "auth.login", targets=[("user", user["uid"])], user=user)
response = action_result(request, safe_next(data.next, "/admin"))
response.set_cookie(
key="session",
value=token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=is_secure_request(request),
)
return response
@router.post("/logout")
async def logout(request: Request):
user = get_current_user(request)
token = request.cookies.get("session")
if token:
end_session(token, user["uid"] if user else "guest")
invalidate_user_cache(propagate=True)
record(request, "auth.logout", user=user)
response = action_result(request, "/")
response.delete_cookie("session")
return response
+13
View File
@@ -0,0 +1,13 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter
from fastapi.responses import Response
from molodetz.avatar import render_avatar_svg
router = APIRouter()
@router.get("/svg/{seed}")
async def avatar_svg(seed: str, size: int = 64):
svg = render_avatar_svg(seed[:128], size)
return Response(svg, media_type="image/svg+xml", headers={"Cache-Control": "public, max-age=86400"})
+42
View File
@@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
import hashlib
from fastapi import APIRouter, Request
from molodetz import config
from molodetz.database import admin_uids_ordered, insert_join_request
from molodetz.dependencies import json_or_form
from molodetz.models import JoinForm
from molodetz.responses import action_result, respond
from molodetz.schemas.joins import JoinPageOut
from molodetz.seo import base_seo_context
from molodetz.seo_meta_text import description_for
from molodetz.utils.audit import record
from molodetz.utils.notifications import create_notification
from molodetz.utils.request_meta import client_ip
router = APIRouter()
def _seo(request):
return base_seo_context(request, "Binnen", description_for("binnen"), breadcrumbs=[("Molodetz", "/"), ("Binnen", "/binnen")])
@router.get("")
async def join_page(request: Request):
submitted = request.query_params.get("ok") == "1"
return respond(request, "binnen.html", {"submitted": submitted, "errors": [], "form": {}, "seo": _seo(request)}, model=JoinPageOut)
@router.post("")
async def join_submit(request: Request):
data = await json_or_form(request, JoinForm)
if data.website:
record(request, "join.request.honeypot", result="denied")
return action_result(request, "/binnen?ok=1")
ip_hash = hashlib.sha256(f"{config.SECRET_KEY}:{client_ip(request)}".encode()).hexdigest()[:24]
row = insert_join_request(data.name, data.contact, data.repo_url, data.message, ip_hash)
record(request, "join.request.create", payload={"uid": row["uid"]}, targets=[("join_request", row["uid"])])
for uid in admin_uids_ordered():
create_notification(uid, "join.request", f"{data.name} wil meeschrijven", "/admin/joins")
return action_result(request, "/binnen?ok=1", data={"uid": row["uid"]})
+76
View File
@@ -0,0 +1,76 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from molodetz.docs_api import group_by_slug, ordered_groups, substitute
from molodetz.docs_export import export_html, export_markdown
from molodetz.docs_live import live_services
from molodetz.docs_prose import page_by_slug, render_page, sidebar
from molodetz.docs_search import search
from molodetz.seo import base_seo_context, base_url
from molodetz.templating import templates
from molodetz.utils.auth import get_current_user, is_admin, not_found
router = APIRouter()
def _ctx(request, title, **extra):
viewer = get_current_user(request)
viewer_is_admin = is_admin(viewer)
groups = [group for group in ordered_groups() if viewer_is_admin or group["slug"] != "beheer"]
seo = base_seo_context(request, title, breadcrumbs=[("Molodetz", "/"), ("Docs", "/docs")])
return {"sidebar": sidebar(viewer_is_admin), "api_groups": groups, "viewer_is_admin": viewer_is_admin, "seo": seo, "doc_title": title, **extra}
@router.get("/docs")
async def docs_index(request: Request):
return templates.TemplateResponse(request, "docs/index.html", _ctx(request, "Documentatie", body=render_page("welkom")))
@router.get("/docs/search")
async def docs_search(request: Request):
query = request.query_params.get("q", "")[:200]
viewer_is_admin = is_admin(get_current_user(request))
results = search(query, viewer_is_admin)
return templates.TemplateResponse(request, "docs/search.html", _ctx(request, "Zoeken", query=query, results=results))
@router.get("/docs/download.md")
async def docs_download_md(request: Request):
viewer_is_admin = is_admin(get_current_user(request))
return PlainTextResponse(export_markdown(viewer_is_admin), media_type="text/markdown; charset=utf-8")
@router.get("/docs/download.html")
async def docs_download_html(request: Request):
viewer_is_admin = is_admin(get_current_user(request))
return HTMLResponse(export_html(viewer_is_admin))
@router.get("/docs/api/{group_slug}")
async def docs_api_group(request: Request, group_slug: str):
group = group_by_slug(group_slug)
viewer = get_current_user(request)
if group is None or (group["slug"] == "beheer" and not is_admin(viewer)):
not_found("Onbekende API-groep")
base = base_url(request)
username = viewer["username"] if viewer else "retoor"
api_key = viewer["api_key"] if viewer else "JOUW_API_SLEUTEL"
endpoints = []
for item in group["endpoints"]:
example = substitute(f"curl -H 'Accept: application/json' -H 'X-API-KEY: {{{{api_key}}}}' {{{{base}}}}{item['path']}", base, username, api_key)
endpoints.append({**item, "curl": example})
return templates.TemplateResponse(
request,
"docs/api.html",
_ctx(request, f"API: {group['title']}", group=group, endpoints=endpoints, viewer=viewer, services=live_services() if group["slug"] == "beheer" else []),
)
@router.get("/docs/{slug}")
async def docs_page(request: Request, slug: str):
page = page_by_slug(slug)
viewer_is_admin = is_admin(get_current_user(request))
if page is None or (page.get("admin") and not viewer_is_admin):
not_found("Pagina niet gevonden")
return templates.TemplateResponse(request, "docs/page.html", _ctx(request, page["title"], body=render_page(slug), page=page))
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.routers._gallery import gallery_page
router = APIRouter()
@router.get("")
async def flyers(request: Request):
return gallery_page(request, "flyer", "flyers")
+12
View File
@@ -0,0 +1,12 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from molodetz import config
router = APIRouter()
@router.get("")
async def health():
return JSONResponse({"status": "ok", "version": config.APP_VERSION})
+42
View File
@@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz import config
from molodetz.cache import TTLCache
from molodetz.database import count_media, published_posts
from molodetz.gallery import gallery_items
from molodetz.responses import respond
from molodetz.schemas.posts import HomeOut
from molodetz.seo import base_seo_context, organization_schema, website_schema
from molodetz.seo_meta_text import description_for
router = APIRouter()
_landing_cache = TTLCache(ttl=config.LANDING_TTL, max_size=4)
def _landing_blocks():
cached = _landing_cache.get("blocks")
if cached is not None:
return cached
posts, _ = published_posts(limit=8)
flyers = gallery_items("flyer")
blocks = {
"posts": posts,
"featured_flyer": flyers[0] if flyers else None,
"flyer_count": count_media("flyer"),
"meme_count": count_media("meme"),
}
_landing_cache.set("blocks", blocks)
return blocks
@router.get("/")
async def index(request: Request):
blocks = _landing_blocks()
seo = base_seo_context(
request,
config.APP_TITLE,
description_for("home"),
schemas=[website_schema(request), organization_schema(request)],
)
return respond(request, "home.html", {**blocks, "seo": seo}, model=HomeOut)
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.routers._gallery import gallery_page
router = APIRouter()
@router.get("")
async def memes(request: Request):
return gallery_page(request, "meme", "memes")
+42
View File
@@ -0,0 +1,42 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import get_user_by_username, post_counts_by_author, public_people, published_posts
from molodetz.responses import respond
from molodetz.schemas.people import PeopleOut, ProfileOut
from molodetz.seo import base_seo_context, profile_schema
from molodetz.seo_meta_text import description_for
from molodetz.utils.auth import not_found
router = APIRouter()
def _with_counts(people):
counts = post_counts_by_author([person["uid"] for person in people])
return [{**person, "post_count": counts.get(person["uid"], 0)} for person in people]
@router.get("")
async def people(request: Request):
persons = _with_counts(public_people())
seo = base_seo_context(request, "Mensen", description_for("mensen"), breadcrumbs=[("Molodetz", "/"), ("Mensen", "/mensen")])
return respond(request, "mensen.html", {"people": persons, "seo": seo}, model=PeopleOut)
@router.get("/{username}")
async def person(request: Request, username: str):
row = get_user_by_username(username)
if row is None:
not_found("Persoon niet gevonden")
person_row = _with_counts([row])[0]
own, _ = published_posts(limit=50, user_uid=row["uid"])
seo = base_seo_context(
request,
row["username"],
row.get("bio") or description_for("mensen"),
og_type="profile",
robots="index,follow" if person_row["post_count"] > 0 else "noindex,follow",
breadcrumbs=[("Molodetz", "/"), ("Mensen", "/mensen"), (row["username"], f"/mensen/{row['username']}")],
schemas=[profile_schema(request, row)],
)
return respond(request, "person.html", {"person": person_row, "posts": own, "seo": seo}, model=ProfileOut)
+26
View File
@@ -0,0 +1,26 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import list_notifications, mark_all_read, unread_count
from molodetz.responses import action_result, respond
from molodetz.schemas.account import NotificationsOut
from molodetz.seo import base_seo_context
from molodetz.utils.auth import require_user
router = APIRouter()
@router.get("")
async def notifications_page(request: Request):
user = require_user(request)
rows = list_notifications(user["uid"])
seo = base_seo_context(request, "Meldingen", robots="noindex,nofollow")
ctx = {"notifications": rows, "unread": unread_count(user["uid"]), "seo": seo}
return respond(request, "notifications.html", ctx, model=NotificationsOut)
@router.post("/read")
async def notifications_read(request: Request):
user = require_user(request)
mark_all_read(user["uid"])
return action_result(request, "/notifications")
+36
View File
@@ -0,0 +1,36 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from molodetz.constants import TOPICS
from molodetz.database import get_post_by_slug, get_user
from molodetz.rendering import content_preview, plain_title
from molodetz.responses import respond
from molodetz.schemas.posts import PostDetailOut
from molodetz.seo import article_schema, base_seo_context
from molodetz.utils.auth import get_current_user, is_admin, not_found
router = APIRouter()
@router.get("/{slug}")
async def post_detail(request: Request, slug: str):
post = get_post_by_slug(slug)
viewer = get_current_user(request)
if post is None or (post["status"] != "published" and not is_admin(viewer)):
not_found("Bericht niet gevonden")
if slug != post["slug"]:
return RedirectResponse(f"/posts/{post['slug']}", status_code=301)
author = get_user(post["user_uid"])
topic_title = TOPICS.get(post["topic"], post["topic"])
seo = base_seo_context(
request,
plain_title(post["title"]),
content_preview(post["body"], 160),
robots="index,follow" if post["status"] == "published" else "noindex,nofollow",
og_type="article",
breadcrumbs=[("Molodetz", "/"), (topic_title, f"/{post['topic']}"), (post["title"], f"/posts/{post['slug']}")],
schemas=[article_schema(request, post, author)],
)
ctx = {"post": post, "author": author, "topic_title": topic_title, "seo": seo}
return respond(request, "post.html", ctx, model=PostDetailOut)
+14
View File
@@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from molodetz import config
from molodetz.utils.presence import published_roster
router = APIRouter()
@router.get("/roster")
async def roster(request: Request):
tracked = published_roster()
return JSONResponse({"online": tracked[: config.PRESENCE_ONLINE_LIMIT], "tracked": len(tracked)})
+14
View File
@@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.responses import respond
from molodetz.schemas.account import PageOut
from molodetz.seo import base_seo_context
router = APIRouter()
@router.get("")
async def privacy(request: Request):
seo = base_seo_context(request, "Privacy", breadcrumbs=[("Molodetz", "/"), ("Privacy", "/privacy")])
return respond(request, "privacy.html", {"title": "Privacy", "seo": seo}, model=PageOut)
+39
View File
@@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import generate_uid, update_user
from molodetz.responses import action_result, respond
from molodetz.schemas.account import ApiKeyOut
from molodetz.seo import base_seo_context
from molodetz.utils.audit import record
from molodetz.utils.auth import invalidate_user_cache, require_user
from molodetz.utils.notifications import create_notification
router = APIRouter()
@router.get("/api-key")
async def api_key_page(request: Request):
user = require_user(request)
seo = base_seo_context(request, "API-sleutel", robots="noindex,nofollow")
return respond(request, "profile/api_key.html", {"api_key": user["api_key"], "seo": seo}, model=ApiKeyOut)
@router.post("/api-key/regenerate")
async def api_key_regenerate(request: Request):
user = require_user(request)
key = generate_uid()
update_user(user["uid"], api_key=key)
invalidate_user_cache(propagate=True)
record(request, "profile.apikey.regenerate", targets=[("user", user["uid"])], user=user)
create_notification(user["uid"], "auth.apikey", "Je API-sleutel is vernieuwd.", "/profile/api-key")
return action_result(request, "/profile/api-key", data={"api_key": key})
@router.post("/avatar/regenerate")
async def avatar_regenerate(request: Request):
user = require_user(request)
update_user(user["uid"], avatar_seed=generate_uid())
invalidate_user_cache(propagate=True)
record(request, "profile.avatar.regenerate", targets=[("user", user["uid"])], user=user)
return action_result(request, f"/mensen/{user['username']}")
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.routers._listing import topic_listing
router = APIRouter()
@router.get("")
async def rol(request: Request):
return topic_listing(request, "rol")
+71
View File
@@ -0,0 +1,71 @@
# retoor <retoor@molodetz.nl>
import logging
from xml.sax.saxutils import escape
import defusedxml.ElementTree as SafeET
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse, Response
from molodetz import config
from molodetz.cache import TTLCache
from molodetz.constants import TOPICS
from molodetz.database import db, public_people
from molodetz.docs_prose import DOCS_PAGES
from molodetz.seo import base_url
router = APIRouter()
logger = logging.getLogger(__name__)
_sitemap_cache = TTLCache(ttl=config.SITEMAP_TTL, max_size=4)
PER_TABLE_CAP = 5000
STATIC_PATHS = ("/", "/flyers", "/memes", "/mensen", "/binnen", "/voorwaarden", "/privacy", "/docs")
@router.get("/robots.txt")
async def robots(request: Request):
base = base_url(request)
body = "\n".join(
[
"User-agent: *",
"Disallow: /admin",
"Disallow: /auth",
"Disallow: /notifications",
"Disallow: /profile",
"Allow: /",
f"Sitemap: {base}/sitemap.xml",
"",
]
)
return PlainTextResponse(body)
def build_sitemap(base):
urls = [(path, None) for path in STATIC_PATHS]
urls += [(f"/{topic}", None) for topic in TOPICS]
if "posts" in db.tables:
rows = list(db["posts"].find(deleted_at=None, status="published", order_by=["-published_at"], _limit=PER_TABLE_CAP + 1))
if len(rows) > PER_TABLE_CAP:
logger.warning("sitemap truncated posts at %d", PER_TABLE_CAP)
rows = rows[:PER_TABLE_CAP]
urls += [(f"/posts/{row['slug']}", row.get("updated_at")) for row in rows]
urls += [(f"/mensen/{person['username']}", None) for person in public_people()[:PER_TABLE_CAP]]
urls += [(f"/docs/{page['slug']}", None) for page in DOCS_PAGES if not page.get("admin")]
parts = ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for path, modified in urls:
entry = f"<url><loc>{escape(base + path)}</loc>"
if modified:
entry += f"<lastmod>{escape(modified[:10])}</lastmod>"
parts.append(entry + "</url>")
parts.append("</urlset>")
xml = "\n".join(parts)
SafeET.fromstring(xml.encode("utf-8"))
return xml
@router.get("/sitemap.xml")
async def sitemap(request: Request):
base = base_url(request)
xml = _sitemap_cache.get(base)
if xml is None:
xml = build_sitemap(base)
_sitemap_cache.set(base, xml)
return Response(xml, media_type="application/xml")
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.routers._listing import topic_listing
router = APIRouter()
@router.get("")
async def standaard(request: Request):
return topic_listing(request, "standaard")
+30
View File
@@ -0,0 +1,30 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import get_setting, update_user
from molodetz.responses import action_result, respond
from molodetz.schemas.account import PageOut
from molodetz.seo import base_seo_context
from molodetz.utils.audit import record
from molodetz.utils.auth import invalidate_user_cache, require_user
from molodetz.utils.request_meta import safe_next
router = APIRouter()
@router.get("")
async def terms(request: Request):
seo = base_seo_context(request, "Voorwaarden", breadcrumbs=[("Molodetz", "/"), ("Voorwaarden", "/voorwaarden")])
ctx = {"title": "Voorwaarden", "terms_version": get_setting("terms_version", "1"), "next": safe_next(request.query_params.get("next"), "/"), "seo": seo}
return respond(request, "voorwaarden.html", ctx, model=PageOut)
@router.post("/accept")
async def accept_terms(request: Request):
user = require_user(request)
version = get_setting("terms_version", "1")
update_user(user["uid"], terms_version=version)
invalidate_user_cache(propagate=True)
record(request, "auth.terms.accept", payload={"version": version}, user=user)
form = await request.form() if not request.headers.get("content-type", "").startswith("application/json") else {}
return action_result(request, safe_next(form.get("next") if form else None, "/"))