Translate the whole site to English: English routes with 301 redirects from the Dutch paths, English UI, docs, seeds and captions, one-time live content migration

This commit is contained in:
2026-10-05 11:25:41 +02:00
parent bbdecdc95b
commit ff547e1677
153 changed files with 1095 additions and 774 deletions
+20 -19
View File
@@ -1,27 +1,28 @@
<!-- retoor <retoor@molodetz.nl> -->
# Architectuur
# Architecture
## Lagen
## Layers
- `molodetz/main.py`: FastAPI-object (`/swagger`, `/openapi.json`), lifespan (init-lock, `init_db`, bootstrap, services met service-lock), statische mounts (uploads, versioned `/static/v<STATIC_VERSION>/`, fallback), middleware in templatevolgorde, exception handlers, routers.
- `molodetz/config.py`: enige plek voor `load_dotenv`, paden, env-bindingen met prefix `MOLODETZ_`, `APP_VERSION` via `tomllib`, `BOOT_ID`, `STATIC_VERSION`.
- `molodetz/database/`: synchrone `dataset`-handle (SQLAlchemy NullPool, WAL-PRAGMA's), per-tabel modules, soft delete met registries, `init_db` met indexen en `ANALYZE`. Geen async driver en geen threadpool rond databasecalls: de synchrone laag wordt direct in async handlers aangeroepen (sectie 7.1). Alleen CPU- en bestandswerk (PBKDF2, archiefbouw, DNS) gaat via `asyncio.to_thread`.
- `molodetz/routers/`: een module per oppervlak. Elke HTML-route geeft JSON bij `Accept: application/json` via `responses.respond` en een Pydantic-uitvoerschema uit `schemas/`.
- `molodetz/templating.py`: de enige `Jinja2Templates`. Globals `static_url`, `local_dt`, `avatar_url`, `render_content` en meer.
- `molodetz/rendering.py`: server-side markdown (mistune, escape aan), emoji, URL-allowlist, streepjesnormalisatie. Gepubliceerde inhoud wordt altijd server-side gerenderd.
- `molodetz/stealth.py`: enige fabriek voor uitgaande HTTP-clients (httpx met curl_cffi-transport, `net_guard` SSRF-bescherming). `link_check.py` gebruikt hem voor de repo-linkcontrole bij aanmeldingen.
- `molodetz/services/`: achtergrondservices (presence, housekeeping, backup) onder een `ServiceManager`, zichtbaar en bestuurbaar via `/admin/services`.
- `molodetz/gallery.py`: flyers en memes uit `static/media/`, Pillow-thumbnails (webp) als blobs onder `data/uploads`, perceptuele hash (imagehash) tegen duplicaten.
- `molodetz/cli/`: argparse-CLI `molodetz`.
- `molodetz/static/js/`: ES6-modules zonder bundler. `Application.js` laadt `components/index.js` (custom elements in light DOM) en zet singletons op `window.app`. Vendor: marked, DOMPurify, highlight.js als ES-modules in `static/vendor/`.
- `molodetz/static/css/`: handgeschreven, tokens in `variables.css`, breakpoints exact 360/480/768/1024.
- `molodetz/main.py`: FastAPI object (`/swagger`, `/openapi.json`), lifespan (init lock, `init_db`, bootstrap, services with service lock), static mounts (uploads, versioned `/static/v<STATIC_VERSION>/`, fallback), middleware in template order, exception handlers, routers.
- `molodetz/config.py`: the only place for `load_dotenv`, paths, env bindings with prefix `MOLODETZ_`, `APP_VERSION` via `tomllib`, `BOOT_ID`, `STATIC_VERSION`.
- `molodetz/database/`: synchronous `dataset` handle (SQLAlchemy NullPool, WAL PRAGMAs), per-table modules, soft delete with registries, `init_db` with indexes and `ANALYZE`. No async driver and no threadpool around database calls: the synchronous layer is called directly from async handlers (section 7.1). Only CPU and file work (PBKDF2, archive building, DNS) goes through `asyncio.to_thread`.
- `molodetz/routers/`: one module per surface. Every HTML route returns JSON on `Accept: application/json` through `responses.respond` and a Pydantic output schema from `schemas/`. `routers/legacy.py` answers the old Dutch paths with 301 (308 for POST), hidden from the schema.
- `molodetz/templating.py`: the only `Jinja2Templates`. Globals `static_url`, `local_dt`, `avatar_url`, `render_content` and more.
- `molodetz/rendering.py`: server-side markdown (mistune, escaping on), emoji, URL allowlist, dash normalisation. Published content is always rendered on the server.
- `molodetz/stealth.py`: the only factory for outbound HTTP clients (httpx with curl_cffi transport, `net_guard` SSRF protection). `link_check.py` uses it for the repo link check on join requests.
- `molodetz/services/`: background services (presence, housekeeping, backup) under a `ServiceManager`, visible and controllable at `/admin/services`.
- `molodetz/gallery.py`: flyers and memes from `static/media/`, Pillow thumbnails (webp) as blobs under `data/uploads`, perceptual hash (imagehash) against duplicates.
- `molodetz/cli/`: argparse CLI `molodetz`.
- `molodetz/static/js/`: ES6 modules without a bundler. `Application.js` loads `components/index.js` (custom elements in light DOM) and puts singletons on `window.app`. Vendor: marked, DOMPurify, highlight.js as ES modules in `static/vendor/`.
- `molodetz/static/css/`: handwritten, tokens in `variables.css`, breakpoints exactly 360/480/768/1024.
## Auth
Sessiecookie (HttpOnly, SameSite=Lax, Secure achter TLS), rijen in `sessions`. Daarnaast `X-API-KEY`, `Authorization: Bearer <api key>` en Basic voor scripts. Wachtwoorden met passlib PBKDF2-SHA256. Geen JWT, geen OAuth. De eerste gebruiker is de beheerder uit `.env`; er is geen publieke registratie.
Session cookie (HttpOnly, SameSite=Lax, Secure behind TLS), rows in `sessions`. Also `X-API-KEY`, `Authorization: Bearer <api key>` and Basic for scripts. Passwords with passlib PBKDF2-SHA256. No JWT, no OAuth. The first user is the administrator from `.env`; there is no public registration.
## Datastromen
## Data flows
- Schrijven: beheerder -> `/admin/posts` -> `content.create_post` / `edit_post` -> SQLite -> server-render bij lezen (lru-cache op tekst).
- Aanmelden: bezoeker -> `POST /binnen` (honeypot, rate limit) -> `join_requests` -> melding aan beheerders -> `/admin/joins`.
- Galerij: start -> `sync_gallery` -> `media`-tabel + thumbnails -> `/flyers`, `/memes` met lightbox.
- Writing: administrator -> `/admin/posts` -> `content.create_post` / `edit_post` -> SQLite -> server render on read (lru cache on text).
- Joining: visitor -> `POST /join` (honeypot, rate limit) -> `join_requests` -> notification to administrators -> `/admin/joins`.
- Gallery: startup -> `sync_gallery` -> `media` table + thumbnails -> `/flyers`, `/memes` with lightbox.
- Language: startup -> `bootstrap.migrate_to_english` (once, guarded by setting `content_language`) -> seeded posts, topics, tagline, maintenance message, notifications and admin bio translated in place. Old post slugs resolve through their uid tail and redirect with 301.
+1 -1
View File
@@ -100,7 +100,7 @@ Legend: MET, PARTIAL (works, but narrower than the template text), NOT MET (miss
1. Docker, nginx sidecar, `instance`, `docker-*` and `deploy` make targets: not shipped. Section 28.2 is not selected (bare metal only), and deploy would push to a remote, which was not requested.
2. Assistant face (8.2 item 3) and action catalogue: skipped (section 16 not selected).
3. Presence is the CORE subset and works by polling `/presence/roster` (20 s) plus server-side touch throttling. There is no WebSocket relay; the rest of section 17 is deferred in `SELECTION.md`.
4. There is no public signup. The only account is the env-bootstrapped admin (`retoor`), so the birth-date/terms signup flow and its shared e2e helper do not exist. Terms acceptance exists (`/voorwaarden/accept`, `TermsGate.js`).
4. There is no public signup. The only account is the env-bootstrapped admin (`retoor`), so the birth-date/terms signup flow and its shared e2e helper do not exist. Terms acceptance exists (`/terms/accept`, `TermsGate.js`).
5. Votes, reports, comments, maturity partials and the report dialog are absent (sections 18 and 20.2 not selected). The JobPoller, OptimisticAction, StickyScrollPane and emoji picker front-end modules are not built, because no selected feature needs them.
6. Mutation routes parse bodies with a shared `json_or_form(request, Model)` helper (form or JSON into one Pydantic model) instead of `Annotated[Model, Form()]`. Validation is equivalent; the signature style differs.
7. The repo-link check on join requests runs inline in the admin request, not as a durable job (section 14.4 not selected).
+13 -13
View File
@@ -1,42 +1,42 @@
<!-- retoor <retoor@molodetz.nl> -->
# Molodetz
Blogrol van de Molodetz-community. Rustig, serif, licht provocerend. Berichten zijn markdown in SQLite, geschreven door retoor via een beheeromgeving. Flyers en memes zijn galerijen. Binnen is een echt aanmeldformulier ("Ik schrijf mee").
Blog roll of the Molodetz community. Quiet, serif, slightly provocative. Posts are markdown in SQLite, written by retoor through an admin area. Flyers and memes are galleries. Join is a real sign-up form ("I'm in"). The whole site is in English; the old Dutch paths (`/rol`, `/standaard`, `/mensen`, `/binnen`, `/voorwaarden`) redirect with 301.
Gebouwd volgens de bevroren DPP-template (`/workspace/dpp/dptemplate.md`). Zie `SELECTION.md` voor de modulekeuze en `COMPLIANCE.md` voor de toetsing.
Built to the frozen DPP template (`/workspace/dpp/dptemplate.md`). See `SELECTION.md` for the module choice and `COMPLIANCE.md` for the review.
## Starten
## Running
```
make install # venv in .venv, pip install -e ".[dev]", Chromium, git hooks
cp .env.example .env # alleen als er nog geen .env is; chmod 600 .env
cp .env.example .env # only when there is no .env yet; chmod 600 .env
make dev # http://127.0.0.1:8088, reload
make prod # workers, gepinde static-versie
make prod # workers, pinned static version
```
Zonder make:
Without make:
```
.venv/bin/python -m uvicorn molodetz.main:app --host 127.0.0.1 --port 8088
```
De eerste start maakt de beheerder aan uit `MOLODETZ_ADMIN_USERNAME` / `MOLODETZ_ADMIN_EMAIL` / `MOLODETZ_ADMIN_PASSWORD` in `.env`, zaait de placeholderberichten eenmalig en synchroniseert de galerij. Inloggen via `/auth/login`, beheer via `/admin`.
The first start creates the administrator from `MOLODETZ_ADMIN_USERNAME` / `MOLODETZ_ADMIN_EMAIL` / `MOLODETZ_ADMIN_PASSWORD` in `.env`, seeds the placeholder posts once and syncs the gallery. A one-time migration (`system.migrate.english`) translates content seeded by older Dutch versions. Log in at `/auth/login`, admin at `/admin`.
## Testen
## Testing
```
make test # unit + api + e2e (Playwright, headless)
make test-headed # zichtbaar met slow motion
make check-ui # zes statische poorten + em-dash + json_error-volgorde
make preflight # import, pyflakes, JS-syntax, sqlglot-lint, check-ui
make test-headed # visible with slow motion
make check-ui # six static gates + em-dash + json_error order
make preflight # import, pyflakes, JS syntax, sqlglot lint, check-ui
make coverage
make locust-headless
```
## CLI
`molodetz --help`: rollen, API-sleutels, posts list/import (.md en .pdf via pypdf), aanmeldingen, back-ups, prunes, sql-lint, emoji regenerate.
`molodetz --help`: roles, API keys, posts list/import (.md and .pdf via pypdf), join requests, backups, prunes, sql-lint, emoji regenerate.
## Data
Alles onder `data/` (database `molodetz.db`, uploads, back-ups, sleutels, locks). `.env` en `data/` staan in `.gitignore`.
Everything lives under `data/` (database `molodetz.db`, uploads, backups, keys, locks). `.env` and `data/` are in `.gitignore`.
+1 -1
View File
@@ -6,7 +6,7 @@ CLI: `molodetz`
Env prefix: `MOLODETZ`
Template: `/workspace/dpp/dptemplate.md` (frozen, section 0.1)
Calm elite serif community blog roll for retoor. Dutch. Posts,
Calm elite serif community blog roll for retoor. English. Posts,
flyers, memes, people, join intent. Admin authoring via sessions.
This file is the only place this project narrows the template
+2 -2
View File
@@ -8,9 +8,9 @@ if [[ "${1:-}" == "--dry-run" ]]; then
fi
PY=.venv/bin/python
DATA="${MOLODETZ_DATA_DIR:-data}"
echo "Schijfgebruik voor:"; du -sh "$DATA" 2>/dev/null || true
echo "Disk usage before:"; du -sh "$DATA" 2>/dev/null || true
$PY -m molodetz.cli sessions prune $DRY
$PY -m molodetz.cli audit prune $DRY
$PY -m molodetz.cli backups prune $DRY
$PY -m molodetz.cli system prune $DRY
echo "Schijfgebruik na:"; du -sh "$DATA" 2>/dev/null || true
echo "Disk usage after:"; du -sh "$DATA" 2>/dev/null || true
+4 -3
View File
@@ -8,7 +8,7 @@ Every audited action goes through `molodetz/utils/audit.py` (`record` for reques
| `auth.login` | auth | `routers/auth.py` login | ok |
| `auth.login.failed` | auth | `routers/auth.py` login | denied |
| `auth.logout` | auth | `routers/auth.py` logout | ok |
| `auth.terms.accept` | auth | `routers/voorwaarden.py` | ok |
| `auth.terms.accept` | auth | `routers/terms.py` | ok |
| `admin.bootstrap` | admin | `bootstrap.py` first admin from env | ok |
| `admin.settings.save` | admin | `routers/admin/settings.py` | ok |
| `admin.user.role` | admin | `routers/admin/users.py`, CLI `role set` | ok, denied |
@@ -20,8 +20,9 @@ Every audited action goes through `molodetz/utils/audit.py` (`record` for reques
| `post.delete` | content | `content.delete_post` (soft delete) | ok |
| `post.delete.denied` | content | `routers/admin/posts.py` | denied |
| `post.seed` | content | `bootstrap.py` placeholder seed | ok |
| `join.request.create` | community | `routers/binnen.py` | ok |
| `join.request.honeypot` | community | `routers/binnen.py` honeypot hit | denied |
| `system.migrate.english` | system | `bootstrap.migrate_to_english` one-time content translation | ok |
| `join.request.create` | community | `routers/join.py` | ok |
| `join.request.honeypot` | community | `routers/join.py` honeypot hit | denied |
| `join.request.status` | community | `routers/admin/joins.py` | ok |
| `join.request.check` | community | `routers/admin/joins.py` repo-link check | ok, error |
| `security.authorization.denied` | security | `utils/auth.py` guards | denied |
+5 -5
View File
@@ -3,14 +3,14 @@ import random
from locust import HttpUser, between, task
PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/binnen", "/docs"]
PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/join", "/docs"]
class Reader(HttpUser):
wait_time = between(0.5, 2.0)
def on_start(self):
response = self.client.get("/rol", headers={"Accept": "application/json"}, name="/rol [json]")
response = self.client.get("/roll", headers={"Accept": "application/json"}, name="/roll [json]")
self.slugs = [post["slug"] for post in response.json().get("posts", [])] if response.ok else []
@task(6)
@@ -28,12 +28,12 @@ class Reader(HttpUser):
@task(1)
def json_face(self):
self.client.get("/standaard", headers={"Accept": "application/json"}, name="/standaard [json]")
self.client.get("/standard", headers={"Accept": "application/json"}, name="/standard [json]")
@task(1)
def join_form(self):
self.client.post(
"/binnen",
"/join",
data={"name": f"Load {random.randint(1, 10**6)}", "contact": "load@example.invalid", "repo_url": "", "message": "", "website": ""},
name="/binnen [post]",
name="/join [post]",
)
+48 -3
View File
@@ -2,15 +2,22 @@
import logging
from molodetz import config
from molodetz.content import seed_placeholder_posts
from molodetz.database import count_users, create_user, get_setting, get_user_by_username, set_setting
from molodetz.content import LEGACY_SEED_TITLES, seed_placeholder_posts, translate_seeded_posts
from molodetz.database import count_users, create_user, db, get_setting, get_user_by_username, set_setting
from molodetz.gallery import sync_gallery
from molodetz.utils.audit import record_system
from molodetz.utils.auth import hash_password
logger = logging.getLogger(__name__)
ADMIN_BIO = "Begint de rol. Schrijft over het werk, het afmaken en het oordeel."
ADMIN_BIO = "Starts the roll. Writes about the work, finishing it, and judgement."
LEGACY_ADMIN_BIO = "Begint de rol. Schrijft over het werk, het afmaken en het oordeel."
LEGACY_SETTINGS = {
"site_tagline": ("De kring, niet de reclame.", "The circle, not the commercial."),
"maintenance_message": ("Molodetz is even dicht voor onderhoud.", "Molodetz is closed for maintenance. Back shortly."),
}
LEGACY_NOTIFICATION_SUFFIXES = ((" wil meeschrijven", " wants to write along"),)
LEGACY_NOTIFICATION_EXACT = {"Je API-sleutel is vernieuwd.": "Your API key was renewed."}
def bootstrap_admin():
@@ -26,9 +33,47 @@ def bootstrap_admin():
def bootstrap_content():
admin = bootstrap_admin()
migrate_to_english()
if admin and get_setting("seed_posts_done", "0") != "1":
created = seed_placeholder_posts(admin)
set_setting("seed_posts_done", "1")
record_system("post.seed", payload={"count": len(created)})
synced = sync_gallery()
logger.info("gallery synced: %d items", synced)
def _translate_notification(message):
if message in LEGACY_NOTIFICATION_EXACT:
return LEGACY_NOTIFICATION_EXACT[message]
if message.startswith("Gepubliceerd: "):
title = message[len("Gepubliceerd: "):]
return "Published: " + LEGACY_SEED_TITLES.get(title, title)
if message.startswith("Back-up "):
return "Backup " + message[len("Back-up "):].replace(" klaar", " finished").replace(" mislukt", " failed")
for dutch, english in LEGACY_NOTIFICATION_SUFFIXES:
if message.endswith(dutch):
return message[: -len(dutch)] + english
return message
def migrate_to_english():
if get_setting("content_language", "nl") == "en":
return 0
posts = translate_seeded_posts()
for key, (dutch, english) in LEGACY_SETTINGS.items():
if get_setting(key) == dutch or ("site_settings" in db.tables and (db["site_settings"].find_one(key=key) or {}).get("value") == dutch):
set_setting(key, english)
notes = 0
if "notifications" in db.tables:
for row in list(db["notifications"].all()):
translated = _translate_notification(row.get("message") or "")
if translated != row.get("message"):
db["notifications"].update({"uid": row["uid"], "message": translated}, ["uid"])
notes += 1
if "users" in db.tables:
for row in list(db["users"].find(bio=LEGACY_ADMIN_BIO)):
db["users"].update({"uid": row["uid"], "bio": ADMIN_BIO}, ["uid"])
set_setting("content_language", "en")
record_system("system.migrate.english", payload={"posts": posts, "notifications": notes})
logger.info("content migrated to english: %d posts, %d notifications", posts, notes)
return posts
+12 -12
View File
@@ -36,7 +36,7 @@ def prepare():
def _user(username):
user = get_user_by_username(username)
if user is None:
raise SystemExit(f"Onbekende gebruiker: {username}")
raise SystemExit(f"Unknown user: {username}")
return user
@@ -69,7 +69,7 @@ def apikey_backfill(args):
if not user.get("api_key"):
update_user(user["uid"], api_key=generate_uid())
changed += 1
print(f"{changed} sleutels aangevuld")
print(f"{changed} keys filled in")
def posts_list(args):
@@ -89,12 +89,12 @@ def read_source(path):
def posts_import(args):
path = Path(args.path)
if not path.is_file():
raise SystemExit(f"Bestand niet gevonden: {path}")
raise SystemExit(f"File not found: {path}")
body = read_source(path)
title = args.title or path.stem.replace("-", " ").replace("_", " ").strip().capitalize()
author = bootstrap_admin() or _user(config.ADMIN_USERNAME)
post = create_post(author, title, body, args.topic, "published" if args.publish else "draft")
print(f"Geimporteerd: {post['uid']} {post['title']}")
print(f"Imported: {post['uid']} {post['title']}")
def joins_list(args):
@@ -110,43 +110,43 @@ def backups_list(args):
def backups_run(args):
row = enqueue_backup(args.target, None)
ok = process_backup(row, args.codec)
print("klaar" if ok else "mislukt")
print("done" if ok else "failed")
return 0 if ok else 1
def backups_prune(args):
count = prune_backups(args.keep, dry_run=args.dry_run)
print(f"{count} archieven {'zouden worden verwijderd' if args.dry_run else 'verwijderd'}")
print(f"{count} archives {'would be removed' if args.dry_run else 'removed'}")
def backups_clear(args):
count = clear_backups(dry_run=args.dry_run)
print(f"{count} archieven {'zouden worden verwijderd' if args.dry_run else 'verwijderd'}")
print(f"{count} archives {'would be removed' if args.dry_run else 'removed'}")
def sessions_prune(args):
print(f"{prune_expired_sessions(dry_run=args.dry_run)} verlopen sessies")
print(f"{prune_expired_sessions(dry_run=args.dry_run)} expired sessions")
def audit_prune(args):
cutoff = (datetime.now(timezone.utc) - timedelta(days=args.days)).isoformat()
print(f"{prune_audit(cutoff, dry_run=args.dry_run)} auditregels ouder dan {args.days} dagen")
print(f"{prune_audit(cutoff, dry_run=args.dry_run)} audit rows older than {args.days} days")
def system_prune(args):
orphans = orphan_blobs()
for path in orphans:
print(("zou verwijderen: " if args.dry_run else "verwijderd: ") + str(path))
print(("would remove: " if args.dry_run else "removed: ") + str(path))
if not args.dry_run:
path.unlink(missing_ok=True)
print(f"{len(orphans)} wees-blobs")
print(f"{len(orphans)} orphan blobs")
def system_sql_lint(args):
problems = lint()
for problem in problems:
print(problem)
print(f"{len(problems)} problemen")
print(f"{len(problems)} problems")
return 1 if problems else 0
+14 -14
View File
@@ -6,10 +6,10 @@ from molodetz.cli import commands
def build_parser():
parser = argparse.ArgumentParser(prog="molodetz", description="Beheer-CLI voor Molodetz.")
parser = argparse.ArgumentParser(prog="molodetz", description="Admin CLI for Molodetz.")
groups = parser.add_subparsers(dest="group", required=True)
role = groups.add_parser("role", help="Rollen lezen en zetten").add_subparsers(dest="action", required=True)
role = groups.add_parser("role", help="Read and set roles").add_subparsers(dest="action", required=True)
role_get = role.add_parser("get")
role_get.add_argument("username")
role_get.set_defaults(handler=commands.role_get)
@@ -18,7 +18,7 @@ def build_parser():
role_set.add_argument("role", choices=["Member", "Admin"])
role_set.set_defaults(handler=commands.role_set)
apikey = groups.add_parser("apikey", help="API-sleutels").add_subparsers(dest="action", required=True)
apikey = groups.add_parser("apikey", help="API keys").add_subparsers(dest="action", required=True)
key_get = apikey.add_parser("get")
key_get.add_argument("username")
key_get.set_defaults(handler=commands.apikey_get)
@@ -27,19 +27,19 @@ def build_parser():
key_reset.set_defaults(handler=commands.apikey_reset)
apikey.add_parser("backfill").set_defaults(handler=commands.apikey_backfill)
posts = groups.add_parser("posts", help="Berichten").add_subparsers(dest="action", required=True)
posts = groups.add_parser("posts", help="Posts").add_subparsers(dest="action", required=True)
posts.add_parser("list").set_defaults(handler=commands.posts_list)
posts_import = posts.add_parser("import", help="Importeer .md of .pdf als concept")
posts_import = posts.add_parser("import", help="Import .md or .pdf as a draft")
posts_import.add_argument("path")
posts_import.add_argument("--topic", default="rol", choices=["rol", "standaard"])
posts_import.add_argument("--topic", default="roll", choices=["roll", "standard"])
posts_import.add_argument("--title")
posts_import.add_argument("--publish", action="store_true")
posts_import.set_defaults(handler=commands.posts_import)
joins = groups.add_parser("joins", help="Aanmeldingen").add_subparsers(dest="action", required=True)
joins = groups.add_parser("joins", help="Join requests").add_subparsers(dest="action", required=True)
joins.add_parser("list").set_defaults(handler=commands.joins_list)
backups = groups.add_parser("backups", help="Back-ups").add_subparsers(dest="action", required=True)
backups = groups.add_parser("backups", help="Backups").add_subparsers(dest="action", required=True)
backups.add_parser("list").set_defaults(handler=commands.backups_list)
backup_run = backups.add_parser("run")
backup_run.add_argument("--target", default="database", choices=["database", "uploads", "keys", "full"])
@@ -53,24 +53,24 @@ def build_parser():
backup_clear.add_argument("--dry-run", action="store_true")
backup_clear.set_defaults(handler=commands.backups_clear)
sessions = groups.add_parser("sessions", help="Sessies").add_subparsers(dest="action", required=True)
sessions = groups.add_parser("sessions", help="Sessions").add_subparsers(dest="action", required=True)
sessions_prune = sessions.add_parser("prune")
sessions_prune.add_argument("--dry-run", action="store_true")
sessions_prune.set_defaults(handler=commands.sessions_prune)
audit = groups.add_parser("audit", help="Auditlog").add_subparsers(dest="action", required=True)
audit = groups.add_parser("audit", help="Audit log").add_subparsers(dest="action", required=True)
audit_prune = audit.add_parser("prune")
audit_prune.add_argument("--days", type=int, default=365)
audit_prune.add_argument("--dry-run", action="store_true")
audit_prune.set_defaults(handler=commands.audit_prune)
system = groups.add_parser("system", help="Systeemonderhoud").add_subparsers(dest="action", required=True)
system_prune = system.add_parser("prune", help="Verwijder wees-blobs")
system = groups.add_parser("system", help="System maintenance").add_subparsers(dest="action", required=True)
system_prune = system.add_parser("prune", help="Remove orphan blobs")
system_prune.add_argument("--dry-run", action="store_true")
system_prune.set_defaults(handler=commands.system_prune)
system.add_parser("sql-lint", help="Parse alle SQL-literals met sqlglot").set_defaults(handler=commands.system_sql_lint)
system.add_parser("sql-lint", help="Parse all SQL literals with sqlglot").set_defaults(handler=commands.system_sql_lint)
emoji = groups.add_parser("emoji", help="Emoji-kaart").add_subparsers(dest="action", required=True)
emoji = groups.add_parser("emoji", help="Emoji map").add_subparsers(dest="action", required=True)
emoji.add_parser("regenerate").set_defaults(handler=commands.emoji_regenerate)
return parser
+16 -11
View File
@@ -10,8 +10,13 @@ ROLE_MEMBER = "Member"
ROLES = (ROLE_ADMIN, ROLE_MEMBER)
TOPICS = {
"rol": "Rol",
"standaard": "Standaard",
"roll": "Roll",
"standard": "Standard",
}
LEGACY_TOPICS = {
"rol": "roll",
"standaard": "standard",
}
POST_STATUSES = ("draft", "published")
@@ -44,12 +49,12 @@ UNREPORTABLE_TABLES = {
REPORTABLE_TARGETS: frozenset[str] = frozenset()
NOTIFICATION_TYPES = {
"join.request": "Nieuwe aanmelding via Binnen",
"post.published": "Bericht gepubliceerd",
"backup.finished": "Back-up afgerond",
"backup.failed": "Back-up mislukt",
"auth.apikey": "API-sleutel vernieuwd",
"system.maintenance": "Onderhoudsmodus gewijzigd",
"join.request": "New request via Join",
"post.published": "Post published",
"backup.finished": "Backup finished",
"backup.failed": "Backup failed",
"auth.apikey": "API key renewed",
"system.maintenance": "Maintenance mode changed",
}
CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile")
@@ -69,7 +74,7 @@ TERMS_EXEMPT_PREFIXES = (
"/avatar",
"/auth",
"/docs",
"/voorwaarden",
"/terms",
"/privacy",
)
@@ -81,10 +86,10 @@ GZIP_SKIP_PREFIXES: tuple[str, ...] = ()
SETTINGS_DEFAULTS = {
"site_title": "Molodetz",
"site_tagline": "De kring, niet de reclame.",
"site_tagline": "The circle, not the commercial.",
"site_intro": SITE_INTRO_SEED,
"maintenance_mode": "0",
"maintenance_message": "Molodetz is even dicht voor onderhoud.",
"maintenance_message": "Molodetz is closed for maintenance. Back shortly.",
"rate_limit_per_minute": "60",
"rate_limit_window_seconds": "60",
"session_max_age_days": "7",
+56 -12
View File
@@ -6,13 +6,15 @@ from molodetz.database import (
now_iso,
db,
)
from molodetz.constants import LEGACY_TOPICS
from molodetz.database.core import make_combined_slug
from molodetz.utils.audit import record
from molodetz.utils.notifications import create_notification
PLACEHOLDER_NOTE = (
"\n\n> Placeholder. Dit is overgenomen uit de oude Molodetz-pagina. "
"retoor vervangt dit later door zijn eigen verhaal."
"\n\n> Placeholder. Carried over from the old Molodetz page. "
"retoor will replace it with his own story."
)
@@ -22,7 +24,7 @@ def create_post(user, title, body, topic, status="draft", is_placeholder=False,
row = insert_post(user["uid"], title, body, topic, status=status, is_placeholder=is_placeholder)
record(request, "post.create", payload={"uid": row["uid"], "status": status}, targets=[("post", row["uid"])], user=user)
if status == "published":
create_notification(user["uid"], "post.published", f"Gepubliceerd: {title}", f"/posts/{row['slug']}")
create_notification(user["uid"], "post.published", f"Published: {title}", f"/posts/{row['slug']}")
return row
@@ -55,18 +57,60 @@ def delete_post(post, actor, request=None):
return stamp
SEED_POSTS = [
(
"standard",
"Thirty-one choices. You have zero.",
"Python 3.12. FastAPI. Jinja2. SQLite. No JWT, because that is a hobby. No JavaScript framework, because that is a catalogue. The document says MUST 107 times. Whoever turns that into \"it depends\" has no taste. They have a roadmap made of other people's tutorials.",
),
("roll", "A pass is not an ambition.", "Settle for good enough and you build good enough. Molodetz starts where that stops."),
("roll", "Finished is a feature.", "A project that stays open is an opinion. A project that is finished is a fact."),
("roll", "The assistant is not a senior.", "AI speeds up the work. It does not replace judgement. Confuse the two and you hand in a chat log."),
("roll", "No course.", "Knowledge travels here from people to people. Not through an ebook promising you will become someone else over a weekend."),
]
LEGACY_SEED_TITLES = {
"Eenendertig keuzes. Jij hebt er nul.": "Thirty-one choices. You have zero.",
"Een zes is geen ambitie.": "A pass is not an ambition.",
"Af is een eigenschap.": "Finished is a feature.",
"De assistent is geen senior.": "The assistant is not a senior.",
"Geen cursus.": "No course.",
}
def seed_placeholder_posts(admin):
seeds = [
("standaard", "Eenendertig keuzes. Jij hebt er nul.", "Python 3.12. FastAPI. Jinja2. SQLite. Geen JWT, want dat is een hobby. Geen JavaScript-framework, want dat is een catalogus. Het document zegt 107 keer MUST. Wie daar \"het hangt ervan af\" van maakt, heeft geen smaak. Die heeft een roadmap van andermans tutorials."),
("rol", "Een zes is geen ambitie.", "Wie tevreden is met voldoende, bouwt voldoende. Molodetz begint waar dat ophoudt."),
("rol", "Af is een eigenschap.", "Een project dat open blijft, is een mening. Een project dat af is, is een feit."),
("rol", "De assistent is geen senior.", "AI versnelt het werk. Het vervangt het oordeel niet. Wie dat door elkaar haalt, levert een chatlog in."),
("rol", "Geen cursus.", "Kennis gaat hier van mensen naar mensen. Niet via een ebook dat belooft dat je in een weekend iemand anders wordt."),
]
created = []
for topic, title, body in seeds:
for topic, title, body in SEED_POSTS:
existing = db["posts"].find_one(title=title, deleted_at=None) if "posts" in db.tables else None
if existing:
continue
created.append(create_post(admin, title, body, topic, status="published", is_placeholder=topic == "rol"))
created.append(create_post(admin, title, body, topic, status="published", is_placeholder=topic == "roll"))
return created
def translate_seeded_posts():
if "posts" not in db.tables:
return 0
seeds = {title: (topic, body) for topic, title, body in SEED_POSTS}
table = db["posts"]
for old, new in LEGACY_TOPICS.items():
for row in list(table.find(topic=old)):
table.update({"uid": row["uid"], "topic": new}, ["uid"])
changed = 0
for dutch, english in LEGACY_SEED_TITLES.items():
topic, body = seeds[english]
for row in list(table.find(title=dutch)):
body_out = body.rstrip() + PLACEHOLDER_NOTE if row.get("is_placeholder") else body
table.update(
{
"uid": row["uid"],
"title": english,
"body": body_out,
"topic": topic,
"slug": make_combined_slug(english, row["uid"]),
"updated_at": now_iso(),
},
["uid"],
)
changed += 1
return changed
+2
View File
@@ -35,6 +35,7 @@ from molodetz.database.posts import (
count_published,
get_post,
get_post_by_slug,
get_post_by_slug_tail,
insert_post,
post_counts_by_author,
published_posts,
@@ -112,6 +113,7 @@ __all__ = [
"get_join_request",
"get_post",
"get_post_by_slug",
"get_post_by_slug_tail",
"get_service_state",
"get_session",
"get_setting",
+1 -1
View File
@@ -41,7 +41,7 @@ TABLE_EXAMPLES = {
"slug": "x",
"title": "x",
"body": "x",
"topic": "rol",
"topic": "roll",
"status": "draft",
"is_placeholder": False,
"user_uid": "x",
+10
View File
@@ -40,6 +40,16 @@ def get_post_by_slug(slug):
return resolve_by_slug("posts", slug)
def get_post_by_slug_tail(slug):
tail = (slug or "").rsplit("-", 1)[-1]
if "posts" not in db.tables or len(tail) != 12 or not all(c in "0123456789abcdef" for c in tail):
return None
for row in db["posts"].find(deleted_at=None):
if row["uid"].replace("-", "").endswith(tail):
return row
return None
def published_posts(topic=None, before=None, limit=25, user_uid=None):
filters = {"status": "published"}
if topic:
+2 -2
View File
@@ -1,10 +1,10 @@
# retoor <retoor@molodetz.nl>
from molodetz.docs_api import account, admin, community, content
from molodetz.docs_api import account, admin, content, join
from molodetz.docs_api.registry import endpoint, field, substitute
def ordered_groups():
return [content.GROUP, community.GROUP, account.GROUP, admin.GROUP]
return [content.GROUP, join.GROUP, account.GROUP, admin.GROUP]
def all_endpoints():
+12 -12
View File
@@ -4,26 +4,26 @@ from molodetz.docs_api.registry import endpoint, field
GROUP = {
"slug": "account",
"title": "Account",
"description": "Inloggen met sessies, API-sleutel, meldingen. Geen JWT, geen OAuth.",
"description": "Session login, API key, notifications. No JWT, no OAuth.",
"endpoints": [
endpoint("GET", "/auth/login", "Inlogpagina", sample={"errors": [], "next": "/admin"}),
endpoint("GET", "/auth/login", "Login page", sample={"errors": [], "next": "/admin"}),
endpoint(
"POST",
"/auth/login",
"Inloggen",
"Zet een httponly sessiecookie (7 dagen, 30 met onthouden).",
"Log in",
"Sets an httponly session cookie (7 days, 30 with remember me).",
fields=[field("username", required=True, example="{{username}}"), field("password", required=True, type="password"), field("remember", type="boolean")],
sample={"ok": True, "redirect": "/admin", "data": None},
interactive=False,
),
endpoint("POST", "/auth/logout", "Uitloggen", sample={"ok": True, "redirect": "/", "data": None}, interactive=False),
endpoint("GET", "/profile/api-key", "API-sleutel bekijken", auth="member", sample={"api_key": "{{api_key}}"}),
endpoint("POST", "/profile/api-key/regenerate", "API-sleutel vernieuwen", auth="member", destructive=True, sample={"ok": True, "redirect": "/profile/api-key", "data": {"api_key": "..."}}, interactive=False),
endpoint("POST", "/profile/avatar/regenerate", "Avatar vernieuwen", auth="member", destructive=True, sample={"ok": True}, interactive=False),
endpoint("GET", "/notifications", "Meldingen", auth="member", sample={"notifications": [], "unread": 0}),
endpoint("POST", "/notifications/read", "Alles gelezen", auth="member", sample={"ok": True, "redirect": "/notifications"}),
endpoint("GET", "/voorwaarden", "Voorwaarden", sample={"title": "Voorwaarden"}),
endpoint("POST", "/voorwaarden/accept", "Voorwaarden accepteren", auth="member", sample={"ok": True, "redirect": "/"}),
endpoint("POST", "/auth/logout", "Log out", sample={"ok": True, "redirect": "/", "data": None}, interactive=False),
endpoint("GET", "/profile/api-key", "View API key", auth="member", sample={"api_key": "{{api_key}}"}),
endpoint("POST", "/profile/api-key/regenerate", "Renew API key", auth="member", destructive=True, sample={"ok": True, "redirect": "/profile/api-key", "data": {"api_key": "..."}}, interactive=False),
endpoint("POST", "/profile/avatar/regenerate", "Renew avatar", auth="member", destructive=True, sample={"ok": True}, interactive=False),
endpoint("GET", "/notifications", "Notifications", auth="member", sample={"notifications": [], "unread": 0}),
endpoint("POST", "/notifications/read", "Mark all read", auth="member", sample={"ok": True, "redirect": "/notifications"}),
endpoint("GET", "/terms", "Terms", sample={"title": "Terms"}),
endpoint("POST", "/terms/accept", "Accept terms", auth="member", sample={"ok": True, "redirect": "/"}),
endpoint("GET", "/privacy", "Privacy", sample={"title": "Privacy"}),
],
}
+31 -31
View File
@@ -2,51 +2,51 @@
from molodetz.docs_api.registry import endpoint, field
GROUP = {
"slug": "beheer",
"title": "Beheer",
"description": "Alleen voor beheerders. Berichten schrijven en publiceren, aanmeldingen, services, prullenbak, instellingen.",
"slug": "admin",
"title": "Admin",
"description": "Administrators only. Writing and publishing posts, join requests, services, trash, settings.",
"endpoints": [
endpoint("GET", "/admin", "Overzicht", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0}),
endpoint("GET", "/admin/posts", "Berichten", auth="admin", sample={"posts": []}),
endpoint("GET", "/admin/posts/new", "Nieuw bericht", auth="admin", sample={}),
endpoint("GET", "/admin", "Overview", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0}),
endpoint("GET", "/admin/posts", "Posts", auth="admin", sample={"posts": []}),
endpoint("GET", "/admin/posts/new", "New post", auth="admin", sample={}),
endpoint(
"POST",
"/admin/posts",
"Bericht aanmaken",
"Markdown-bericht aanmaken; status draft of published.",
"Post creation",
"Adds a markdown post; status draft or published.",
auth="admin",
fields=[
field("title", required=True, example="Een titel"),
field("body", required=True, example="Tekst in **markdown**."),
field("topic", enum=["rol", "standaard"], example="rol"),
field("title", required=True, example="A title"),
field("body", required=True, example="Text in **markdown**."),
field("topic", enum=["roll", "standard"], example="roll"),
field("status", enum=["draft", "published"], example="draft"),
field("is_placeholder", type="boolean"),
],
sample={"ok": True, "redirect": "/admin/posts", "data": {"uid": "..."}},
probe_body={"title": "x"},
),
endpoint("GET", "/admin/posts/{uid}/edit", "Bericht bewerken", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/edit"),
endpoint("POST", "/admin/posts/{uid}", "Bericht opslaan", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend", probe_body={"title": "x"}),
endpoint("POST", "/admin/posts/{uid}/publish", "Publiceren", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/publish"),
endpoint("POST", "/admin/posts/{uid}/delete", "Bericht verwijderen", "Zachte verwijdering; herstel via prullenbak.", auth="admin", destructive=True, fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/onbekend/delete"),
endpoint("GET", "/admin/joins", "Aanmeldingen", auth="admin", sample={"requests": []}),
endpoint("POST", "/admin/joins/{uid}/status", "Status aanmelding", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/onbekend/status", probe_body={"status": "open"}),
endpoint("POST", "/admin/joins/{uid}/check", "Repo-link controleren", "Haalt de titel op via de beveiligde uitgaande client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/onbekend/check"),
endpoint("GET", "/admin/posts/{uid}/edit", "Edit post", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/unknown/edit"),
endpoint("POST", "/admin/posts/{uid}", "Save post", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/unknown", probe_body={"title": "x"}),
endpoint("POST", "/admin/posts/{uid}/publish", "Publish", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/unknown/publish"),
endpoint("POST", "/admin/posts/{uid}/delete", "Post removal", "Soft delete; restore from the trash.", auth="admin", destructive=True, fields=[field("uid", required=True, location="path")], probe_path="/admin/posts/unknown/delete"),
endpoint("GET", "/admin/joins", "Join requests", auth="admin", sample={"requests": []}),
endpoint("POST", "/admin/joins/{uid}/status", "Join request status", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/unknown/status", probe_body={"status": "open"}),
endpoint("POST", "/admin/joins/{uid}/check", "Check repo link", "Fetches the title through the guarded outbound client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/unknown/check"),
endpoint("GET", "/admin/services", "Services", auth="admin", sample={"services": []}),
endpoint("GET", "/admin/services/{name}", "Service", auth="admin", fields=[field("name", required=True, location="path", example="backup")], probe_path="/admin/services/backup"),
endpoint("POST", "/admin/services/{name}/command", "Service-commando", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}),
endpoint("POST", "/admin/services/{name}/config", "Service-configuratie", auth="admin", fields=[field("name", required=True, location="path")], probe_path="/admin/services/backup/config", probe_body={}),
endpoint("GET", "/admin/trash", "Prullenbak", auth="admin", sample={"items": []}),
endpoint("POST", "/admin/trash/restore", "Herstellen", auth="admin", fields=[field("stamp", required=True)], probe_body={"stamp": "none"}),
endpoint("POST", "/admin/trash/purge", "Definitief verwijderen", auth="admin", destructive=True, fields=[field("stamp", required=True)], probe_body={"stamp": "none"}),
endpoint("GET", "/admin/settings", "Instellingen", auth="admin", sample={"settings": {}}),
endpoint("POST", "/admin/settings", "Instellingen opslaan", auth="admin", probe_body={}),
endpoint("POST", "/admin/services/{name}/command", "Service command", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}),
endpoint("POST", "/admin/services/{name}/config", "Service configuration", auth="admin", fields=[field("name", required=True, location="path")], probe_path="/admin/services/backup/config", probe_body={}),
endpoint("GET", "/admin/trash", "Trash", auth="admin", sample={"items": []}),
endpoint("POST", "/admin/trash/restore", "Restore", auth="admin", fields=[field("stamp", required=True)], probe_body={"stamp": "none"}),
endpoint("POST", "/admin/trash/purge", "Permanent purge", auth="admin", destructive=True, fields=[field("stamp", required=True)], probe_body={"stamp": "none"}),
endpoint("GET", "/admin/settings", "Settings", auth="admin", sample={"settings": {}}),
endpoint("POST", "/admin/settings", "Save settings", auth="admin", probe_body={}),
endpoint("GET", "/admin/users", "Accounts", auth="admin", sample={"users": []}),
endpoint("POST", "/admin/users/{uid}/role", "Rol wijzigen", auth="admin", fields=[field("uid", required=True, location="path"), field("role", enum=["Admin", "Member"])], probe_path="/admin/users/onbekend/role", probe_body={"role": "Member"}),
endpoint("GET", "/admin/audit", "Auditlog", auth="admin", sample={"rows": []}),
endpoint("GET", "/admin/backups", "Back-ups", auth="admin", sample={"backups": []}),
endpoint("POST", "/admin/backups", "Back-up inplannen", auth="admin", fields=[field("target", enum=["database", "uploads", "keys", "full"])], probe_body={"target": "nope"}),
endpoint("GET", "/admin/backups/{uid}/download", "Back-up downloaden", "Alleen de primaire beheerder.", auth="admin", negotiation="none", interactive=False, probe_path="/admin/backups/onbekend/download"),
endpoint("GET", "/admin/stats", "Statistieken", auth="admin", sample={"top_paths": [], "daily": []}),
endpoint("POST", "/admin/users/{uid}/role", "Change role", auth="admin", fields=[field("uid", required=True, location="path"), field("role", enum=["Admin", "Member"])], probe_path="/admin/users/unknown/role", probe_body={"role": "Member"}),
endpoint("GET", "/admin/audit", "Audit log", auth="admin", sample={"rows": []}),
endpoint("GET", "/admin/backups", "Backups", auth="admin", sample={"backups": []}),
endpoint("POST", "/admin/backups", "Schedule backup", auth="admin", fields=[field("target", enum=["database", "uploads", "keys", "full"])], probe_body={"target": "nope"}),
endpoint("GET", "/admin/backups/{uid}/download", "Download backup", "Primary administrator only.", auth="admin", negotiation="none", interactive=False, probe_path="/admin/backups/unknown/download"),
endpoint("GET", "/admin/stats", "Statistics", auth="admin", sample={"top_paths": [], "daily": []}),
],
}
-25
View File
@@ -1,25 +0,0 @@
# retoor <retoor@molodetz.nl>
from molodetz.docs_api.registry import endpoint, field
GROUP = {
"slug": "binnen",
"title": "Binnen",
"description": "Aanmelden om mee te schrijven. Opgeslagen voor beheerders, nergens publiek.",
"endpoints": [
endpoint("GET", "/binnen", "Aanmeldformulier", sample={"submitted": False}),
endpoint(
"POST",
"/binnen",
"Ik schrijf mee",
"Slaat een aanmelding op: naam, contact en optioneel een repo-link.",
fields=[
field("name", required=True, example="Ada"),
field("contact", required=True, example="ada@example.nl"),
field("repo_url", example="https://git.example.nl/ada"),
field("message", example="Ik schrijf over afmaken."),
],
sample={"ok": True, "redirect": "/binnen?ok=1", "data": {"uid": "0190..."}},
probe_body={"name": "Probe", "contact": "probe@example.nl"},
),
],
}
+25 -25
View File
@@ -2,45 +2,45 @@
from molodetz.docs_api.registry import endpoint, field
GROUP = {
"slug": "inhoud",
"title": "Inhoud",
"description": "Publieke leesroutes: de rol, de standaard, berichten, galerijen en mensen.",
"slug": "content",
"title": "Content",
"description": "Public read routes: the roll, the standard, posts, galleries and people.",
"endpoints": [
endpoint("GET", "/", "Voorpagina", "Laatste berichten en uitgelichte flyer.", sample={"posts": [], "flyer_count": 4, "meme_count": 20}),
endpoint("GET", "/", "Home", "Latest posts and the featured flyer.", sample={"posts": [], "flyer_count": 4, "meme_count": 20}),
endpoint(
"GET",
"/rol",
"De rol",
"Gepubliceerde notities in het onderwerp rol, nieuwste eerst, cursor-gepagineerd.",
fields=[field("before", description="Cursor uit next_cursor", location="query")],
sample={"title": "Rol", "topic": "rol", "posts": [], "next_cursor": None},
"/roll",
"The roll",
"Published notes in the roll topic, newest first, cursor paginated.",
fields=[field("before", description="Cursor from next_cursor", location="query")],
sample={"title": "Roll", "topic": "roll", "posts": [], "next_cursor": None},
),
endpoint("GET", "/standaard", "De standaard", "Gepubliceerde berichten in het onderwerp standaard.", sample={"title": "Standaard", "topic": "standaard", "posts": []}),
endpoint("GET", "/standard", "The standard", "Published posts in the standard topic.", sample={"title": "Standard", "topic": "standard", "posts": []}),
endpoint(
"GET",
"/posts/{slug}",
"Bericht",
"Een gepubliceerd bericht via slug of uid.",
fields=[field("slug", required=True, location="path", example="een-zes-is-geen-ambitie")],
sample={"post": {"title": "Een zes is geen ambitie.", "topic": "rol"}, "author": {"username": "retoor"}},
probe_path="/posts/bestaat-niet",
"Post",
"A published post by slug or uid. Old slugs redirect with 301 to the canonical slug.",
fields=[field("slug", required=True, location="path", example="a-pass-is-not-an-ambition")],
sample={"post": {"title": "A pass is not an ambition.", "topic": "roll"}, "author": {"username": "retoor"}},
probe_path="/posts/does-not-exist",
),
endpoint("GET", "/flyers", "Flyers", "Galerij met flyers.", sample={"kind": "flyer", "title": "Flyers", "items": []}),
endpoint("GET", "/memes", "Memes", "Galerij met memes.", sample={"kind": "meme", "title": "Memes", "items": []}),
endpoint("GET", "/mensen", "Mensen", "Mensen op de rol.", sample={"people": [{"username": "retoor", "post_count": 5}]}),
endpoint("GET", "/flyers", "Flyers", "Flyer gallery.", sample={"kind": "flyer", "title": "Flyers", "items": []}),
endpoint("GET", "/memes", "Memes", "Meme gallery.", sample={"kind": "meme", "title": "Memes", "items": []}),
endpoint("GET", "/people", "People", "People on the roll.", sample={"people": [{"username": "retoor", "post_count": 5}]}),
endpoint(
"GET",
"/mensen/{username}",
"Persoon",
"Profiel met berichten.",
"/people/{username}",
"Person",
"Profile with posts.",
fields=[field("username", required=True, location="path", example="retoor")],
sample={"person": {"username": "retoor"}, "posts": []},
probe_path="/mensen/retoor",
probe_path="/people/retoor",
),
endpoint("GET", "/robots.txt", "robots.txt", negotiation="none", interactive=False),
endpoint("GET", "/sitemap.xml", "Sitemap", negotiation="none", interactive=False),
endpoint("GET", "/avatar/svg/{seed}", "Avatar", "Deterministische SVG-avatar.", negotiation="none", interactive=False, probe_path="/avatar/svg/retoor"),
endpoint("GET", "/health", "Health", "Liveness en versie.", negotiation="json", sample={"status": "ok", "version": "1.0.0"}),
endpoint("GET", "/presence/roster", "Presence", "Online-set (uids).", negotiation="json", sample={"online": [], "tracked": 0}),
endpoint("GET", "/avatar/svg/{seed}", "Avatar", "Deterministic SVG avatar.", negotiation="none", interactive=False, probe_path="/avatar/svg/retoor"),
endpoint("GET", "/health", "Health", "Liveness and version.", negotiation="json", sample={"status": "ok", "version": "1.0.0"}),
endpoint("GET", "/presence/roster", "Presence", "Online set (uids).", negotiation="json", sample={"online": [], "tracked": 0}),
],
}
+25
View File
@@ -0,0 +1,25 @@
# retoor <retoor@molodetz.nl>
from molodetz.docs_api.registry import endpoint, field
GROUP = {
"slug": "join",
"title": "Join",
"description": "Sign up to write along. Stored for administrators, never public.",
"endpoints": [
endpoint("GET", "/join", "Join form", sample={"submitted": False}),
endpoint(
"POST",
"/join",
"I'm in",
"Stores a join request: name, contact and an optional repo link.",
fields=[
field("name", required=True, example="Ada"),
field("contact", required=True, example="ada@example.com"),
field("repo_url", example="https://git.example.com/ada"),
field("message", example="I write about finishing things."),
],
sample={"ok": True, "redirect": "/join?ok=1", "data": {"uid": "0190..."}},
probe_body={"name": "Probe", "contact": "probe@example.com"},
),
],
}
+5 -5
View File
@@ -6,11 +6,11 @@ from molodetz.docs_prose import page_source, render_page, visible_pages
def export_markdown(viewer_is_admin):
parts = ["# Molodetz documentatie", ""]
parts = ["# Molodetz documentation", ""]
for page in visible_pages(viewer_is_admin):
parts += [page_source(page["slug"]).strip(), ""]
for group in ordered_groups():
if group["slug"] == "beheer" and not viewer_is_admin:
if group["slug"] == "admin" and not viewer_is_admin:
continue
parts += [f"## API: {group['title']}", "", group["description"], ""]
for item in group["endpoints"]:
@@ -24,7 +24,7 @@ def export_html(viewer_is_admin):
for page in visible_pages(viewer_is_admin):
body.append(f"<section>{render_page(page['slug'])}</section>")
for group in ordered_groups():
if group["slug"] == "beheer" and not viewer_is_admin:
if group["slug"] == "admin" and not viewer_is_admin:
continue
items = "".join(
f"<li><code>{escape(item['method'])} {escape(item['path'])}</code> ({escape(item['auth'])}): {escape(item['title'])}</li>"
@@ -32,6 +32,6 @@ def export_html(viewer_is_admin):
)
body.append(f"<section><h2>API: {escape(group['title'])}</h2><ul>{items}</ul></section>")
return (
"<!doctype html><html lang=\"nl\"><head><meta charset=\"utf-8\"><title>Molodetz documentatie</title></head>"
f"<body><h1>Molodetz documentatie</h1>{''.join(body)}</body></html>"
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Molodetz documentation</title></head>"
f"<body><h1>Molodetz documentation</h1>{''.join(body)}</body></html>"
)
+11 -11
View File
@@ -10,25 +10,25 @@ from molodetz.rendering import normalize_dashes
PROSE_DIR = config.TEMPLATES_DIR / "docs" / "prose"
SECTIONS = ("Begin hier", "Bouwen met de API", "Bijdragen en internals", "Beheer")
SECTIONS = ("Start here", "Building with the API", "Contributing and internals", "Admin")
COMPONENTS = ("avatar", "code", "content", "title", "toast", "dialog", "context-menu", "upload", "lightbox", "chat")
DOCS_PAGES = [
{"slug": "welkom", "title": "Welkom", "section": "Begin hier", "file": "welkom.md"},
{"slug": "schrijven", "title": "Schrijven op de rol", "section": "Begin hier", "file": "schrijven.md"},
{"slug": "binnen", "title": "Binnenkomen", "section": "Begin hier", "file": "binnen.md"},
{"slug": "juridisch", "title": "Voorwaarden en privacy", "section": "Begin hier", "file": "juridisch.md"},
{"slug": "api", "title": "API en authenticatie", "section": "Bouwen met de API", "file": "api.md"},
{"slug": "architectuur", "title": "Architectuur", "section": "Bijdragen en internals", "file": "architectuur.md"},
{"slug": "frontend", "title": "Frontend en componenten", "section": "Bijdragen en internals", "file": "frontend.md"},
{"slug": "beheer", "title": "Beheer en services", "section": "Beheer", "file": "beheer.md", "admin": True},
{"slug": "backups", "title": "Back-ups en onderhoud", "section": "Beheer", "file": "backups.md", "admin": True},
{"slug": "welcome", "title": "Welcome", "section": "Start here", "file": "welcome.md"},
{"slug": "writing", "title": "Writing on the roll", "section": "Start here", "file": "writing.md"},
{"slug": "join", "title": "Joining", "section": "Start here", "file": "join.md"},
{"slug": "legal", "title": "Terms and privacy", "section": "Start here", "file": "legal.md"},
{"slug": "api", "title": "API and authentication", "section": "Building with the API", "file": "api.md"},
{"slug": "architecture", "title": "Architecture", "section": "Contributing and internals", "file": "architecture.md"},
{"slug": "frontend", "title": "Frontend and components", "section": "Contributing and internals", "file": "frontend.md"},
{"slug": "admin", "title": "Admin and services", "section": "Admin", "file": "admin.md", "admin": True},
{"slug": "backups", "title": "Backups and maintenance", "section": "Admin", "file": "backups.md", "admin": True},
] + [
{
"slug": f"component-{name}",
"title": f"dp-{name}",
"section": "Bijdragen en internals",
"section": "Contributing and internals",
"file": f"component-{name}.md",
}
for name in COMPONENTS
+1 -1
View File
@@ -29,7 +29,7 @@ def build_index():
for item in group["endpoints"]:
lines.append(f"{item['method']} {item['path']} {item['title']} {item['description']}")
documents.append(
{"title": f"API: {group['title']}", "url": f"/docs/api/{group['slug']}", "text": " ".join(lines), "admin": group["slug"] == "beheer"}
{"title": f"API: {group['title']}", "url": f"/docs/api/{group['slug']}", "text": " ".join(lines), "admin": group["slug"] == "admin"}
)
tokens = [Counter(tokenize(doc["title"] + " " + doc["text"])) for doc in documents]
lengths = [sum(counter.values()) for counter in tokens]
+4 -4
View File
@@ -2,10 +2,10 @@
import random
POOL = (
"Deze pagina is nooit afgemaakt. Dat zegt genoeg.",
"Hier stond iets. Of het was bijna klaar.",
"Niet gevonden. Wel een goede reden om terug te gaan naar de rol.",
"Een link die nergens heen gaat is een mening, geen feit.",
"This page was never finished. That says enough.",
"Something lived here. Or it was almost done.",
"Not found. A good reason to head back to the roll.",
"A link that goes nowhere is an opinion, not a fact.",
)
+20 -20
View File
@@ -16,26 +16,26 @@ THUMB_WIDTH = 720
DUPLICATE_DISTANCE = 2
GALLERY = (
("flyer-keuzes.jpg", "flyer", "Eenendertig keuzes. Jij hebt er nul."),
("flyer-chat.jpg", "flyer", "Een chat is geen portfolio."),
("flyer-bijna.jpg", "flyer", "Bijna is geen staat."),
("flyer-assistent.jpg", "flyer", "De assistent oordeelt niet."),
("meme-vloer.jpg", "meme", "Een zesje is prima. De vloer, bedoel je."),
("meme-split.jpg", "meme", "Stuur ze naar huis."),
("meme-cursus.jpg", "meme", "Geen ebook. Wel afmaken."),
("take-chatlog.jpg", "meme", "Je portfolio is een chatlog."),
("take-copilot.jpg", "meme", "Copilot is geen senior."),
("take-coping.jpg", "meme", "Een zesje is coping."),
("poster-thuis.jpg", "meme", "Vibe coders, ga naar huis."),
("poster-vloer.jpg", "meme", "Een zesje? Dat is de vloer."),
("poster-cursus.jpg", "meme", "Geen cursus. Wel afmaken."),
("poster-luiheid.jpg", "meme", "AI pakt je baan niet. Luiheid wel."),
("poster-spel.jpg", "meme", "Het spel is om. Jij bent het niet."),
("story.jpg", "meme", "Geen ebook. Geen x10. Afwerken wel."),
("meme-zes.jpg", "meme", "Een zesje. Gefeliciteerd, je bent gemiddeld."),
("meme-gesprek.jpg", "meme", "Mijn portfolio is een gesprek."),
("meme-product.jpg", "meme", "De cursus is het product."),
("meme-bijna.jpg", "meme", "Iedereen staat bij bijna."),
("flyer-keuzes.jpg", "flyer", "Thirty-one choices. You have zero."),
("flyer-chat.jpg", "flyer", "A chat is not a portfolio."),
("flyer-bijna.jpg", "flyer", "Almost is not a state."),
("flyer-assistent.jpg", "flyer", "The assistant does not judge."),
("meme-vloer.jpg", "meme", "A pass is fine. The floor, you mean."),
("meme-split.jpg", "meme", "Send them home."),
("meme-cursus.jpg", "meme", "No ebook. Just finish it."),
("take-chatlog.jpg", "meme", "Your portfolio is a chat log."),
("take-copilot.jpg", "meme", "Copilot is not a senior."),
("take-coping.jpg", "meme", "A pass is coping."),
("poster-thuis.jpg", "meme", "Vibe coders, go home."),
("poster-vloer.jpg", "meme", "A pass? That is the floor."),
("poster-cursus.jpg", "meme", "No course. Just finish it."),
("poster-luiheid.jpg", "meme", "AI is not taking your job. Laziness is."),
("poster-spel.jpg", "meme", "The game has turned. You have not."),
("story.jpg", "meme", "No ebook. No 10x developer. Just finishing."),
("meme-zes.jpg", "meme", "A pass. Congratulations, you are average."),
("meme-gesprek.jpg", "meme", "My portfolio is a conversation."),
("meme-product.jpg", "meme", "The course is the product. The work is not."),
("meme-bijna.jpg", "meme", "Everyone lines up at almost."),
("meme-compiles.jpg", "meme", "It compiles. Ship it, king."),
("meme-senior.jpg", "meme", "My copilot is the senior."),
("meme-trophy.jpg", "meme", "Peak performance, 6 out of 10."),
+3 -3
View File
@@ -43,11 +43,11 @@ async def check_repo_link(row):
title = extract_title(response.headers.get("content-type", ""), body) if response.status_code < 400 else None
status = f"http {response.status_code}"
except UnsafeURL as exc:
title, status = None, f"geweigerd: {exc}"
title, status = None, f"refused: {exc}"
except (httpx.HTTPError, OSError, SafeET.ParseError) as exc:
title, status = None, f"fout: {type(exc).__name__}"
title, status = None, f"error: {type(exc).__name__}"
except Exception as exc:
logger.warning("repo link check failed: %s", exc)
title, status = None, f"fout: {type(exc).__name__}"
title, status = None, f"error: {type(exc).__name__}"
update_join_request(row["uid"], link_title=title, link_status=status, link_checked_at=now_iso())
return {"status": status, "title": title}
+28 -26
View File
@@ -32,22 +32,23 @@ from molodetz.responses import json_error, wants_json
from molodetz.routers import (
auth,
avatar,
binnen,
docs,
flyers,
health,
home,
join,
legacy,
memes,
mensen,
notifications,
people,
posts,
presence,
privacy,
profile,
rol,
roll,
seo,
standaard,
voorwaarden,
standard,
terms,
)
from molodetz.routers import admin
from molodetz.bootstrap import bootstrap_content
@@ -138,16 +139,17 @@ app.include_router(profile.router, prefix="/profile")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(presence.router, prefix="/presence")
app.include_router(posts.router, prefix="/posts")
app.include_router(rol.router, prefix="/rol")
app.include_router(standaard.router, prefix="/standaard")
app.include_router(roll.router, prefix="/roll")
app.include_router(standard.router, prefix="/standard")
app.include_router(flyers.router, prefix="/flyers")
app.include_router(memes.router, prefix="/memes")
app.include_router(mensen.router, prefix="/mensen")
app.include_router(binnen.router, prefix="/binnen")
app.include_router(voorwaarden.router, prefix="/voorwaarden")
app.include_router(people.router, prefix="/people")
app.include_router(join.router, prefix="/join")
app.include_router(terms.router, prefix="/terms")
app.include_router(privacy.router, prefix="/privacy")
app.include_router(health.router, prefix="/health")
app.include_router(seo.router)
app.include_router(legacy.router)
app.include_router(docs.router)
app.include_router(home.router)
@@ -247,9 +249,9 @@ async def rate_limit_middleware(request: Request, call_next):
retry = max(1, int(window - (now - bucket[0])))
record(request, "security.ratelimit.exceeded", result="denied", payload={"ip": key})
if wants_json(request):
response = json_error(429, "Te veel verzoeken", retry_after=retry)
response = json_error(429, "Too many requests", retry_after=retry)
else:
response = PlainTextResponse("Te veel verzoeken. Probeer het zo opnieuw.", status_code=429)
response = PlainTextResponse("Too many requests. Try again in a moment.", status_code=429)
response.headers["Retry-After"] = str(retry)
return response
bucket.append(now)
@@ -263,14 +265,14 @@ async def maintenance_middleware(request: Request, call_next):
return await call_next(request)
if request.url.path.startswith(MAINTENANCE_ALLOWED_PREFIXES) or is_admin(get_current_user(request)):
return await call_next(request)
message = get_setting("maintenance_message", "Onderhoud")
message = get_setting("maintenance_message", "Maintenance")
record(request, "security.maintenance.blocked", result="denied")
if wants_json(request):
return json_error(503, message)
return templates.TemplateResponse(
request,
"error.html",
{"status_code": 503, "message": message, "seo": {"title": "Onderhoud", "robots": "noindex,nofollow"}},
{"status_code": 503, "message": message, "seo": {"title": "Maintenance", "robots": "noindex,nofollow"}},
status_code=503,
)
@@ -282,9 +284,9 @@ async def terms_middleware(request: Request, call_next):
user = get_current_user(request)
version = get_setting("terms_version", "1")
if user is not None and str(user.get("terms_version") or "") != str(version):
target = f"/voorwaarden?next={request.url.path}"
target = f"/terms?next={request.url.path}"
if wants_json(request):
return json_error(403, "Accepteer eerst de voorwaarden", code="terms_required", redirect=target, terms_version=version)
return json_error(403, "Accept the terms first", code="terms_required", redirect=target, terms_version=version)
return RedirectResponse(target, status_code=303)
return await call_next(request)
@@ -379,22 +381,22 @@ async def http_exception_handler(request: Request, exc: HTTPException):
if wants_json(request) or request.headers.get("x-api-key") or request.headers.get("authorization"):
return json_error(exc.status_code, str(exc.detail))
if exc.status_code == 404:
message = friendly_message() if get_setting("friendly_404", "1") == "1" else "Niet gevonden."
message = friendly_message() if get_setting("friendly_404", "1") == "1" else "Not found."
return _error_page(request, 404, message)
return _error_page(request, exc.status_code, str(exc.detail), headers=getattr(exc, "headers", None))
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
return await http_exception_handler(request, HTTPException(404, "Niet gevonden"))
return await http_exception_handler(request, HTTPException(404, "Not found"))
@app.exception_handler(500)
async def server_error_handler(request: Request, exc):
logger.error("unhandled error: %s", "".join(traceback.format_exception(exc)))
if wants_json(request):
return json_error(500, "Interne fout")
return _error_page(request, 500, "Er ging iets mis aan onze kant.")
return json_error(500, "Internal error")
return _error_page(request, 500, "Something broke on our side.")
AUTH_TEMPLATES = {"/auth/login": "auth/login.html"}
@@ -405,9 +407,9 @@ def _friendly_messages(errors):
for error in errors:
loc = [str(part) for part in error.get("loc", ()) if part not in ("body", "query")]
field = loc[-1] if loc else "body"
message = str(error.get("msg", "Ongeldige invoer")).replace("Value error, ", "")
message = str(error.get("msg", "Invalid input")).replace("Value error, ", "")
if field == "password" and "at least" in message:
message = "Wachtwoord moet 6 tot 128 tekens zijn."
message = "Password must be 6 to 128 characters."
fields.append(field)
messages.append(f"{field}: {message}" if field != "body" else message)
return fields, messages
@@ -425,16 +427,16 @@ async def validation_handler(request: Request, exc: RequestValidationError):
return templates.TemplateResponse(
request,
template,
{"errors": messages, "form": echoed, "next": echoed.get("next"), "seo": {"title": "Inloggen", "robots": "noindex,nofollow"}},
{"errors": messages, "form": echoed, "next": echoed.get("next"), "seo": {"title": "Log in", "robots": "noindex,nofollow"}},
status_code=400,
)
if request.url.path == "/binnen":
if request.url.path == "/join":
form = await request.form()
echoed = {key: value for key, value in form.items() if isinstance(value, str)}
return templates.TemplateResponse(
request,
"binnen.html",
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Binnen", "robots": "index,follow"}},
"join.html",
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Join", "robots": "index,follow"}},
status_code=400,
)
return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303)
+8 -8
View File
@@ -17,7 +17,7 @@ def normalize_website(value):
if not value.lower().startswith(("http://", "https://")):
value = "https://" + value
if not URL_HOST_PATTERN.match(value):
raise ValueError("Geef een geldige link op, bijvoorbeeld https://git.example.nl/naam")
raise ValueError("Enter a valid link, for example https://git.example.com/name")
return value[:500]
@@ -30,7 +30,7 @@ def normalize_european_date(value):
return datetime.strptime(value, fmt).date().isoformat()
except ValueError:
continue
raise ValueError("Gebruik DD/MM/JJJJ")
raise ValueError("Use DD/MM/YYYY")
class FormModel(BaseModel):
@@ -47,7 +47,7 @@ class LoginForm(FormModel):
class PostForm(FormModel):
title: str = Field(min_length=3, max_length=200)
body: str = Field(min_length=10, max_length=PROSE_CAP)
topic: str = "rol"
topic: str = "roll"
status: str = "draft"
is_placeholder: bool = False
@@ -55,14 +55,14 @@ class PostForm(FormModel):
@classmethod
def check_topic(cls, value):
if value not in TOPICS:
raise ValueError("Onbekend onderwerp")
raise ValueError("Unknown topic")
return value
@field_validator("status")
@classmethod
def check_status(cls, value):
if value not in POST_STATUSES:
raise ValueError("Onbekende status")
raise ValueError("Unknown status")
return value
@@ -86,7 +86,7 @@ class JoinStatusForm(FormModel):
@classmethod
def check_status(cls, value):
if value not in JOIN_STATUSES:
raise ValueError("Onbekende status")
raise ValueError("Unknown status")
return value
@@ -97,7 +97,7 @@ class RoleForm(FormModel):
@classmethod
def check_role(cls, value):
if value not in ROLES:
raise ValueError("Onbekende rol")
raise ValueError("Unknown role")
return value
@@ -133,5 +133,5 @@ class BackupForm(FormModel):
@classmethod
def check_target(cls, value):
if value not in ("database", "uploads", "keys", "full"):
raise ValueError("Onbekend doel")
raise ValueError("Unknown target")
return value
+1 -1
View File
@@ -92,7 +92,7 @@ def _media_pass(html):
if not MENTION_PATTERN.search(text):
continue
escaped = mistune.escape(text)
replaced = MENTION_PATTERN.sub(lambda m: f'<a class="mention" href="/mensen/{m.group(1)}">@{m.group(1)}</a>', escaped)
replaced = MENTION_PATTERN.sub(lambda m: f'<a class="mention" href="/people/{m.group(1)}">@{m.group(1)}</a>', escaped)
fragment = BeautifulSoup(replaced, "html.parser")
node.replace_with(*list(fragment.contents))
return str(soup)
+1 -1
View File
@@ -14,4 +14,4 @@ router = APIRouter()
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)
return respond(request, "admin/audit.html", {"rows": list_audit(200, category), "category": category, "seo": admin_seo(request, "Audit log")}, model=AuditOut)
+4 -4
View File
@@ -21,7 +21,7 @@ router = APIRouter()
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")}
ctx = {"backups": rows, "can_download": is_primary_admin(user), "seo": admin_seo(request, "Backups")}
return respond(request, "admin/backups.html", ctx, model=BackupsOut)
@@ -39,13 +39,13 @@ 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")
raise HTTPException(403, "Primary administrator only")
row = get_backup(uid)
if row is None or row.get("status") != "done":
not_found("Back-up niet gevonden")
not_found("Backup not found")
path = backup_path(row)
if not path.exists():
not_found("Archief ontbreekt")
not_found("Archive missing")
record(request, "backup.download", targets=[("backup", uid)], user=user)
return StreamingResponse(
_stream_file(path),
+1 -1
View File
@@ -19,6 +19,6 @@ async def admin_index(request: Request):
"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"),
"seo": admin_seo(request, "Admin"),
}
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
+3 -3
View File
@@ -17,7 +17,7 @@ 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)
return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
@router.post("/joins/{uid}/status")
@@ -25,7 +25,7 @@ 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")
not_found("Join request not found")
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)
@@ -37,7 +37,7 @@ 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")
not_found("Join request not found")
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)
+8 -8
View File
@@ -19,13 +19,13 @@ 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)
return respond(request, "admin/posts.html", {"posts": all_posts(), "seo": admin_seo(request, "Posts")}, 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")}
ctx = {"post": None, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "New post")}
return respond(request, "admin/post_form.html", ctx, model=PostDetailOut)
@@ -42,18 +42,18 @@ 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")}
not_found("Post not found")
ctx = {"post": post, "topics": TOPICS, "errors": [], "seo": admin_seo(request, "Edit post")}
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")
not_found("Post not found")
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")
raise HTTPException(403, "Only the author may edit this post")
return post
@@ -80,9 +80,9 @@ 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")
not_found("Post not found")
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")
raise HTTPException(403, "Not allowed")
stamp = delete_post(post, user, request=request)
return action_result(request, "/admin/posts", data={"uid": uid, "stamp": stamp})
+2 -2
View File
@@ -15,7 +15,7 @@ VERBS = ("start", "stop", "run", "clear")
def _service(name):
service = manager.get(name)
if service is None:
not_found("Onbekende service")
not_found("Unknown service")
return service
@@ -47,7 +47,7 @@ async def services_command(request: Request, name: str):
service = _service(name)
verb = (await _payload(request)).get("verb", "")
if verb not in VERBS:
raise HTTPException(400, "Onbekend commando")
raise HTTPException(400, "Unknown command")
if verb in ("start", "stop"):
manager.set_enabled(service.name, verb == "start")
else:
+1 -1
View File
@@ -18,7 +18,7 @@ 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)
return respond(request, "admin/settings.html", {"settings": all_settings(), "seo": admin_seo(request, "Settings")}, model=SettingsOut)
@router.post("/settings")
+1 -1
View File
@@ -13,5 +13,5 @@ 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")}
ctx = {"top_paths": [dict(row) for row in top_paths()], "daily": [dict(row) for row in daily_totals()], "seo": admin_seo(request, "Statistics")}
return respond(request, "admin/stats.html", ctx, model=StatsOut)
+2 -2
View File
@@ -30,7 +30,7 @@ async def trash_index(request: Request):
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)
return respond(request, "admin/trash.html", {"items": items, "seo": admin_seo(request, "Trash")}, model=TrashOut)
async def _stamp(request):
@@ -40,7 +40,7 @@ async def _stamp(request):
else:
stamp = (await request.form()).get("stamp")
if not stamp:
raise HTTPException(400, "stamp ontbreekt")
raise HTTPException(400, "stamp missing")
return str(stamp)
+2 -2
View File
@@ -24,11 +24,11 @@ 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")
not_found("Account not found")
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")
raise HTTPException(403, "You cannot manage a more senior administrator")
set_role(uid, data.role)
invalidate_user_cache(propagate=True)
record(request, "admin.user.role", payload={"role": data.role}, targets=[("user", uid)], user=actor)
+2 -2
View File
@@ -16,7 +16,7 @@ router = APIRouter()
def _seo(request):
return base_seo_context(request, "Inloggen", robots="noindex,nofollow")
return base_seo_context(request, "Log in", robots="noindex,nofollow")
@router.get("/login")
@@ -33,7 +33,7 @@ async def login_submit(request: Request):
if not valid:
record(request, "auth.login.failed", result="denied", payload={"username": data.username[:64]})
ctx = {
"errors": ["Onbekende combinatie van naam en wachtwoord."],
"errors": ["Unknown combination of name and password."],
"form": {"username": data.username},
"next": data.next,
"seo": _seo(request),
+8 -8
View File
@@ -17,14 +17,14 @@ 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"]
groups = [group for group in ordered_groups() if viewer_is_admin or group["slug"] != "admin"]
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")))
return templates.TemplateResponse(request, "docs/index.html", _ctx(request, "Documentation", body=render_page("welcome")))
@router.get("/docs/search")
@@ -32,7 +32,7 @@ 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))
return templates.TemplateResponse(request, "docs/search.html", _ctx(request, "Search", query=query, results=results))
@router.get("/docs/download.md")
@@ -51,11 +51,11 @@ async def docs_download_html(request: Request):
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")
if group is None or (group["slug"] == "admin" and not is_admin(viewer)):
not_found("Unknown API group")
base = base_url(request)
username = viewer["username"] if viewer else "retoor"
api_key = viewer["api_key"] if viewer else "JOUW_API_SLEUTEL"
api_key = viewer["api_key"] if viewer else "YOUR_API_KEY"
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)
@@ -63,7 +63,7 @@ async def docs_api_group(request: Request, group_slug: str):
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 []),
_ctx(request, f"API: {group['title']}", group=group, endpoints=endpoints, viewer=viewer, services=live_services() if group["slug"] == "admin" else []),
)
@@ -72,5 +72,5 @@ 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")
not_found("Page not found")
return templates.TemplateResponse(request, "docs/page.html", _ctx(request, page["title"], body=render_page(slug), page=page))
@@ -19,13 +19,13 @@ router = APIRouter()
def _seo(request):
return base_seo_context(request, "Binnen", description_for("binnen"), breadcrumbs=[("Molodetz", "/"), ("Binnen", "/binnen")])
return base_seo_context(request, "Join", description_for("join"), breadcrumbs=[("Molodetz", "/"), ("Join", "/join")])
@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)
return respond(request, "join.html", {"submitted": submitted, "errors": [], "form": {}, "seo": _seo(request)}, model=JoinPageOut)
@router.post("")
@@ -33,10 +33,10 @@ 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")
return action_result(request, "/join?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"]})
create_notification(uid, "join.request", f"{data.name} wants to write along", "/admin/joins")
return action_result(request, "/join?ok=1", data={"uid": row["uid"]})
+58
View File
@@ -0,0 +1,58 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
router = APIRouter(include_in_schema=False)
LEGACY_PATHS = {
"/rol": "/roll",
"/standaard": "/standard",
"/mensen": "/people",
"/binnen": "/join",
"/voorwaarden": "/terms",
"/docs/welkom": "/docs/welcome",
"/docs/schrijven": "/docs/writing",
"/docs/binnen": "/docs/join",
"/docs/juridisch": "/docs/legal",
"/docs/architectuur": "/docs/architecture",
"/docs/beheer": "/docs/admin",
"/docs/api/inhoud": "/docs/api/content",
"/docs/api/binnen": "/docs/api/join",
"/docs/api/beheer": "/docs/api/admin",
}
def _target(request, path):
query = request.url.query
return f"{path}?{query}" if query else path
def _redirect(request, path):
status = 301 if request.method in ("GET", "HEAD") else 308
return RedirectResponse(_target(request, path), status_code=status)
@router.get("/mensen/{username}")
async def legacy_person(request: Request, username: str):
return _redirect(request, f"/people/{username}")
@router.post("/binnen")
async def legacy_join_post(request: Request):
return _redirect(request, "/join")
@router.post("/voorwaarden/accept")
async def legacy_terms_accept(request: Request):
return _redirect(request, "/terms/accept")
def _make_handler(target):
async def handler(request: Request):
return _redirect(request, target)
return handler
for _old, _new in LEGACY_PATHS.items():
router.add_api_route(_old, _make_handler(_new), methods=["GET"])
+1 -1
View File
@@ -14,7 +14,7 @@ router = APIRouter()
async def notifications_page(request: Request):
user = require_user(request)
rows = list_notifications(user["uid"])
seo = base_seo_context(request, "Meldingen", robots="noindex,nofollow")
seo = base_seo_context(request, "Notifications", robots="noindex,nofollow")
ctx = {"notifications": rows, "unread": unread_count(user["uid"]), "seo": seo}
return respond(request, "notifications.html", ctx, model=NotificationsOut)
@@ -19,24 +19,24 @@ def _with_counts(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)
seo = base_seo_context(request, "People", description_for("people"), breadcrumbs=[("Molodetz", "/"), ("People", "/people")])
return respond(request, "people.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")
not_found("Person not found")
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"),
row.get("bio") or description_for("people"),
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']}")],
breadcrumbs=[("Molodetz", "/"), ("People", "/people"), (row["username"], f"/people/{row['username']}")],
schemas=[profile_schema(request, row)],
)
return respond(request, "person.html", {"person": person_row, "posts": own, "seo": seo}, model=ProfileOut)
+3 -3
View File
@@ -3,7 +3,7 @@ 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.database import get_post_by_slug, get_post_by_slug_tail, get_user
from molodetz.rendering import content_preview, plain_title
from molodetz.responses import respond
from molodetz.schemas.posts import PostDetailOut
@@ -15,10 +15,10 @@ router = APIRouter()
@router.get("/{slug}")
async def post_detail(request: Request, slug: str):
post = get_post_by_slug(slug)
post = get_post_by_slug(slug) or get_post_by_slug_tail(slug)
viewer = get_current_user(request)
if post is None or (post["status"] != "published" and not is_admin(viewer)):
not_found("Bericht niet gevonden")
not_found("Post not found")
if slug != post["slug"]:
return RedirectResponse(f"/posts/{post['slug']}", status_code=301)
author = get_user(post["user_uid"])
+3 -3
View File
@@ -15,7 +15,7 @@ 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")
seo = base_seo_context(request, "API key", robots="noindex,nofollow")
return respond(request, "profile/api_key.html", {"api_key": user["api_key"], "seo": seo}, model=ApiKeyOut)
@@ -26,7 +26,7 @@ async def api_key_regenerate(request: Request):
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")
create_notification(user["uid"], "auth.apikey", "Your API key was renewed.", "/profile/api-key")
return action_result(request, "/profile/api-key", data={"api_key": key})
@@ -36,4 +36,4 @@ async def avatar_regenerate(request: 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']}")
return action_result(request, f"/people/{user['username']}")
@@ -7,5 +7,5 @@ router = APIRouter()
@router.get("")
async def rol(request: Request):
return topic_listing(request, "rol")
async def roll(request: Request):
return topic_listing(request, "roll")
+2 -2
View File
@@ -17,7 +17,7 @@ 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")
STATIC_PATHS = ("/", "/flyers", "/memes", "/people", "/join", "/terms", "/privacy", "/docs")
@router.get("/robots.txt")
@@ -47,7 +47,7 @@ def build_sitemap(base):
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"/people/{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:
@@ -7,5 +7,5 @@ router = APIRouter()
@router.get("")
async def standaard(request: Request):
return topic_listing(request, "standaard")
async def standard(request: Request):
return topic_listing(request, "standard")
@@ -14,9 +14,9 @@ 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)
seo = base_seo_context(request, "Terms", breadcrumbs=[("Molodetz", "/"), ("Terms", "/terms")])
ctx = {"title": "Terms", "terms_version": get_setting("terms_version", "1"), "next": safe_next(request.query_params.get("next"), "/"), "seo": seo}
return respond(request, "terms.html", ctx, model=PageOut)
@router.post("/accept")
+5 -5
View File
@@ -26,7 +26,7 @@ def website_schema(request):
"@type": "WebSite",
"name": config.APP_TITLE,
"url": base + "/",
"inLanguage": "nl",
"inLanguage": "en",
"potentialAction": {
"@type": "SearchAction",
"target": base + "/docs/search?q={search_term_string}",
@@ -60,9 +60,9 @@ def article_schema(request, post, author):
"headline": _clean(post["title"]),
"datePublished": post.get("published_at") or post.get("created_at"),
"dateModified": post.get("updated_at"),
"inLanguage": "nl",
"inLanguage": "en",
"mainEntityOfPage": f"{base}/posts/{post['slug']}",
"author": {"@type": "Person", "name": author["username"] if author else "retoor", "url": f"{base}/mensen/{author['username'] if author else 'retoor'}"},
"author": {"@type": "Person", "name": author["username"] if author else "retoor", "url": f"{base}/people/{author['username'] if author else 'retoor'}"},
"publisher": {"@type": "Organization", "name": config.APP_TITLE},
}
@@ -72,7 +72,7 @@ def profile_schema(request, person):
return {
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {"@type": "Person", "name": person["username"], "url": f"{base}/mensen/{person['username']}"},
"mainEntity": {"@type": "Person", "name": person["username"], "url": f"{base}/people/{person['username']}"},
}
@@ -113,5 +113,5 @@ def base_seo_context(
"json_ld": [json.dumps(schema, ensure_ascii=False).replace("</", "<\\/") for schema in all_schemas],
"prev_url": prev_url,
"next_url": next_url,
"keywords": keywords or "molodetz, schrijven, vakmanschap, software, community",
"keywords": keywords or "molodetz, writing, craftsmanship, software, community",
}
+8 -8
View File
@@ -1,17 +1,17 @@
# retoor <retoor@molodetz.nl>
DEFAULT_DESCRIPTION = (
"Molodetz is een kleine kring die schrijft over het werk. "
"De standaard ligt boven wat nu gewoon lijkt."
"Molodetz is a small circle that writes about the work. "
"The standard sits above what passes for normal today."
)
PAGE_DESCRIPTIONS = {
"home": DEFAULT_DESCRIPTION,
"rol": "De rol: korte notities over vakmanschap, afmaken en oordeel.",
"standaard": "De standaard: waar Molodetz op bouwt en waarom keuzes vastliggen.",
"flyers": "Flyers van Molodetz. Rustig gezet, scherp bedoeld.",
"memes": "Memes van Molodetz. Lichtelijk provocerend, nooit schreeuwerig.",
"mensen": "De mensen achter Molodetz.",
"binnen": "Schrijf mee met Molodetz. Laat weten wie je bent.",
"roll": "The roll: short notes on craft, finishing and judgement.",
"standard": "The standard: what Molodetz builds on and why the choices are fixed.",
"flyers": "Molodetz flyers. Calmly set, sharply meant.",
"memes": "Molodetz memes. Mildly provocative, never shouty.",
"people": "The people behind Molodetz.",
"join": "Write with Molodetz. Tell us who you are.",
}
+14 -14
View File
@@ -160,17 +160,17 @@ async def process_backup_async(row, codec):
class BackupService(BaseService):
name = "backup"
title = "Back-ups"
description = "Verwerkt de back-upwachtrij, plant automatische back-ups en snoeit oude archieven."
title = "Backups"
description = "Works through the backup queue, schedules automatic backups and prunes old archives."
default_interval = 10
min_interval = 5
metrics_interval = 30
config_fields = [
ConfigField("schedule_hours", "int", 24, "Automatisch elke N uur (0 = uit)", minimum=0, maximum=720, group="Planning"),
ConfigField("schedule_target", "select", "database", "Automatisch doel", options=list(TARGETS), group="Planning"),
ConfigField("keep_last", "int", 14, "Bewaar laatste N", minimum=1, maximum=1000, group="Bewaren"),
ConfigField("codec", "select", "zstd", "Compressie", options=["zstd", "brotli"], group="Archief"),
ConfigField("offload", "bool", "0", "Offload via rclone", group="Archief"),
ConfigField("schedule_hours", "int", 24, "Automatic every N hours (0 = off)", minimum=0, maximum=720, group="Schedule"),
ConfigField("schedule_target", "select", "database", "Automatic target", options=list(TARGETS), group="Schedule"),
ConfigField("keep_last", "int", 14, "Keep last N", minimum=1, maximum=1000, group="Retention"),
ConfigField("codec", "select", "zstd", "Compression", options=["zstd", "brotli"], group="Archive"),
ConfigField("offload", "bool", "0", "Offload via rclone", group="Archive"),
]
def __init__(self):
@@ -190,30 +190,30 @@ class BackupService(BaseService):
cfg = self.get_config()
if self._schedule_due(int(cfg["schedule_hours"])):
enqueue_backup(cfg["schedule_target"], "system")
self.log(f"automatische back-up ingepland: {cfg['schedule_target']}")
self.log(f"automatic backup scheduled: {cfg['schedule_target']}")
for row in pending_backups():
update_backup(row["uid"], status="running")
ok = await process_backup_async(row, cfg["codec"])
self.processed += 1
self.log(f"back-up {row['target']} {'klaar' if ok else 'mislukt'}")
self.log(f"backup {row['target']} {'finished' if ok else 'failed'}")
primary = admin_uids_ordered()[:1]
for uid in primary:
create_notification(uid, "backup.finished" if ok else "backup.failed", f"Back-up {row['target']} {'klaar' if ok else 'mislukt'}", "/admin/backups")
create_notification(uid, "backup.finished" if ok else "backup.failed", f"Backup {row['target']} {'finished' if ok else 'failed'}", "/admin/backups")
if ok and cfg["offload"] == "1":
done = [item for item in list_backups(limit=5) if item["uid"] == row["uid"]]
if done:
self.log(f"offload: {offload(backup_path(done[0]))}")
pruned = prune_backups(int(cfg["keep_last"]))
if pruned:
self.log(f"{pruned} oude back-ups gesnoeid")
self.log(f"{pruned} old backups pruned")
def collect_metrics(self):
rows = list_backups(limit=200)
return {
"cards": [
{"label": "Archieven", "value": sum(1 for row in rows if row["status"] == "done")},
{"label": "Wachtrij", "value": sum(1 for row in rows if row["status"] == "pending")},
{"label": "Verwerkt sinds start", "value": self.processed},
{"label": "Archives", "value": sum(1 for row in rows if row["status"] == "done")},
{"label": "Queue", "value": sum(1 for row in rows if row["status"] == "pending")},
{"label": "Processed since start", "value": self.processed},
],
"table": None,
}
+5 -5
View File
@@ -24,7 +24,7 @@ FIELD_TYPES = ("int", "str", "url", "text", "password", "bool", "select")
class ConfigField:
def __init__(self, key, type="str", default="", label="", help="", minimum=None, maximum=None, options=None, secret=False, group="Algemeen"):
def __init__(self, key, type="str", default="", label="", help="", minimum=None, maximum=None, options=None, secret=False, group="General"):
if type not in FIELD_TYPES:
raise ValueError(f"unknown field type {type}")
self.key = key
@@ -109,10 +109,10 @@ class BaseService:
def fields(self):
framework = [
ConfigField("enabled", "bool", "1" if self.default_enabled else "0", "Ingeschakeld", group="Algemeen"),
ConfigField("interval", "int", self.default_interval, "Interval (s)", minimum=self.min_interval, group="Algemeen"),
ConfigField("enabled", "bool", "1" if self.default_enabled else "0", "Enabled", group="General"),
ConfigField("interval", "int", self.default_interval, "Interval (s)", minimum=self.min_interval, group="General"),
]
tail = [ConfigField("log_size", "int", 200, "Logregels", minimum=10, maximum=5000, group="Logs")]
tail = [ConfigField("log_size", "int", 200, "Log lines", minimum=10, maximum=5000, group="Logs")]
return framework + list(self.config_fields) + tail
def field(self, key):
@@ -196,7 +196,7 @@ class BaseService:
try:
await self.run_once()
except Exception as exc:
self.log(f"fout: {exc}")
self.log(f"error: {exc}")
logger.exception("service %s failed", self.name)
self._next_run = datetime.now(timezone.utc) + timedelta(seconds=self.current_interval())
write_service_state(self._row_id, last_run=now_iso())
+5 -5
View File
@@ -8,7 +8,7 @@ from molodetz.services.base import BaseService
class HousekeepingService(BaseService):
name = "housekeeping"
title = "Housekeeping"
description = "Ruimt verlopen sessies op en snoeit het auditlog volgens de bewaartermijn."
description = "Clears expired sessions and prunes the audit log to the retention period."
default_interval = 3600
min_interval = 60
metrics_interval = 300
@@ -23,14 +23,14 @@ class HousekeepingService(BaseService):
days = max(1, get_int_setting("audit_retention_days", 365))
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
self.last_audit = prune_audit(cutoff)
self.log(f"sessies verwijderd: {self.last_sessions}, auditregels gesnoeid: {self.last_audit}")
self.log(f"sessions removed: {self.last_sessions}, audit rows pruned: {self.last_audit}")
def collect_metrics(self):
return {
"cards": [
{"label": "Auditregels", "value": count_audit()},
{"label": "Laatste sessie-opruiming", "value": self.last_sessions},
{"label": "Laatste auditsnoei", "value": self.last_audit},
{"label": "Audit rows", "value": count_audit()},
{"label": "Last session cleanup", "value": self.last_sessions},
{"label": "Last audit prune", "value": self.last_audit},
],
"table": None,
}
+2 -2
View File
@@ -7,7 +7,7 @@ from molodetz.utils.presence import compute_roster
class PresenceService(BaseService):
name = "presence"
title = "Presence"
description = "Publiceert de online-set uit de laatste activiteit van accounts."
description = "Publishes the online set from recent account activity."
default_interval = 5
min_interval = 2
metrics_interval = 5
@@ -24,7 +24,7 @@ class PresenceService(BaseService):
"roster": self.roster,
"cards": [
{"label": "Online", "value": len(self.roster[: config.PRESENCE_ONLINE_LIMIT])},
{"label": "Gevolgd", "value": len(self.roster)},
{"label": "Tracked", "value": len(self.roster)},
],
"table": None,
}
+3 -3
View File
@@ -38,13 +38,13 @@ export class Application {
wireCopy() {
document.querySelectorAll("[data-copy]").forEach((node) => {
node.title = "Klik om te kopi\u00ebren";
node.title = "Click to copy";
node.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(node.textContent.trim());
this.toast("Gekopieerd");
this.toast("Copied");
} catch {
Toast.flash(node, "Kopi\u00ebren mislukt", 1500);
Toast.flash(node, "Copy failed", 1500);
}
});
});
+2 -2
View File
@@ -12,11 +12,11 @@ export class Chat extends Component {
form.className = "chat-form";
this.input = document.createElement("textarea");
this.input.rows = 2;
this.input.setAttribute("aria-label", "Bericht");
this.input.setAttribute("aria-label", "Message");
const send = document.createElement("button");
send.type = "submit";
send.className = "button small";
send.textContent = "Verstuur";
send.textContent = "Send";
form.append(this.input, send);
form.addEventListener("submit", (event) => {
event.preventDefault();
+3 -3
View File
@@ -23,13 +23,13 @@ export class Code extends Component {
const button = document.createElement("button");
button.type = "button";
button.className = "copy-button";
button.textContent = "Kopieer";
button.textContent = "Copy";
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code.textContent);
Toast.flash(button, "Gekopieerd", 1500, "Kopieer");
Toast.flash(button, "Copied", 1500, "Copy");
} catch {
Toast.flash(button, "Mislukt", 1500, "Kopieer");
Toast.flash(button, "Failed", 1500, "Copy");
}
});
this.prepend(button);
+1 -1
View File
@@ -10,7 +10,7 @@ export class Dialog extends Component {
this.setAttribute("aria-modal", "true");
}
confirm(message, { okLabel = "Ja", cancelLabel = "Annuleren" } = {}) {
confirm(message, { okLabel = "Yes", cancelLabel = "Cancel" } = {}) {
return new Promise((resolve) => {
this.replaceChildren();
const box = document.createElement("div");
+2 -2
View File
@@ -7,7 +7,7 @@ export class Lightbox extends Component {
setup() {
this.setAttribute("role", "dialog");
this.setAttribute("aria-modal", "true");
this.setAttribute("aria-label", "Afbeelding");
this.setAttribute("aria-label", "Image");
this.hidden = true;
this.image = document.createElement("img");
this.image.alt = "";
@@ -16,7 +16,7 @@ export class Lightbox extends Component {
this.closeButton = document.createElement("button");
this.closeButton.type = "button";
this.closeButton.className = "modal-close lightbox-close";
this.closeButton.setAttribute("aria-label", "Sluiten");
this.closeButton.setAttribute("aria-label", "Close");
this.closeButton.textContent = "\u00d7";
this.append(this.closeButton, this.image, this.caption);
this.items = [];
+1 -1
View File
@@ -13,7 +13,7 @@ export class Upload extends Component {
const button = document.createElement("button");
button.type = "button";
button.className = "button ghost small";
button.textContent = this.getAttribute("label") || "Bestand kiezen";
button.textContent = this.getAttribute("label") || "Choose file";
button.addEventListener("click", () => this.input.click());
this.input.addEventListener("change", () => this.emit("dp-files", { files: [...this.input.files] }));
this.addEventListener("dragover", (event) => {
+2 -2
View File
@@ -12,7 +12,7 @@ export class ApiTester {
const section = button.closest("[data-endpoint]");
const output = section.querySelector("[data-response]");
if (section.dataset.destructive === "1") {
const accepted = await window.app.dialog.confirm("Dit verzoek wijzigt echte data. Doorgaan?");
const accepted = await window.app.dialog.confirm("This request changes real data. Continue?");
if (!accepted) {
return;
}
@@ -53,7 +53,7 @@ export class ApiTester {
}
output.textContent = `${response.status} ${response.statusText} (${Math.round(performance.now() - started)} ms)\n\n${pretty}`;
} catch (error) {
output.textContent = `Fout: ${error.message}`;
output.textContent = `Error: ${error.message}`;
} finally {
LoadingButton.setLoading(button, false);
}
+4 -4
View File
@@ -3,10 +3,10 @@ import { Poller } from "./Poller.js";
export class LocalTime {
constructor() {
this.dateFormat = new Intl.DateTimeFormat("nl-NL", { day: "2-digit", month: "2-digit", year: "numeric" });
this.timeFormat = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
this.fullFormat = new Intl.DateTimeFormat("nl-NL", { dateStyle: "full", timeStyle: "long" });
this.relative = new Intl.RelativeTimeFormat("nl-NL", { numeric: "auto" });
this.dateFormat = new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" });
this.timeFormat = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit" });
this.fullFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "long" });
this.relative = new Intl.RelativeTimeFormat("en-GB", { numeric: "auto" });
this.renderAll(document);
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => node.nodeType === 1 && this.renderAll(node)));
+2 -2
View File
@@ -12,12 +12,12 @@ export class TermsGate {
}
if (!this.pending) {
this.pending = this.dialog
.confirm(`De voorwaarden zijn gewijzigd (versie ${payload.error.terms_version}). Accepteren en doorgaan?`)
.confirm(`The terms have changed (version ${payload.error.terms_version}). Accept and continue?`)
.then(async (accepted) => {
if (!accepted) {
return false;
}
const result = await fetch("/voorwaarden/accept", {
const result = await fetch("/terms/accept", {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "Content-Type": "application/json" },
+1 -1
View File
@@ -12,7 +12,7 @@ from molodetz.net_guard import GuardedTransport
BROWSER_ALIGNED_HEADERS = {
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "nl-NL,nl;q=0.9,en;q=0.8",
"accept-language": "en-GB,en;q=0.9",
}
CONSENT_MARKERS = ("consent", "privacy-gate", "cookiewall")
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- retoor <retoor@molodetz.nl> -->
<a class="presence-host" href="/mensen/{{ _user.username }}" aria-label="{{ _user.username }}">
<a class="presence-host" href="/people/{{ _user.username }}" aria-label="{{ _user.username }}">
<img class="avatar" src="{{ avatar_url(_user, _size or 40) }}" width="{{ _size or 40 }}" height="{{ _size or 40 }}" alt="" loading="lazy">
<span class="presence-dot{% if is_online(_user.uid) %} online{% endif %}" data-presence-uid="{{ _user.uid }}"></span>
</a>
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- retoor <retoor@molodetz.nl> -->
<nav class="breadcrumbs" aria-label="Kruimelpad">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{% for name, path in seo.breadcrumbs %}
{% if not loop.last %}<a href="{{ path }}">{{ name }}</a><span aria-hidden="true">/</span>{% else %}<span aria-current="page">{{ name }}</span>{% endif %}
{% endfor %}
+2 -2
View File
@@ -2,6 +2,6 @@
<div class="post-meta">
<span class="label">{{ TOPICS.get(_post.topic, _post.topic) }}</span>
{{ local_dt(_post.published_at or _post.created_at, 'date') }}
{% if _post.is_placeholder %}<span class="tag-placeholder" title="Overgenomen tekst; wordt vervangen door een eigen verhaal">Placeholder</span>{% endif %}
{% if _post.status != 'published' %}<span class="tag-placeholder tag-draft">Concept</span>{% endif %}
{% if _post.is_placeholder %}<span class="tag-placeholder" title="Carried over text; to be replaced with an original story">Placeholder</span>{% endif %}
{% if _post.status != 'published' %}<span class="tag-placeholder tag-draft">Draft</span>{% endif %}
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- retoor <retoor@molodetz.nl> -->
{% if _next_url %}
<div class="load-more"><a class="button ghost" href="{{ _next_url }}" rel="next">Oudere notities</a></div>
<div class="load-more"><a class="button ghost" href="{{ _next_url }}" rel="next">Older notes</a></div>
{% endif %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% macro modal(id, title, wide=False) %}
<div class="modal-overlay" id="{{ id }}" role="dialog" aria-modal="true" aria-labelledby="{{ id }}-title">
<div class="modal{% if wide %} wide{% endif %}">
<header><h2 id="{{ id }}-title">{{ title }}</h2><button class="modal-close" type="button" aria-label="Sluiten">&times;</button></header>
<header><h2 id="{{ id }}-title">{{ title }}</h2><button class="modal-close" type="button" aria-label="Close">&times;</button></header>
{{ caller() }}
</div>
</div>
+3 -3
View File
@@ -1,8 +1,8 @@
<!-- retoor <retoor@molodetz.nl> -->
{% if _pages and _pages.pages > 1 %}
<nav class="actions" aria-label="Paginering">
{% if _pages.has_prev %}<a class="button ghost small" href="?page={{ _pages.page - 1 }}" rel="prev">Vorige</a>{% endif %}
<nav class="actions" aria-label="Pagination">
{% if _pages.has_prev %}<a class="button ghost small" href="?page={{ _pages.page - 1 }}" rel="prev">Previous</a>{% endif %}
<span class="muted">{{ _pages.page }} / {{ _pages.pages }}</span>
{% if _pages.has_next %}<a class="button ghost small" href="?page={{ _pages.page + 1 }}" rel="next">Volgende</a>{% endif %}
{% if _pages.has_next %}<a class="button ghost small" href="?page={{ _pages.page + 1 }}" rel="next">Next</a>{% endif %}
</nav>
{% endif %}
+2 -2
View File
@@ -1,5 +1,5 @@
<!-- retoor <retoor@molodetz.nl> -->
<form class="field" method="get" action="/docs/search" role="search">
<label for="docs-q">Zoeken</label>
<input id="docs-q" type="search" name="q" value="{{ query or '' }}" placeholder="bijv. sessie">
<label for="docs-q">Search</label>
<input id="docs-q" type="search" name="q" value="{{ query or '' }}" placeholder="e.g. session">
</form>
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- retoor <retoor@molodetz.nl> -->
<div class="field">
<label for="topic">Onderwerp</label>
<label for="topic">Topic</label>
<select id="topic" name="topic">
{% for key, label in TOPICS.items() %}
<option value="{{ key }}" {% if _selected == key %}selected{% endif %}>{{ label }}</option>
+1 -1
View File
@@ -1,2 +1,2 @@
<!-- retoor <retoor@molodetz.nl> -->
<a class="user-link" href="/mensen/{{ _user.username }}">{{ _user.username }}</a>
<a class="user-link" href="/people/{{ _user.username }}">{{ _user.username }}</a>
+3 -3
View File
@@ -1,15 +1,15 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Auditlog</h1>
<h1>Audit log</h1>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Tijd</th><th>Gebeurtenis</th><th>Categorie</th><th>Actor</th><th>Resultaat</th></tr></thead>
<thead><tr><th>Time</th><th>Event</th><th>Category</th><th>Actor</th><th>Result</th></tr></thead>
<tbody>
{% for row in rows %}
<tr><td>{{ local_dt(row.created_at) }}</td><td><code>{{ row.event_key }}</code></td><td><a href="?category={{ row.category }}">{{ row.category }}</a></td><td>{{ row.actor_kind }}</td><td><span class="status {{ 'failed' if row.result == 'denied' else 'done' }}">{{ row.result }}</span></td></tr>
{% else %}
<tr><td colspan="5" class="muted">Nog geen regels.</td></tr>
<tr><td colspan="5" class="muted">No rows yet.</td></tr>
{% endfor %}
</tbody>
</table>
+5 -5
View File
@@ -1,14 +1,14 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Back-ups</h1>
<h1>Backups</h1>
<form class="actions" method="post" action="/admin/backups">
<select name="target" aria-label="Doel">{% for target in ['database', 'uploads', 'keys', 'full'] %}<option value="{{ target }}">{{ target }}</option>{% endfor %}</select>
<button class="button" type="submit">Back-up inplannen</button>
<select name="target" aria-label="Target">{% for target in ['database', 'uploads', 'keys', 'full'] %}<option value="{{ target }}">{{ target }}</option>{% endfor %}</select>
<button class="button" type="submit">Schedule backup</button>
</form>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Doel</th><th>Status</th><th>Grootte</th><th>Aangemaakt</th><th></th></tr></thead>
<thead><tr><th>Target</th><th>Status</th><th>Size</th><th>Created</th><th></th></tr></thead>
<tbody>
{% for row in backups %}
<tr>
@@ -19,7 +19,7 @@
<td>{% if can_download and row.status == 'done' %}<a class="button ghost small" href="/admin/backups/{{ row.uid }}/download">Download</a>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Nog geen back-ups.</td></tr>
<tr><td colspan="5" class="muted">No backups yet.</td></tr>
{% endfor %}
</tbody>
</table>
+11 -11
View File
@@ -4,19 +4,19 @@
{% block extra_head %}<link rel="stylesheet" href="{{ static_url('/static/css/admin.css') }}">{% block admin_head %}{% endblock %}{% endblock %}
{% block content %}
<div class="admin-layout">
<nav class="admin-nav" aria-label="Beheer">
<a href="/admin" class="{{ nav_active(request, '/admin', exact=True) }}">Overzicht</a>
<a href="/admin/posts" class="{{ nav_active(request, '/admin/posts') }}">Berichten</a>
<a href="/admin/joins" class="{{ nav_active(request, '/admin/joins') }}">Aanmeldingen</a>
<nav class="admin-nav" aria-label="Admin">
<a href="/admin" class="{{ nav_active(request, '/admin', exact=True) }}">Overview</a>
<a href="/admin/posts" class="{{ nav_active(request, '/admin/posts') }}">Posts</a>
<a href="/admin/joins" class="{{ nav_active(request, '/admin/joins') }}">Join requests</a>
<a href="/admin/services" class="{{ nav_active(request, '/admin/services') }}">Services</a>
<a href="/admin/backups" class="{{ nav_active(request, '/admin/backups') }}">Back-ups</a>
<a href="/admin/trash" class="{{ nav_active(request, '/admin/trash') }}">Prullenbak</a>
<a href="/admin/backups" class="{{ nav_active(request, '/admin/backups') }}">Backups</a>
<a href="/admin/trash" class="{{ nav_active(request, '/admin/trash') }}">Trash</a>
<a href="/admin/users" class="{{ nav_active(request, '/admin/users') }}">Accounts</a>
<a href="/admin/settings" class="{{ nav_active(request, '/admin/settings') }}">Instellingen</a>
<a href="/admin/audit" class="{{ nav_active(request, '/admin/audit') }}">Auditlog</a>
<a href="/admin/stats" class="{{ nav_active(request, '/admin/stats') }}">Statistieken</a>
<a href="/profile/api-key">API-sleutel</a>
<a href="/docs/beheer">Docs</a>
<a href="/admin/settings" class="{{ nav_active(request, '/admin/settings') }}">Settings</a>
<a href="/admin/audit" class="{{ nav_active(request, '/admin/audit') }}">Audit log</a>
<a href="/admin/stats" class="{{ nav_active(request, '/admin/stats') }}">Statistics</a>
<a href="/profile/api-key">API key</a>
<a href="/docs/admin">Docs</a>
</nav>
<div class="admin-main">
{% block admin_content %}{% endblock %}
+5 -5
View File
@@ -1,12 +1,12 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Beheer</h1>
<h1>Admin</h1>
<div class="stat-cards">
<div class="stat-card"><span class="value">{{ post_count }}</span><span class="name">Berichten</span></div>
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open aanmeldingen</span></div>
<div class="stat-card"><span class="value">{{ post_count }}</span><span class="name">Posts</span></div>
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open join requests</span></div>
<div class="stat-card"><span class="value">{{ user_count }}</span><span class="name">Accounts</span></div>
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In prullenbak</span></div>
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In trash</span></div>
</div>
<div class="actions"><a class="button" href="/admin/posts/new">Nieuw bericht</a><a class="button ghost" href="/admin/joins">Aanmeldingen bekijken</a></div>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a></div>
{% endblock %}
+6 -6
View File
@@ -1,29 +1,29 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Aanmeldingen</h1>
<h1>Join requests</h1>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Wie</th><th>Contact</th><th>Werk</th><th>Bericht</th><th>Status</th><th>Ontvangen</th><th></th></tr></thead>
<thead><tr><th>Who</th><th>Contact</th><th>Work</th><th>Message</th><th>Status</th><th>Received</th><th></th></tr></thead>
<tbody>
{% for item in requests %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.contact }}</td>
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">geen</span>{% endif %}</td>
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ item.message }}</td>
<td><span class="status {{ item.status }}">{{ item.status }}</span></td>
<td>{{ local_dt(item.created_at) }}</td>
<td class="actions">
<form method="post" action="/admin/joins/{{ item.uid }}/status">
<select name="status" aria-label="Status">{% for value in ['open', 'contacted', 'accepted', 'declined'] %}<option value="{{ value }}" {% if item.status == value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select>
<button class="button ghost small" type="submit">Zet</button>
<button class="button ghost small" type="submit">Set</button>
</form>
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Link controleren</button></form>{% endif %}
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Check link</button></form>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Nog geen aanmeldingen.</td></tr>
<tr><td colspan="7" class="muted">No join requests yet.</td></tr>
{% endfor %}
</tbody>
</table>
+10 -10
View File
@@ -1,31 +1,31 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>{{ 'Bericht bewerken' if post else 'Nieuw bericht' }}</h1>
<h1>{{ 'Edit post' if post else 'New post' }}</h1>
<form class="form wide" method="post" action="{{ '/admin/posts/' ~ post.uid if post else '/admin/posts' }}" data-form>
<div class="field"><label for="title">Titel</label><input id="title" type="text" name="title" required minlength="3" maxlength="200" value="{{ post.title if post else '' }}"></div>
{% set _selected = post.topic if post else 'rol' %}
<div class="field"><label for="title">Title</label><input id="title" type="text" name="title" required minlength="3" maxlength="200" value="{{ post.title if post else '' }}"></div>
{% set _selected = post.topic if post else 'roll' %}
{% include "_topic_selector.html" %}
<div class="editor">
<div class="field">
<label for="body">Tekst (markdown)</label>
<label for="body">Text (markdown)</label>
<textarea id="body" name="body" required minlength="10" data-preview-source>{{ post.body if post else '' }}</textarea>
<span class="help">Markdown met GFM. Emoji via :shortcode:. Voorbeeld rechts is een live voorbeeld; de gepubliceerde versie wordt op de server gerenderd.</span>
<span class="help">Markdown with GFM. Emoji via :shortcode:. The preview on the right is live; the published version is rendered on the server.</span>
</div>
<div class="field">
<label>Voorbeeld</label>
<label>Preview</label>
<dp-content class="preview prose" data-preview-target></dp-content>
</div>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft" {% if not post or post.status == 'draft' %}selected{% endif %}>Concept</option>
<option value="published" {% if post and post.status == 'published' %}selected{% endif %}>Gepubliceerd</option>
<option value="draft" {% if not post or post.status == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if post and post.status == 'published' %}selected{% endif %}>Published</option>
</select>
</div>
<label class="check"><input type="checkbox" name="is_placeholder" value="true" {% if post and post.is_placeholder %}checked{% endif %}> Markeer als placeholder</label>
<div class="actions"><button class="button" type="submit" data-loading>Opslaan</button><a class="button ghost" href="/admin/posts">Annuleren</a></div>
<label class="check"><input type="checkbox" name="is_placeholder" value="true" {% if post and post.is_placeholder %}checked{% endif %}> Mark as placeholder</label>
<div class="actions"><button class="button" type="submit" data-loading>Save</button><a class="button ghost" href="/admin/posts">Cancel</a></div>
</form>
{% endblock %}
{% block extra_js %}<script type="module" src="{{ static_url('/static/js/utils/EditorPreview.js') }}"></script>{% endblock %}
+7 -7
View File
@@ -1,11 +1,11 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Berichten</h1>
<div class="actions"><a class="button" href="/admin/posts/new">Nieuw bericht</a></div>
<h1>Posts</h1>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a></div>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Titel</th><th>Onderwerp</th><th>Status</th><th>Bijgewerkt</th><th></th></tr></thead>
<thead><tr><th>Title</th><th>Topic</th><th>Status</th><th>Updated</th><th></th></tr></thead>
<tbody>
{% for post in posts %}
<tr>
@@ -14,13 +14,13 @@
<td><span class="status {{ post.status }}">{{ post.status }}</span></td>
<td>{{ local_dt(post.updated_at) }}</td>
<td class="actions">
<a class="button ghost small" href="/admin/posts/{{ post.uid }}/edit">Bewerken</a>
<form method="post" action="/admin/posts/{{ post.uid }}/publish"><button class="button ghost small" type="submit">{{ 'Depubliceren' if post.status == 'published' else 'Publiceren' }}</button></form>
<form method="post" action="/admin/posts/{{ post.uid }}/delete" data-confirm="Bericht naar de prullenbak verplaatsen?"><button class="button danger small" type="submit">Verwijderen</button></form>
<a class="button ghost small" href="/admin/posts/{{ post.uid }}/edit">Edit</a>
<form method="post" action="/admin/posts/{{ post.uid }}/publish"><button class="button ghost small" type="submit">{{ 'Unpublish' if post.status == 'published' else 'Publish' }}</button></form>
<form method="post" action="/admin/posts/{{ post.uid }}/delete" data-confirm="Move this post to the trash?"><button class="button danger small" type="submit">Delete</button></form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Nog geen berichten.</td></tr>
<tr><td colspan="5" class="muted">No posts yet.</td></tr>
{% endfor %}
</tbody>
</table>
+5 -5
View File
@@ -5,8 +5,8 @@
<p class="muted">{{ service.description }}</p>
<div data-service-monitor="detail" data-service="{{ service.name }}">
<div class="tabs" role="tablist" data-tabs>
<button type="button" class="active" data-tab="overview">Overzicht</button>
<button type="button" data-tab="config">Configuratie</button>
<button type="button" class="active" data-tab="overview">Overview</button>
<button type="button" data-tab="config">Configuration</button>
<button type="button" data-tab="logs">Logs</button>
</div>
<section data-panel="overview">
@@ -16,8 +16,8 @@
</div>
<div class="actions">
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="{{ 'stop' if service.enabled else 'start' }}"><button class="button ghost small" type="submit">{{ 'Stop' if service.enabled else 'Start' }}</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="run"><button class="button ghost small" type="submit">Nu draaien</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="clear"><button class="button ghost small" type="submit">Logs wissen</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="run"><button class="button ghost small" type="submit">Run now</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="clear"><button class="button ghost small" type="submit">Clear logs</button></form>
</div>
</section>
<section data-panel="config" hidden>
@@ -42,7 +42,7 @@
</div>
{% endfor %}
{% endfor %}
<div class="actions"><button class="button" type="submit">Opslaan</button></div>
<div class="actions"><button class="button" type="submit">Save</button></div>
</form>
</section>
<section data-panel="logs" hidden>
+2 -2
View File
@@ -4,7 +4,7 @@
<h1>Services</h1>
<div class="table-wrap">
<table class="admin-table" data-service-monitor="index">
<thead><tr><th>Service</th><th>Status</th><th>Laatste run</th><th>Volgende run</th><th></th></tr></thead>
<thead><tr><th>Service</th><th>Status</th><th>Last run</th><th>Next run</th><th></th></tr></thead>
<tbody>
{% for service in services %}
<tr data-service="{{ service.name }}">
@@ -14,7 +14,7 @@
<td data-field="next_run">{{ local_dt(service.next_run) }}</td>
<td class="actions">
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="{{ 'stop' if service.enabled else 'start' }}"><button class="button ghost small" type="submit">{{ 'Stop' if service.enabled else 'Start' }}</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="run"><button class="button ghost small" type="submit">Nu draaien</button></form>
<form method="post" action="/admin/services/{{ service.name }}/command"><input type="hidden" name="verb" value="run"><button class="button ghost small" type="submit">Run now</button></form>
</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -1,14 +1,14 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Instellingen</h1>
<h1>Settings</h1>
<form class="form" method="post" action="/admin/settings">
{% for key, value in settings.items() %}
<div class="field">
<label for="s-{{ key }}">{{ key }}</label>
{% if key == 'site_intro' %}
<textarea id="s-{{ key }}" name="{{ key }}" rows="24">{{ value }}</textarea>
<span class="help">Intro bovenaan de homepage. Markdown; elke regelovergang blijft staan, een lege regel begint een nieuwe alinea. De eerste alinea wordt groot gezet.</span>
<span class="help">Intro at the top of the home page. Markdown; every line break is kept, an empty line starts a new paragraph. The first paragraph is set large.</span>
{% elif key in ('maintenance_mode', 'friendly_404') %}
<select id="s-{{ key }}" name="{{ key }}"><option value="0" {% if value == '0' %}selected{% endif %}>0</option><option value="1" {% if value == '1' %}selected{% endif %}>1</option></select>
{% else %}
@@ -16,6 +16,6 @@
{% endif %}
</div>
{% endfor %}
<div class="actions"><button class="button" type="submit">Opslaan</button></div>
<div class="actions"><button class="button" type="submit">Save</button></div>
</form>
{% endblock %}
+7 -7
View File
@@ -1,19 +1,19 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Statistieken</h1>
<h1>Statistics</h1>
<div class="tabs" role="tablist" data-tabs>
<button type="button" class="active" data-tab="paths">Paden</button>
<button type="button" data-tab="days">Per dag</button>
<button type="button" class="active" data-tab="paths">Paths</button>
<button type="button" data-tab="days">Per day</button>
</div>
<section data-panel="paths">
<table class="admin-table"><thead><tr><th>Pad</th><th>Hits</th></tr></thead><tbody>
{% for row in top_paths %}<tr><td>{{ row.path }}</td><td>{{ row.hits }}</td></tr>{% else %}<tr><td colspan="2" class="muted">Nog geen data.</td></tr>{% endfor %}
<table class="admin-table"><thead><tr><th>Path</th><th>Hits</th></tr></thead><tbody>
{% for row in top_paths %}<tr><td>{{ row.path }}</td><td>{{ row.hits }}</td></tr>{% else %}<tr><td colspan="2" class="muted">No data yet.</td></tr>{% endfor %}
</tbody></table>
</section>
<section data-panel="days" hidden>
<table class="admin-table"><thead><tr><th>Dag</th><th>Hits</th></tr></thead><tbody>
{% for row in daily %}<tr><td>{{ format_date(row.day) }}</td><td>{{ row.hits }}</td></tr>{% else %}<tr><td colspan="2" class="muted">Nog geen data.</td></tr>{% endfor %}
<table class="admin-table"><thead><tr><th>Day</th><th>Hits</th></tr></thead><tbody>
{% for row in daily %}<tr><td>{{ format_date(row.day) }}</td><td>{{ row.hits }}</td></tr>{% else %}<tr><td colspan="2" class="muted">No data yet.</td></tr>{% endfor %}
</tbody></table>
</section>
{% endblock %}
+5 -5
View File
@@ -1,10 +1,10 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Prullenbak</h1>
<h1>Trash</h1>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Soort</th><th>Item</th><th>Verwijderd</th><th>Door</th><th></th></tr></thead>
<thead><tr><th>Kind</th><th>Item</th><th>Deleted</th><th>By</th><th></th></tr></thead>
<tbody>
{% for item in items %}
<tr>
@@ -13,12 +13,12 @@
<td>{{ local_dt(item.deleted_at) }}</td>
<td>{{ item.deleted_by }}</td>
<td class="actions">
<form method="post" action="/admin/trash/restore"><input type="hidden" name="stamp" value="{{ item.deleted_at }}"><button class="button ghost small" type="submit">Herstellen</button></form>
<form method="post" action="/admin/trash/purge" data-confirm="Definitief verwijderen? Dit kan niet ongedaan worden gemaakt."><input type="hidden" name="stamp" value="{{ item.deleted_at }}"><button class="button danger small" type="submit">Definitief</button></form>
<form method="post" action="/admin/trash/restore"><input type="hidden" name="stamp" value="{{ item.deleted_at }}"><button class="button ghost small" type="submit">Restore</button></form>
<form method="post" action="/admin/trash/purge" data-confirm="Delete permanently? This cannot be undone."><input type="hidden" name="stamp" value="{{ item.deleted_at }}"><button class="button danger small" type="submit">Purge</button></form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">De prullenbak is leeg.</td></tr>
<tr><td colspan="5" class="muted">The trash is empty.</td></tr>
{% endfor %}
</tbody>
</table>
+4 -4
View File
@@ -4,17 +4,17 @@
<h1>Accounts</h1>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Naam</th><th>Rol</th><th>Sinds</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>Role</th><th>Since</th><th></th></tr></thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}{% if is_primary_admin(user) %} <span class="muted">(primair)</span>{% endif %}</td>
<td>{{ user.username }}{% if is_primary_admin(user) %} <span class="muted">(primary)</span>{% endif %}</td>
<td>{{ user.role }}</td>
<td>{{ local_dt(user.created_at, 'date') }}</td>
<td>
<form class="actions" method="post" action="/admin/users/{{ user.uid }}/role">
<select name="role" aria-label="Rol"><option value="Admin" {% if user.role == 'Admin' %}selected{% endif %}>Admin</option><option value="Member" {% if user.role == 'Member' %}selected{% endif %}>Member</option></select>
<button class="button ghost small" type="submit">Zet</button>
<select name="role" aria-label="Role"><option value="Admin" {% if user.role == 'Admin' %}selected{% endif %}>Admin</option><option value="Member" {% if user.role == 'Member' %}selected{% endif %}>Member</option></select>
<button class="button ghost small" type="submit">Set</button>
</form>
</td>
</tr>
+6 -6
View File
@@ -2,17 +2,17 @@
{% extends "base.html" %}
{% block content %}
<header class="article-header">
<span class="label">Beheer</span>
<h1>Inloggen</h1>
<span class="label">Admin</span>
<h1>Log in</h1>
</header>
{% if errors %}
<div class="notice error" role="alert"><ul>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
{% endif %}
<form class="form" method="post" action="/auth/login">
<input type="hidden" name="next" value="{{ next or '/admin' }}">
<div class="field"><label for="username">Naam of e-mail</label><input id="username" type="text" name="username" required value="{{ (form or {}).username or '' }}" autocomplete="username"></div>
<div class="field"><label for="password">Wachtwoord</label><input id="password" type="password" name="password" required minlength="6" maxlength="128" autocomplete="current-password"></div>
<label class="check"><input type="checkbox" name="remember" value="true"> Onthoud mij 30 dagen</label>
<div class="actions"><button class="button" type="submit" data-loading>Inloggen</button></div>
<div class="field"><label for="username">Name or email</label><input id="username" type="text" name="username" required value="{{ (form or {}).username or '' }}" autocomplete="username"></div>
<div class="field"><label for="password">Password</label><input id="password" type="password" name="password" required minlength="6" maxlength="128" autocomplete="current-password"></div>
<label class="check"><input type="checkbox" name="remember" value="true"> Remember me for 30 days</label>
<div class="actions"><button class="button" type="submit" data-loading>Log in</button></div>
</form>
{% endblock %}
+14 -14
View File
@@ -2,7 +2,7 @@
{% set seo = seo or {} %}
{% set viewer = current_user(request) %}
<!doctype html>
<html lang="nl">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -21,7 +21,7 @@
<meta property="og:type" content="{{ seo.og_type or 'website' }}">
{% if seo.canonical %}<meta property="og:url" content="{{ seo.canonical }}">{% endif %}
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="og:locale" content="nl_NL">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ seo.title or app_title }}">
<meta name="twitter:description" content="{{ seo.description or '' }}">
@@ -40,23 +40,23 @@
{% block extra_head %}{% endblock %}
</head>
<body>
<a class="sr-only" href="#main">Naar inhoud</a>
<a class="sr-only" href="#main">Skip to content</a>
<header class="site-header">
<nav class="site-nav" aria-label="Hoofdmenu">
<nav class="site-nav" aria-label="Main menu">
<a class="brand" href="/">MOLODETZ</a>
<button class="nav-toggle" type="button" data-nav-toggle aria-expanded="false" aria-controls="nav-links">Menu</button>
<div class="nav-links" id="nav-links">
<a href="/rol" class="{{ nav_active(request, '/rol') }}">Rol</a>
<a href="/standaard" class="{{ nav_active(request, '/standaard') }}">Standaard</a>
<a href="/roll" class="{{ nav_active(request, '/roll') }}">Roll</a>
<a href="/standard" class="{{ nav_active(request, '/standard') }}">Standard</a>
<a href="/flyers" class="{{ nav_active(request, '/flyers') }}">Flyers</a>
<a href="/memes" class="{{ nav_active(request, '/memes') }}">Memes</a>
<a href="/mensen" class="{{ nav_active(request, '/mensen') }}">Mensen</a>
<a href="/binnen" class="{{ nav_active(request, '/binnen') }}">Binnen</a>
<a href="/people" class="{{ nav_active(request, '/people') }}">People</a>
<a href="/join" class="{{ nav_active(request, '/join') }}">Join</a>
{% if viewer %}
<span class="nav-account">
{% if is_admin(viewer) %}<a href="/admin" class="{{ nav_active(request, '/admin') }}">Beheer</a>{% endif %}
<a href="/notifications" class="{{ nav_active(request, '/notifications') }}">Meldingen{% set unread = unread_count(request) %}{% if unread %}<span class="badge-count">{{ unread }}</span>{% endif %}</a>
<form method="post" action="/auth/logout"><button type="submit">Uit</button></form>
{% if is_admin(viewer) %}<a href="/admin" class="{{ nav_active(request, '/admin') }}">Admin</a>{% endif %}
<a href="/notifications" class="{{ nav_active(request, '/notifications') }}">Notifications{% set unread = unread_count(request) %}{% if unread %}<span class="badge-count">{{ unread }}</span>{% endif %}</a>
<form method="post" action="/auth/logout"><button type="submit">Log out</button></form>
</span>
{% endif %}
</div>
@@ -72,15 +72,15 @@
<div class="inner">
<span class="tagline">Molodetz. {{ setting('site_tagline') }}</span>
<a href="/docs">Docs</a>
<a href="/voorwaarden">Voorwaarden</a>
<a href="/terms">Terms</a>
<a href="/privacy">Privacy</a>
{% if not viewer %}<a href="/auth/login" rel="nofollow">Inloggen</a>{% endif %}
{% if not viewer %}<a href="/auth/login" rel="nofollow">Log in</a>{% endif %}
<span>&copy; {{ current_year() }}</span>
</div>
</footer>
<dp-toast></dp-toast>
<dp-lightbox></dp-lightbox>
<span class="response-time" title="Server-renderingstijd">{{ response_time(request) }}</span>
<span class="response-time" title="Server render time">{{ response_time(request) }}</span>
<script type="module" src="{{ static_url('/static/js/Application.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
+8 -8
View File
@@ -3,22 +3,22 @@
{% block docs_content %}
<h1>API: {{ group.title }}</h1>
<p>{{ group.description }}</p>
<p class="muted">Elke HTML-route geeft JSON terug met <code>Accept: application/json</code>. Authenticatie: sessiecookie, <code>X-API-KEY</code>, <code>Authorization: Bearer</code> of <code>Basic</code>.</p>
<p class="muted">Every HTML route returns JSON with <code>Accept: application/json</code>. Authentication: session cookie, <code>X-API-KEY</code>, <code>Authorization: Bearer</code> or <code>Basic</code>.</p>
{% for item in endpoints %}
<section class="endpoint" data-endpoint data-method="{{ item.method }}" data-path="{{ item.path }}" data-auth="{{ item.auth }}" data-negotiation="{{ item.negotiation }}" data-destructive="{{ '1' if item.destructive else '0' }}">
<div class="signature"><span class="method">{{ item.method }}</span><code>{{ item.path }}</code><span class="auth">{{ item.min_role }}</span></div>
<strong>{{ item.title }}</strong>
{% if item.description %}<p>{{ item.description }}</p>{% endif %}
{% if item.fields %}
<table class="admin-table"><thead><tr><th>Veld</th><th>Type</th><th>Plek</th><th>Verplicht</th></tr></thead><tbody>
{% for f in item.fields %}<tr><td><code>{{ f.name }}</code>{% if f.enum %}<div class="allowed">Toegestaan: {{ f.enum | join(', ') }}</div>{% endif %}</td><td>{{ f.type }}</td><td>{{ f.location }}</td><td>{{ 'ja' if f.required else 'nee' }}</td></tr>{% endfor %}
<table class="admin-table"><thead><tr><th>Field</th><th>Type</th><th>Location</th><th>Required</th></tr></thead><tbody>
{% for f in item.fields %}<tr><td><code>{{ f.name }}</code>{% if f.enum %}<div class="allowed">Allowed: {{ f.enum | join(', ') }}</div>{% endif %}</td><td>{{ f.type }}</td><td>{{ f.location }}</td><td>{{ 'yes' if f.required else 'no' }}</td></tr>{% endfor %}
</tbody></table>
{% endif %}
<div class="tabs" data-tabs>
<button type="button" class="active" data-tab="curl">cURL</button>
<button type="button" data-tab="js">JS</button>
<button type="button" data-tab="python">Python</button>
<button type="button" data-tab="expected">Verwacht</button>
<button type="button" data-tab="expected">Expected</button>
{% if item.interactive %}<button type="button" data-tab="live">Live</button>{% endif %}
</div>
<pre data-panel="curl">{{ item.curl }}</pre>
@@ -26,7 +26,7 @@
console.log(await response.json());</pre>
<pre data-panel="python" hidden>import httpx
print(httpx.request("{{ item.method }}", BASE + "{{ item.path }}", headers={"Accept": "application/json", "X-API-KEY": API_KEY}).json())</pre>
<pre data-panel="expected" hidden>{{ item.sample | tojson(indent=2) if item.sample is not none else 'Geen voorbeeld' }}</pre>
<pre data-panel="expected" hidden>{{ item.sample | tojson(indent=2) if item.sample is not none else 'No example' }}</pre>
{% if item.interactive %}
<div data-panel="live" hidden class="tester">
{% for f in item.fields %}
@@ -35,8 +35,8 @@ print(httpx.request("{{ item.method }}", BASE + "{{ item.path }}", headers={"Acc
{% else %}<input type="text" data-param="{{ f.name }}" data-location="{{ f.location }}" value="{{ f.example or '' }}">{% endif %}
</div>
{% endfor %}
<div class="actions"><button class="button small" type="button" data-send {% if item.auth != 'public' and not viewer %}disabled title="Log in om te versturen"{% endif %}>Verstuur</button></div>
<pre data-response>Nog niet verstuurd.</pre>
<div class="actions"><button class="button small" type="button" data-send {% if item.auth != 'public' and not viewer %}disabled title="Log in to send"{% endif %}>Send</button></div>
<pre data-response>Not sent yet.</pre>
</div>
{% endif %}
</section>
@@ -45,7 +45,7 @@ print(httpx.request("{{ item.method }}", BASE + "{{ item.path }}", headers={"Acc
<h2>Live services</h2>
{% for service in services %}
<section class="endpoint"><strong>{{ service.title }}</strong> <code>{{ service.name }}</code><p>{{ service.description }}</p>
<p class="muted">Velden: {% for f in service.fields %}<code>{{ f.key }}</code> ({{ f.type }}){% if not loop.last %}, {% endif %}{% endfor %}</p></section>
<p class="muted">Fields: {% for f in service.fields %}<code>{{ f.key }}</code> ({{ f.type }}){% if not loop.last %}, {% endif %}{% endfor %}</p></section>
{% endfor %}
{% endif %}
{% endblock %}
+3 -3
View File
@@ -13,16 +13,16 @@
<ul>{% for page in pages %}<li><a href="/docs/{{ page.slug }}" class="{{ nav_active(request, '/docs/' ~ page.slug, exact=True) }}">{{ page.title }}</a></li>{% endfor %}</ul>
</div>
{% endif %}
{% if section == 'Bouwen met de API' %}
{% if section == 'Building with the API' %}
<div>
<h4>API-groepen</h4>
<h4>API groups</h4>
<ul>{% for group in api_groups %}<li><a href="/docs/api/{{ group.slug }}" class="{{ nav_active(request, '/docs/api/' ~ group.slug, exact=True) }}">{{ group.title }}</a></li>{% endfor %}</ul>
</div>
{% endif %}
{% endfor %}
<div>
<h4>Downloads</h4>
<ul><li><a href="/docs/download.md">download.md</a></li><li><a href="/docs/download.html">download.html</a></li><li><a href="/voorwaarden">Voorwaarden</a></li><li><a href="/privacy">Privacy</a></li><li><a href="/swagger">OpenAPI</a></li></ul>
<ul><li><a href="/docs/download.md">download.md</a></li><li><a href="/docs/download.html">download.html</a></li><li><a href="/terms">Terms</a></li><li><a href="/privacy">Privacy</a></li><li><a href="/swagger">OpenAPI</a></li></ul>
</div>
</aside>
<article class="docs-content prose">
+17
View File
@@ -0,0 +1,17 @@
# Admin and services
*Admin, Services* lists every background service. Each service has an overview, configuration and logs. Start, stop, run now and clear logs are database writes the service picks up within a second.
| Service | Purpose |
|---------|---------|
| presence | publishes the online set |
| housekeeping | clears expired sessions, prunes the audit log |
| backup | works through the backup queue and schedules automatic backups |
## Intro
The intro at the top of the home page lives in the `site_intro` setting (markdown, line breaks are kept). Edit it under *Settings*; the first paragraph is set large.
## Maintenance mode
Set `maintenance_mode` to `1` under *Settings*. Administrators keep seeing everything.
+17 -13
View File
@@ -1,24 +1,28 @@
# API en authenticatie
# API and authentication
Elke route heeft vier gezichten: HTML, JSON, documentatie en (waar geselecteerd) een assistent-tool. Vraag JSON met `Accept: application/json`.
Every route has four faces: HTML, JSON, documentation and (where selected) an assistant tool. Ask for JSON with `Accept: application/json`.
## Authenticatie
## Authentication
Volgorde van resolutie:
Resolution order:
1. `session` cookie (64 hex tekens) na inloggen op `/auth/login`.
2. `X-API-KEY` header met je API-sleutel.
3. `Authorization: Bearer <sleutel>`.
4. `Authorization: Basic` met naam of e-mail en wachtwoord.
1. `session` cookie (64 hex characters) after logging in at `/auth/login`.
2. `X-API-KEY` header with your API key.
3. `Authorization: Bearer <key>`.
4. `Authorization: Basic` with name or email and password.
Er is geen JWT en geen OAuth.
There is no JWT and no OAuth.
## Voorbeeld
## Example
```bash
curl -H 'Accept: application/json' https://molodetz.nl/rol
curl -H 'Accept: application/json' https://molodetz.nl/roll
```
## Fouten
## Errors
Fouten hebben de vorm `{"error": {"status": 404, "message": "..."}}`. Validatiefouten geven `422` met `{"error": "validation", "fields": [...], "messages": [...]}`.
Errors have the shape `{"error": {"status": 404, "message": "..."}}`. Validation errors return `422` with `{"error": "validation", "fields": [...], "messages": [...]}`.
## Old paths
The Dutch paths from before (`/rol`, `/standaard`, `/mensen`, `/binnen`, `/voorwaarden`) answer with a 301 to their English replacement. Query strings are kept.
@@ -0,0 +1,18 @@
# Architecture
FastAPI with Jinja2, rendered on the server. SQLite through `dataset`, synchronous and deliberately without a threadpool. Sessions with PBKDF2-SHA256 passwords.
## Layers
- `molodetz/database/`: one file per subject, always import from the package root.
- `molodetz/routers/`: mirrors the URL path.
- `molodetz/services/`: background services under one supervisor with a file lock.
- `molodetz/templates/`: one Jinja environment in `templating.py`.
## Deleting
Everything is soft deleted with `deleted_at` and `deleted_by`. Only cleanup jobs delete for real.
## Static files
Assets go through `static_url()`, which versions them under `/static/v<version>/`. Those are immutably cacheable for a year.
@@ -1,18 +0,0 @@
# Architectuur
FastAPI met Jinja2, server-side gerenderd. SQLite via `dataset`, synchroon en bewust zonder threadpool. Sessies met PBKDF2-SHA256 wachtwoorden.
## Lagen
- `molodetz/database/`: één bestand per onderwerp, importeer altijd vanaf de package-root.
- `molodetz/routers/`: spiegelt het URL-pad.
- `molodetz/services/`: achtergrondservices onder één supervisor met een bestandsslot.
- `molodetz/templates/`: één Jinja-omgeving in `templating.py`.
## Verwijderen
Alles wordt zacht verwijderd met `deleted_at` en `deleted_by`. Alleen opruimtaken verwijderen hard.
## Statische bestanden
Assets lopen via `static_url()`, wat ze versioneert onder `/static/v<versie>/`. Die zijn een jaar onveranderlijk cachebaar.
+3 -3
View File
@@ -1,6 +1,6 @@
# Back-ups en onderhoud
# Backups and maintenance
Doelen: `database`, `uploads`, `keys`, `full`. Archieven worden gecomprimeerd met zstd of brotli en staan onder `data/backups/`. Alleen de primaire beheerder kan downloaden.
Targets: `database`, `uploads`, `keys`, `full`. Archives are compressed with zstd or brotli and live under `data/backups/`. Only the primary administrator can download them.
## CLI
@@ -11,4 +11,4 @@ molodetz backups prune --keep 14 --dry-run
molodetz system prune --dry-run
```
Het onderhoudsscript `bin/maintenance.sh` draait alle prunes en toont schijfgebruik voor en na.
The maintenance script `bin/maintenance.sh` runs every prune and shows disk usage before and after.
-15
View File
@@ -1,15 +0,0 @@
# Beheer en services
Onder *Beheer, Services* staan alle achtergrondservices. Elke service heeft een overzicht, configuratie en logs. Starten, stoppen, nu draaien en logs wissen zijn databaseschrijfacties waar de service binnen een seconde op reageert.
| Service | Doel |
|---------|------|
| presence | publiceert de online-set |
| housekeeping | ruimt verlopen sessies op, snoeit het auditlog |
| backup | verwerkt de back-upwachtrij en plant automatische back-ups |
## Onderhoudsmodus
De intro bovenaan de homepage staat in de instelling `site_intro` (markdown, regelovergangen blijven staan). Bewerk hem onder *Instellingen*; de eerste alinea wordt groot gezet.
Zet `maintenance_mode` op `1` onder *Instellingen*. Beheerders blijven alles zien.

Some files were not shown because too many files have changed in this diff Show More