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:
+20
-19
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,7 +41,7 @@ TABLE_EXAMPLES = {
|
||||
"slug": "x",
|
||||
"title": "x",
|
||||
"body": "x",
|
||||
"topic": "rol",
|
||||
"topic": "roll",
|
||||
"status": "draft",
|
||||
"is_placeholder": False,
|
||||
"user_uid": "x",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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": []}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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."),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]})
|
||||
@@ -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"])
|
||||
@@ -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,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"])
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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,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,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,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,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 %}
|
||||
|
||||
@@ -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">×</button></header>
|
||||
<header><h2 id="{{ id }}-title">{{ title }}</h2><button class="modal-close" type="button" aria-label="Close">×</button></header>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>© {{ 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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user