Molodetz blogrol volgens DPP-template
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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)})
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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]})
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user