diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d8da1e6..7edf920 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,27 +1,28 @@ -# 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/`, 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/`, 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 ` 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 ` 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. diff --git a/COMPLIANCE.md b/COMPLIANCE.md index c651280..6552cec 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -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). diff --git a/README.md b/README.md index ab4c675..75fe1e2 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,42 @@ # 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`. diff --git a/SELECTION.md b/SELECTION.md index 8d89a17..7d05af3 100644 --- a/SELECTION.md +++ b/SELECTION.md @@ -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 diff --git a/bin/maintenance.sh b/bin/maintenance.sh index 04c6ccc..e884ca8 100755 --- a/bin/maintenance.sh +++ b/bin/maintenance.sh @@ -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 diff --git a/events.md b/events.md index f31fd8d..36fb7aa 100644 --- a/events.md +++ b/events.md @@ -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 | diff --git a/locustfile.py b/locustfile.py index eca3051..4b4532e 100644 --- a/locustfile.py +++ b/locustfile.py @@ -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]", ) diff --git a/molodetz/bootstrap.py b/molodetz/bootstrap.py index fc3d38a..75a4ad3 100644 --- a/molodetz/bootstrap.py +++ b/molodetz/bootstrap.py @@ -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 diff --git a/molodetz/cli/commands.py b/molodetz/cli/commands.py index 84bafdc..dbd1752 100644 --- a/molodetz/cli/commands.py +++ b/molodetz/cli/commands.py @@ -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 diff --git a/molodetz/cli/main.py b/molodetz/cli/main.py index d9d906a..9294ed1 100644 --- a/molodetz/cli/main.py +++ b/molodetz/cli/main.py @@ -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 diff --git a/molodetz/constants.py b/molodetz/constants.py index ea3004d..3142073 100644 --- a/molodetz/constants.py +++ b/molodetz/constants.py @@ -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", diff --git a/molodetz/content.py b/molodetz/content.py index 96970ee..68a458b 100644 --- a/molodetz/content.py +++ b/molodetz/content.py @@ -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 diff --git a/molodetz/database/__init__.py b/molodetz/database/__init__.py index d1747e1..fd79bf1 100644 --- a/molodetz/database/__init__.py +++ b/molodetz/database/__init__.py @@ -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", diff --git a/molodetz/database/init_db.py b/molodetz/database/init_db.py index 8ea870f..106d8f6 100644 --- a/molodetz/database/init_db.py +++ b/molodetz/database/init_db.py @@ -41,7 +41,7 @@ TABLE_EXAMPLES = { "slug": "x", "title": "x", "body": "x", - "topic": "rol", + "topic": "roll", "status": "draft", "is_placeholder": False, "user_uid": "x", diff --git a/molodetz/database/posts.py b/molodetz/database/posts.py index 5f75ca2..ab3682c 100644 --- a/molodetz/database/posts.py +++ b/molodetz/database/posts.py @@ -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: diff --git a/molodetz/docs_api/__init__.py b/molodetz/docs_api/__init__.py index 7a69201..e72666e 100644 --- a/molodetz/docs_api/__init__.py +++ b/molodetz/docs_api/__init__.py @@ -1,10 +1,10 @@ # retoor -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(): diff --git a/molodetz/docs_api/account.py b/molodetz/docs_api/account.py index ab65b70..085f0d5 100644 --- a/molodetz/docs_api/account.py +++ b/molodetz/docs_api/account.py @@ -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"}), ], } diff --git a/molodetz/docs_api/admin.py b/molodetz/docs_api/admin.py index 44be905..1cad3eb 100644 --- a/molodetz/docs_api/admin.py +++ b/molodetz/docs_api/admin.py @@ -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": []}), ], } diff --git a/molodetz/docs_api/community.py b/molodetz/docs_api/community.py deleted file mode 100644 index a3f4c5a..0000000 --- a/molodetz/docs_api/community.py +++ /dev/null @@ -1,25 +0,0 @@ -# retoor -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"}, - ), - ], -} diff --git a/molodetz/docs_api/content.py b/molodetz/docs_api/content.py index b855781..d1f22ef 100644 --- a/molodetz/docs_api/content.py +++ b/molodetz/docs_api/content.py @@ -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}), ], } diff --git a/molodetz/docs_api/join.py b/molodetz/docs_api/join.py new file mode 100644 index 0000000..87ec879 --- /dev/null +++ b/molodetz/docs_api/join.py @@ -0,0 +1,25 @@ +# retoor +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"}, + ), + ], +} diff --git a/molodetz/docs_export.py b/molodetz/docs_export.py index b56347c..e16c944 100644 --- a/molodetz/docs_export.py +++ b/molodetz/docs_export.py @@ -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"
{render_page(page['slug'])}
") 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"
  • {escape(item['method'])} {escape(item['path'])} ({escape(item['auth'])}): {escape(item['title'])}
  • " @@ -32,6 +32,6 @@ def export_html(viewer_is_admin): ) body.append(f"

    API: {escape(group['title'])}

      {items}
    ") return ( - "Molodetz documentatie" - f"

    Molodetz documentatie

    {''.join(body)}" + "Molodetz documentation" + f"

    Molodetz documentation

    {''.join(body)}" ) diff --git a/molodetz/docs_prose.py b/molodetz/docs_prose.py index 95cb838..24c56bf 100644 --- a/molodetz/docs_prose.py +++ b/molodetz/docs_prose.py @@ -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 diff --git a/molodetz/docs_search.py b/molodetz/docs_search.py index add5aea..e292c8a 100644 --- a/molodetz/docs_search.py +++ b/molodetz/docs_search.py @@ -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] diff --git a/molodetz/friendly404.py b/molodetz/friendly404.py index a6a0fde..0518793 100644 --- a/molodetz/friendly404.py +++ b/molodetz/friendly404.py @@ -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.", ) diff --git a/molodetz/gallery.py b/molodetz/gallery.py index 6b68114..9c2b05c 100644 --- a/molodetz/gallery.py +++ b/molodetz/gallery.py @@ -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."), diff --git a/molodetz/link_check.py b/molodetz/link_check.py index cd032f7..49ff8a9 100644 --- a/molodetz/link_check.py +++ b/molodetz/link_check.py @@ -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} diff --git a/molodetz/main.py b/molodetz/main.py index 99ae839..2f91696 100644 --- a/molodetz/main.py +++ b/molodetz/main.py @@ -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) diff --git a/molodetz/models.py b/molodetz/models.py index 1afece9..438cc79 100644 --- a/molodetz/models.py +++ b/molodetz/models.py @@ -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 diff --git a/molodetz/rendering.py b/molodetz/rendering.py index 90933ab..4da8080 100644 --- a/molodetz/rendering.py +++ b/molodetz/rendering.py @@ -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'@{m.group(1)}', escaped) + replaced = MENTION_PATTERN.sub(lambda m: f'@{m.group(1)}', escaped) fragment = BeautifulSoup(replaced, "html.parser") node.replace_with(*list(fragment.contents)) return str(soup) diff --git a/molodetz/routers/admin/audit.py b/molodetz/routers/admin/audit.py index 493dba5..c3e13a2 100644 --- a/molodetz/routers/admin/audit.py +++ b/molodetz/routers/admin/audit.py @@ -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) diff --git a/molodetz/routers/admin/backups.py b/molodetz/routers/admin/backups.py index 4fa5671..15c7d2d 100644 --- a/molodetz/routers/admin/backups.py +++ b/molodetz/routers/admin/backups.py @@ -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), diff --git a/molodetz/routers/admin/index.py b/molodetz/routers/admin/index.py index 5a3354d..18e54c7 100644 --- a/molodetz/routers/admin/index.py +++ b/molodetz/routers/admin/index.py @@ -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) diff --git a/molodetz/routers/admin/joins.py b/molodetz/routers/admin/joins.py index 510536b..97277f5 100644 --- a/molodetz/routers/admin/joins.py +++ b/molodetz/routers/admin/joins.py @@ -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) diff --git a/molodetz/routers/admin/posts.py b/molodetz/routers/admin/posts.py index 88e49b8..c283426 100644 --- a/molodetz/routers/admin/posts.py +++ b/molodetz/routers/admin/posts.py @@ -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}) diff --git a/molodetz/routers/admin/services.py b/molodetz/routers/admin/services.py index 5f82632..bc59eb8 100644 --- a/molodetz/routers/admin/services.py +++ b/molodetz/routers/admin/services.py @@ -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: diff --git a/molodetz/routers/admin/settings.py b/molodetz/routers/admin/settings.py index c593e22..07a023f 100644 --- a/molodetz/routers/admin/settings.py +++ b/molodetz/routers/admin/settings.py @@ -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") diff --git a/molodetz/routers/admin/stats.py b/molodetz/routers/admin/stats.py index 5d06a6b..d8483f0 100644 --- a/molodetz/routers/admin/stats.py +++ b/molodetz/routers/admin/stats.py @@ -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) diff --git a/molodetz/routers/admin/trash.py b/molodetz/routers/admin/trash.py index 8f00707..b61c84e 100644 --- a/molodetz/routers/admin/trash.py +++ b/molodetz/routers/admin/trash.py @@ -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) diff --git a/molodetz/routers/admin/users.py b/molodetz/routers/admin/users.py index d84876e..a020a6c 100644 --- a/molodetz/routers/admin/users.py +++ b/molodetz/routers/admin/users.py @@ -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) diff --git a/molodetz/routers/auth.py b/molodetz/routers/auth.py index cc8811b..8c1750d 100644 --- a/molodetz/routers/auth.py +++ b/molodetz/routers/auth.py @@ -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), diff --git a/molodetz/routers/docs.py b/molodetz/routers/docs.py index 298c70b..ecaa06a 100644 --- a/molodetz/routers/docs.py +++ b/molodetz/routers/docs.py @@ -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)) diff --git a/molodetz/routers/binnen.py b/molodetz/routers/join.py similarity index 76% rename from molodetz/routers/binnen.py rename to molodetz/routers/join.py index 077d685..c7531f4 100644 --- a/molodetz/routers/binnen.py +++ b/molodetz/routers/join.py @@ -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"]}) diff --git a/molodetz/routers/legacy.py b/molodetz/routers/legacy.py new file mode 100644 index 0000000..6519abf --- /dev/null +++ b/molodetz/routers/legacy.py @@ -0,0 +1,58 @@ +# retoor +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"]) diff --git a/molodetz/routers/notifications.py b/molodetz/routers/notifications.py index ad602c5..2297888 100644 --- a/molodetz/routers/notifications.py +++ b/molodetz/routers/notifications.py @@ -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) diff --git a/molodetz/routers/mensen.py b/molodetz/routers/people.py similarity index 77% rename from molodetz/routers/mensen.py rename to molodetz/routers/people.py index 5da881c..9810d10 100644 --- a/molodetz/routers/mensen.py +++ b/molodetz/routers/people.py @@ -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) diff --git a/molodetz/routers/posts.py b/molodetz/routers/posts.py index 6358d9d..c8a8286 100644 --- a/molodetz/routers/posts.py +++ b/molodetz/routers/posts.py @@ -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"]) diff --git a/molodetz/routers/profile.py b/molodetz/routers/profile.py index c1a08df..38a6b6e 100644 --- a/molodetz/routers/profile.py +++ b/molodetz/routers/profile.py @@ -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']}") diff --git a/molodetz/routers/rol.py b/molodetz/routers/roll.py similarity index 68% rename from molodetz/routers/rol.py rename to molodetz/routers/roll.py index 2913ed1..91aeca2 100644 --- a/molodetz/routers/rol.py +++ b/molodetz/routers/roll.py @@ -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") diff --git a/molodetz/routers/seo.py b/molodetz/routers/seo.py index 07f24dc..eff85d7 100644 --- a/molodetz/routers/seo.py +++ b/molodetz/routers/seo.py @@ -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 = ['', ''] for path, modified in urls: diff --git a/molodetz/routers/standaard.py b/molodetz/routers/standard.py similarity index 65% rename from molodetz/routers/standaard.py rename to molodetz/routers/standard.py index 1a2f541..911c043 100644 --- a/molodetz/routers/standaard.py +++ b/molodetz/routers/standard.py @@ -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") diff --git a/molodetz/routers/voorwaarden.py b/molodetz/routers/terms.py similarity index 75% rename from molodetz/routers/voorwaarden.py rename to molodetz/routers/terms.py index e4f9745..80dd7f0 100644 --- a/molodetz/routers/voorwaarden.py +++ b/molodetz/routers/terms.py @@ -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") diff --git a/molodetz/seo.py b/molodetz/seo.py index c3b1733..9340e9b 100644 --- a/molodetz/seo.py +++ b/molodetz/seo.py @@ -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(" 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.", } diff --git a/molodetz/services/backup.py b/molodetz/services/backup.py index 8408e81..76c06fb 100644 --- a/molodetz/services/backup.py +++ b/molodetz/services/backup.py @@ -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, } diff --git a/molodetz/services/base.py b/molodetz/services/base.py index 4b3be66..6995ab5 100644 --- a/molodetz/services/base.py +++ b/molodetz/services/base.py @@ -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()) diff --git a/molodetz/services/housekeeping.py b/molodetz/services/housekeeping.py index 06fcf53..b41e5cc 100644 --- a/molodetz/services/housekeeping.py +++ b/molodetz/services/housekeeping.py @@ -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, } diff --git a/molodetz/services/presence.py b/molodetz/services/presence.py index c7b9dea..c4c7f14 100644 --- a/molodetz/services/presence.py +++ b/molodetz/services/presence.py @@ -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, } diff --git a/molodetz/static/js/Application.js b/molodetz/static/js/Application.js index ef1fa25..58f5a52 100644 --- a/molodetz/static/js/Application.js +++ b/molodetz/static/js/Application.js @@ -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); } }); }); diff --git a/molodetz/static/js/components/Chat.js b/molodetz/static/js/components/Chat.js index 96de516..3a5f723 100644 --- a/molodetz/static/js/components/Chat.js +++ b/molodetz/static/js/components/Chat.js @@ -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(); diff --git a/molodetz/static/js/components/Code.js b/molodetz/static/js/components/Code.js index 29cc2e6..ae5baff 100644 --- a/molodetz/static/js/components/Code.js +++ b/molodetz/static/js/components/Code.js @@ -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); diff --git a/molodetz/static/js/components/Dialog.js b/molodetz/static/js/components/Dialog.js index 438b9fa..57eef5f 100644 --- a/molodetz/static/js/components/Dialog.js +++ b/molodetz/static/js/components/Dialog.js @@ -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"); diff --git a/molodetz/static/js/components/Lightbox.js b/molodetz/static/js/components/Lightbox.js index d4c11a5..e1ed676 100644 --- a/molodetz/static/js/components/Lightbox.js +++ b/molodetz/static/js/components/Lightbox.js @@ -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 = []; diff --git a/molodetz/static/js/components/Upload.js b/molodetz/static/js/components/Upload.js index 35972fc..806a445 100644 --- a/molodetz/static/js/components/Upload.js +++ b/molodetz/static/js/components/Upload.js @@ -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) => { diff --git a/molodetz/static/js/utils/ApiTester.js b/molodetz/static/js/utils/ApiTester.js index e5833e5..ed4f4b7 100644 --- a/molodetz/static/js/utils/ApiTester.js +++ b/molodetz/static/js/utils/ApiTester.js @@ -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); } diff --git a/molodetz/static/js/utils/LocalTime.js b/molodetz/static/js/utils/LocalTime.js index ba3d636..79ae06f 100644 --- a/molodetz/static/js/utils/LocalTime.js +++ b/molodetz/static/js/utils/LocalTime.js @@ -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))); diff --git a/molodetz/static/js/utils/TermsGate.js b/molodetz/static/js/utils/TermsGate.js index 79418a5..4f6f66a 100644 --- a/molodetz/static/js/utils/TermsGate.js +++ b/molodetz/static/js/utils/TermsGate.js @@ -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" }, diff --git a/molodetz/stealth.py b/molodetz/stealth.py index ba1b659..34d49a8 100644 --- a/molodetz/stealth.py +++ b/molodetz/stealth.py @@ -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") diff --git a/molodetz/templates/_avatar_link.html b/molodetz/templates/_avatar_link.html index 6b420af..2898369 100644 --- a/molodetz/templates/_avatar_link.html +++ b/molodetz/templates/_avatar_link.html @@ -1,5 +1,5 @@ - + diff --git a/molodetz/templates/_breadcrumbs.html b/molodetz/templates/_breadcrumbs.html index e6f8557..b26915b 100644 --- a/molodetz/templates/_breadcrumbs.html +++ b/molodetz/templates/_breadcrumbs.html @@ -1,5 +1,5 @@ -