From 63b7ca0853ae43f1d2695d933fe840e4f335fa6f Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Tue, 18 Aug 2026 03:33:10 +0200 Subject: [PATCH] cmap functions, email, architecture documentation. --- Makefile.mk | 20 +- README.md | 108 +++- architecture/cmaps/architectuur.json | 186 ++++++ architecture/cmaps/kwaliteitskenmerken.json | 195 +++++++ architecture/import.bak | 538 ++++++++++++++++++ architecture/import.rkt | 562 +++++++++++++++++++ architecture/manifest.rktd | 58 ++ architecture/pages/analyseerbaarheid.md | 76 +++ architecture/pages/architectuur.md | 52 ++ architecture/pages/beheer-en-evolutie.md | 99 ++++ architecture/pages/beveiliging.md | 60 ++ architecture/pages/code-regels.md | 102 ++++ architecture/pages/data-en-versies.md | 86 +++ architecture/pages/gedrag.md | 61 ++ architecture/pages/modulariteit.md | 62 ++ architecture/pages/onderhoudbaarheid.md | 69 +++ architecture/pages/performance.md | 80 +++ architecture/pages/structuur-en-samenhang.md | 67 +++ architecture/pages/testbaarheid.md | 66 +++ info.rkt | 3 +- private/auth.rkt | 151 ++++- private/cmap-storage.rkt | 116 +++- private/mail.rkt | 224 ++++++++ private/migrations.rkt | 69 ++- scrbl/racket-wiki.scrbl | 51 ++ server.rkt | 281 +++++++++- static/cmap/cmap-racket-wiki.js | 6 +- static/cmap/cmap.css | 68 +++ static/cmap/cmap.js | 2 +- static/css/wiki.css | 13 +- static/index.html | 61 ++ static/js/wiki.js | 414 +++++++++++++- translate.rkt | 48 +- 33 files changed, 3974 insertions(+), 80 deletions(-) create mode 100644 architecture/cmaps/architectuur.json create mode 100644 architecture/cmaps/kwaliteitskenmerken.json create mode 100644 architecture/import.bak create mode 100644 architecture/import.rkt create mode 100644 architecture/manifest.rktd create mode 100644 architecture/pages/analyseerbaarheid.md create mode 100644 architecture/pages/architectuur.md create mode 100644 architecture/pages/beheer-en-evolutie.md create mode 100644 architecture/pages/beveiliging.md create mode 100644 architecture/pages/code-regels.md create mode 100644 architecture/pages/data-en-versies.md create mode 100644 architecture/pages/gedrag.md create mode 100644 architecture/pages/modulariteit.md create mode 100644 architecture/pages/onderhoudbaarheid.md create mode 100644 architecture/pages/performance.md create mode 100644 architecture/pages/structuur-en-samenhang.md create mode 100644 architecture/pages/testbaarheid.md create mode 100644 private/mail.rkt diff --git a/Makefile.mk b/Makefile.mk index f6520cd..7cb7834 100644 --- a/Makefile.mk +++ b/Makefile.mk @@ -2,8 +2,7 @@ (require rash-coreutils rash-makefile - rash-git-cli - racket-sprintf) + rash-git-cli) (makefile racket-wiki @@ -14,20 +13,6 @@ (target ver (displayln (git* next-version))) - (target status - (map (λ (e) - (displayln - (sprintf "%-40.40s: %-15.15s %-15.15s" - (caddr e) (cadr e) (car e)))) - (git 'status))) - - (target sync - { - git add -A - git commit - git push - }) - (target zip (deps clean) (zip-package #:exclude '("wiki-data"))) @@ -38,8 +23,5 @@ (for-each (λ (f) (displayln f) (rm-f f)) (list-files "scrbl" #px"[.](css|js|html)$")) ) - (target refresh - (refresh-makefile)) - ) \ No newline at end of file diff --git a/README.md b/README.md index 1a0564e..c59d6a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Version 0.2.31 adds page namespaces as database metadata and extends wiki references to forms such as `RWS:ModelTreeWalker` and `[roadmap](racket:roadmap)`. Todo items and bookmarks are grouped by namespace. The source has also been documented more thoroughly, especially `static/js/wiki.js`. -Current development version: **0.2.84**. +Current development version: **0.2.94**. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. @@ -289,6 +289,46 @@ Untrusted uploads are not blindly rendered inline. Only common raster image form For internet-facing deployment, put the server behind a TLS terminating reverse proxy, use `--secure-cookie`, and add normal operational controls such as backups, access logging and upload limits appropriate to the installation. +## Architecture documentation import + +The package contains a coherent Dutch architecture documentation set under +`architecture/`: twelve namespaced wiki pages and two native concept maps. The +pages cover structure, behaviour, data and versioning, modularity, +maintainability, analyzability, testability, long-term performance, security, +coding rules, and architecture evolution. + +Validate and preview the import first: + +```text +racket architecture/import.rkt --data ./wiki-data --dry-run +``` + +Import the set and record a recognizable author in page and CMap history: + +```text +racket architecture/import.rkt --data ./wiki-data --author "Hans Dijkema" +``` + +From DrRacket or another module, the equivalent convenience call is: + +```racket +(require racket-wiki/architecture/import) + +(import-racket-wiki-architecture-from-data-directory! + "wiki-data" + #:author "Hans Dijkema") +``` + +Imported pages use namespace `racket-wiki`. The concept maps use the stable +slugs `racket-wiki-architectuur` and `racket-wiki-kwaliteitskenmerken` and are +embedded in the overview pages with `{{cmap:...}}`. + +Every imported item carries a source hash. A later import updates an item only +when its imported source has not been edited locally. Modified items are +reported as `skipped-modified`; use `--overwrite-modified` only after reviewing +their version history. Re-importing identical sources does not create needless +versions. + ## Next useful steps The backend and storage API remain independent of EasyMDE. Useful next additions include internal wiki-link syntax, page namespaces/navigation, restore-from-archive, site settings, per-page ACLs and a richer admin console. @@ -728,3 +768,69 @@ CMap autosave and Ctrl/Cmd+S remain available. Navigation again follows the prov Creating an item and editing another item are now guaranteed to form two separate Undo transactions. Item creation and content editing start their history transaction before the CMap node is redrawn. A synchronous render callback can therefore no longer replace the pre-edit history snapshot while automatically fitting the node to its text. The same transaction ordering is used for inline linking-phrase edits. Automatic sizing remains part of the edit that caused it, rather than becoming a separate Undo step. + +### 0.2.85 + +Users can manage their own display name and email address from **Profile**. A password change requires the current password, keeps the active browser session and revokes the user's other sessions. Administrators can also maintain email addresses in user administration. + +The login page now links to a password-reset flow. Reset tokens are random, stored only as SHA-256 hashes, expire after one hour, work once and revoke all existing sessions when used. The public response never reveals whether an account exists. The number of reset links per user in a rolling hour is configurable from **Admin → Email and password reset** and defaults to two. + +SMTP uses Racket's `net/smtp` library directly. Administrators configure the public wiki URL, SMTP server/port, sender, credentials and STARTTLS in the same admin screen. Values can alternatively be supplied through `RACKET_WIKI_PUBLIC_URL`, `RACKET_WIKI_SMTP_HOST`, `RACKET_WIKI_SMTP_PORT`, `RACKET_WIKI_SMTP_FROM`, `RACKET_WIKI_SMTP_USER`, `RACKET_WIKI_SMTP_PASSWORD`, `RACKET_WIKI_SMTP_TLS`, `RACKET_WIKI_SMTP_ACCEPT_UNTRUSTED_CERTIFICATES` and `RACKET_WIKI_RESET_LIMIT`. Environment variables act as fallbacks for values not stored by the administrator. + +### 0.2.86 + +Concept maps now have immutable database-backed version history. Creation, manual saves, autosaves and renames each store a complete CMap snapshot with version number, title, author, action, summary and timestamp. Schema migration 11 creates `concept_map_versions` and records the current state of every existing CMap as its first available historical snapshot. + +### 0.2.87 + +Fixed startup of the password-reset mail module by importing Racket's `db` library explicitly. The module uses `query-rows`, `query-exec` and `call-with-transaction` directly and therefore must require that library itself. + +### 0.2.88 + +The SMTP administration form now includes a test recipient and a synchronous test-email action. The test uses the current form values without storing them and keeps using an already stored SMTP password when the password field is empty. SMTP acceptance or the concrete connection/authentication error is shown next to the form. Password-reset requests retain their account-enumeration-safe response, now show a clearer next step, and write a server diagnostic when SMTP has not been configured. + +### 0.2.89 + +STARTTLS now uses a secure client context with automatic modern TLS negotiation and SMTP-hostname certificate verification. This avoids `net/smtp`'s legacy callback argument `'tls`, which selects TLS 1.0 and can produce OpenSSL's `no protocols available` error on current systems where TLS 1.0 is disabled. + +Certificate and hostname verification remain enabled by default. An administrator can explicitly disable verification for a trusted local SMTP server with a self-signed or otherwise locally invalid certificate. This exception still uses modern TLS encryption, but it does not authenticate the SMTP server and should not be used for an untrusted network or public server. + +### 0.2.90 + +The STARTTLS choice now follows the administration setting directly. With **Accept untrusted certificates** disabled, mail uses `ssl-secure-client-context` and verifies both the certificate chain and SMTP hostname. With the setting enabled, mail uses `ssl-make-client-context 'auto`; communication remains encrypted, but the server certificate is not authenticated. + +### 0.2.91 + +Dragging the background frame of an expanded sub-CMap once again moves the complete sub-CMap, including its main concept, descendants and nested sub-CMaps. Dragging the main concept itself continues to move only that concept within the sub-CMap. + +The CMap tools menu contains **CMap history**. Historical versions can be loaded visually into the normal editor. Loading does not overwrite the current version: the historical map remains an unsaved editor state until the user explicitly saves it, at which point a new current version is created. + +### 0.2.92 + +The CMap tools menu can create an explicitly described snapshot. Unlike autosave, this always creates a new immutable CMap version, even when the document itself has not changed since the last automatic save. + +Wiki Markdown can embed a stored concept map on its own line with `{{cmap:Test}}`. The page shows a compact read-only rendition without page guides; double-clicking it opens the full CMap editor. The Markdown CMap picker can insert either the ordinary link or the embedded form. + +### 0.2.93 + +Adds a bundled architecture documentation set consisting of twelve linked wiki +pages under namespace `racket-wiki` and two native concept maps. It documents +the current structure and behaviour together with modularity, maintainability, +analyzability, testability, long-term performance expectations, security, +coding rules and architecture evolution. + +`architecture/import.rkt` validates the complete set and imports it through the +normal page and CMap storage procedures. Source hashes make repeated imports +idempotent and protect locally edited imported content. The command supports +`--dry-run`, an explicit history author and deliberate +`--overwrite-modified`. Its test submodule validates internal page links, CMap +embeds, node ids, connector endpoints and source-change detection. + +### 0.2.94 + +Fixes two overescaped line-ending expressions in the architecture importer. +The source-marker expression now uses Racket string escapes for CR and LF, +instead of passing the invalid alphabetic escapes `\\r` and `\\n` to the +regexp parser. The CMap-embed expression uses an actual newline character in +its exclusion class for the same reason. Regression tests cover LF and CRLF +source markers and single-line CMap embeds. diff --git a/architecture/cmaps/architectuur.json b/architecture/cmaps/architectuur.json new file mode 100644 index 0000000..7e202ed --- /dev/null +++ b/architecture/cmaps/architectuur.json @@ -0,0 +1,186 @@ +{ + "schemaVersion": 1, + "items": [ + { + "id": 1, + "kind": "page", + "label": "racket-wiki", + "synopsis": "Kleine zelf-gehoste Markdownwiki met native conceptmaps.", + "pageSlug": "racket-wiki:architectuur", + "x": 505, + "y": 35, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#dceef8", + "borderColor": "#376f92" + }, + { + "id": 2, + "kind": "phrase", + "label": "bestaat tijdens gebruik uit", + "x": 545, + "y": 165, + "width": 190, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 3, + "kind": "page", + "label": "Browserapplicatie", + "synopsis": "Navigatie, Markdown, editorstate, CMaps en visualisaties.", + "pageSlug": "racket-wiki:gedrag", + "x": 80, + "y": 255, + "width": 250, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#fff4cf", + "borderColor": "#a97c00" + }, + { + "id": 4, + "kind": "page", + "label": "Racket-backend", + "synopsis": "HTTP, autorisatie, validatie en opslagorkestratie.", + "pageSlug": "racket-wiki:modulariteit", + "x": 505, + "y": 255, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#e8e1f7", + "borderColor": "#72539a" + }, + { + "id": 5, + "kind": "page", + "label": "PostgreSQL", + "synopsis": "Huidige toestand, transacties, historie, indexen en bijlagen.", + "pageSlug": "racket-wiki:data-en-versies", + "x": 930, + "y": 255, + "width": 250, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#e1f1e1", + "borderColor": "#4f7d51" + }, + { + "id": 6, + "kind": "phrase", + "label": "gebruikt JSON/HTTP van", + "x": 315, + "y": 390, + "width": 180, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 7, + "kind": "phrase", + "label": "schrijft transactioneel naar", + "x": 775, + "y": 390, + "width": 195, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 8, + "kind": "page", + "label": "Structuur en samenhang", + "synopsis": "Modules, uitvoerende delen en afhankelijkheidsrichting.", + "pageSlug": "racket-wiki:structuur-en-samenhang", + "x": 75, + "y": 520, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#f3f6f8", + "borderColor": "#5d6d7e" + }, + { + "id": 9, + "kind": "page", + "label": "Beveiliging en vertrouwen", + "synopsis": "Backend en database bewaken de vertrouwensgrenzen.", + "pageSlug": "racket-wiki:beveiliging", + "x": 380, + "y": 520, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#fbe4e4", + "borderColor": "#a04f4f" + }, + { + "id": 10, + "kind": "page", + "label": "Beheer en evolutie", + "synopsis": "Migraties, releases en herhaalbare documentatie-import.", + "pageSlug": "racket-wiki:beheer-en-evolutie", + "x": 685, + "y": 520, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#edf7e8", + "borderColor": "#57834a" + }, + { + "id": 11, + "kind": "page", + "label": "Regels bij het coderen", + "synopsis": "Expliciete code, statische afhankelijkheden en invarianten.", + "pageSlug": "racket-wiki:code-regels", + "x": 990, + "y": 520, + "width": 260, + "height": 92, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#f3f6f8", + "borderColor": "#5d6d7e" + }, + { + "id": 12, + "kind": "phrase", + "label": "wordt onderhouden via", + "x": 550, + "y": 665, + "width": 175, + "height": 36, + "autoWidth": false, + "autoHeight": false + } + ], + "connectors": [ + { "id": 1, "sourceId": 1, "targetId": 2, "hasArrow": false, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 2, "sourceId": 2, "targetId": 3, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 3, "sourceId": 2, "targetId": 4, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 4, "sourceId": 2, "targetId": 5, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 5, "sourceId": 3, "targetId": 6, "hasArrow": false, "lineColor": "#8c7428", "lineWidth": 2 }, + { "id": 6, "sourceId": 6, "targetId": 4, "hasArrow": true, "lineColor": "#8c7428", "lineWidth": 2 }, + { "id": 7, "sourceId": 4, "targetId": 7, "hasArrow": false, "lineColor": "#72539a", "lineWidth": 2 }, + { "id": 8, "sourceId": 7, "targetId": 5, "hasArrow": true, "lineColor": "#72539a", "lineWidth": 2 }, + { "id": 9, "sourceId": 4, "targetId": 8, "hasArrow": true, "lineColor": "#82909d", "lineWidth": 2 }, + { "id": 10, "sourceId": 4, "targetId": 9, "hasArrow": true, "lineColor": "#a04f4f", "lineWidth": 2 }, + { "id": 11, "sourceId": 4, "targetId": 10, "hasArrow": true, "lineColor": "#57834a", "lineWidth": 2 }, + { "id": 12, "sourceId": 4, "targetId": 11, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 13, "sourceId": 10, "targetId": 12, "hasArrow": false, "lineColor": "#57834a", "lineWidth": 2 }, + { "id": 14, "sourceId": 12, "targetId": 1, "hasArrow": true, "lineColor": "#57834a", "lineWidth": 2 } + ], + "conceptMaps": [] +} diff --git a/architecture/cmaps/kwaliteitskenmerken.json b/architecture/cmaps/kwaliteitskenmerken.json new file mode 100644 index 0000000..d9c1c8f --- /dev/null +++ b/architecture/cmaps/kwaliteitskenmerken.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": 1, + "items": [ + { + "id": 1, + "kind": "page", + "label": "Duurzame racket-wiki", + "synopsis": "Kwaliteit ontstaat uit samenhangende ontwikkelregels, niet uit één techniek.", + "pageSlug": "racket-wiki:architectuur", + "x": 505, + "y": 35, + "width": 280, + "height": 100, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#dceef8", + "borderColor": "#376f92" + }, + { + "id": 2, + "kind": "phrase", + "label": "vereist", + "x": 585, + "y": 170, + "width": 120, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 3, + "kind": "page", + "label": "Onderhoudbaarheid", + "synopsis": "Wijzigingen zijn lokaliseerbaar, begrijpelijk en controleerbaar.", + "pageSlug": "racket-wiki:onderhoudbaarheid", + "x": 40, + "y": 270, + "width": 245, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#edf7e8", + "borderColor": "#57834a" + }, + { + "id": 4, + "kind": "page", + "label": "Analyseerbaarheid", + "synopsis": "Een symptoom is naar laag, request en versie te herleiden.", + "pageSlug": "racket-wiki:analyseerbaarheid", + "x": 300, + "y": 270, + "width": 245, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#fff4cf", + "borderColor": "#a97c00" + }, + { + "id": 5, + "kind": "page", + "label": "Testbaarheid", + "synopsis": "Pure regels, transacties en gebruikerspaden hebben passende tests.", + "pageSlug": "racket-wiki:testbaarheid", + "x": 560, + "y": 270, + "width": 245, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#e8e1f7", + "borderColor": "#72539a" + }, + { + "id": 6, + "kind": "page", + "label": "Performance", + "synopsis": "Groei wordt gemeten; concrete knelpunten worden gericht opgelost.", + "pageSlug": "racket-wiki:performance", + "x": 820, + "y": 270, + "width": 245, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#f3f6f8", + "borderColor": "#5d6d7e" + }, + { + "id": 7, + "kind": "page", + "label": "Beveiliging", + "synopsis": "Veilige defaults en server-side vertrouwensgrenzen.", + "pageSlug": "racket-wiki:beveiliging", + "x": 1080, + "y": 270, + "width": 245, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#fbe4e4", + "borderColor": "#a04f4f" + }, + { + "id": 8, + "kind": "page", + "label": "Code-regels", + "synopsis": "Expliciete constructies, kleine contracten en juiste transactiegrenzen.", + "pageSlug": "racket-wiki:code-regels", + "x": 235, + "y": 525, + "width": 250, + "height": 96, + "autoWidth": false, + "autoHeight": false, + "backgroundColor": "#f3f6f8", + "borderColor": "#5d6d7e" + }, + { + "id": 9, + "kind": "phrase", + "label": "maakt wijzigingen", + "x": 300, + "y": 430, + "width": 150, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 10, + "kind": "phrase", + "label": "levert reproduceerbare", + "x": 480, + "y": 430, + "width": 170, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 11, + "kind": "phrase", + "label": "beschermt", + "x": 635, + "y": 525, + "width": 120, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 12, + "kind": "phrase", + "label": "wordt gestuurd door meting", + "x": 825, + "y": 430, + "width": 185, + "height": 36, + "autoWidth": false, + "autoHeight": false + }, + { + "id": 13, + "kind": "phrase", + "label": "begrensd door veilige defaults", + "x": 1050, + "y": 430, + "width": 210, + "height": 36, + "autoWidth": false, + "autoHeight": false + } + ], + "connectors": [ + { "id": 1, "sourceId": 1, "targetId": 2, "hasArrow": false, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 2, "sourceId": 2, "targetId": 3, "hasArrow": true, "lineColor": "#57834a", "lineWidth": 2 }, + { "id": 3, "sourceId": 2, "targetId": 4, "hasArrow": true, "lineColor": "#a97c00", "lineWidth": 2 }, + { "id": 4, "sourceId": 2, "targetId": 5, "hasArrow": true, "lineColor": "#72539a", "lineWidth": 2 }, + { "id": 5, "sourceId": 2, "targetId": 6, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 6, "sourceId": 2, "targetId": 7, "hasArrow": true, "lineColor": "#a04f4f", "lineWidth": 2 }, + { "id": 7, "sourceId": 8, "targetId": 9, "hasArrow": false, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 8, "sourceId": 9, "targetId": 3, "hasArrow": true, "lineColor": "#57834a", "lineWidth": 2 }, + { "id": 9, "sourceId": 4, "targetId": 10, "hasArrow": false, "lineColor": "#a97c00", "lineWidth": 2 }, + { "id": 10, "sourceId": 10, "targetId": 5, "hasArrow": true, "lineColor": "#72539a", "lineWidth": 2 }, + { "id": 11, "sourceId": 5, "targetId": 11, "hasArrow": false, "lineColor": "#72539a", "lineWidth": 2 }, + { "id": 12, "sourceId": 11, "targetId": 3, "hasArrow": true, "lineColor": "#57834a", "lineWidth": 2 }, + { "id": 13, "sourceId": 4, "targetId": 12, "hasArrow": false, "lineColor": "#a97c00", "lineWidth": 2 }, + { "id": 14, "sourceId": 12, "targetId": 6, "hasArrow": true, "lineColor": "#5d6d7e", "lineWidth": 2 }, + { "id": 15, "sourceId": 7, "targetId": 13, "hasArrow": false, "lineColor": "#a04f4f", "lineWidth": 2 }, + { "id": 16, "sourceId": 13, "targetId": 6, "hasArrow": true, "lineColor": "#a04f4f", "lineWidth": 2 } + ], + "conceptMaps": [] +} diff --git a/architecture/import.bak b/architecture/import.bak new file mode 100644 index 0000000..69c573b --- /dev/null +++ b/architecture/import.bak @@ -0,0 +1,538 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Repeatable import of the bundled racket-wiki architecture pages and CMaps. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require file/sha1 + json + racket/cmdline + racket/file + racket/list + racket/path + racket/runtime-path + racket/set + racket/string + "../private/cmap-storage.rkt" + "../private/config.rkt" + "../private/database.rkt" + "../private/storage.rkt") + +(provide (struct-out architecture-import-result) + validate-racket-wiki-architecture! + import-racket-wiki-architecture-from-data-directory! + import-racket-wiki-architecture!) + +(struct architecture-import-result (kind reference status message) + #:transparent) + +(struct architecture-page (reference title markdown tags) + #:transparent) + +(struct architecture-concept-map (slug title document) + #:transparent) + +(define-runtime-path architecture-directory ".") + +(define page-source-marker-pattern + #px"^\\r?\\n") + +(define concept-map-source-marker-id + "racket-wiki-architecture-source") + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Content loading and validation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (manifest-path) + (build-path architecture-directory "manifest.rktd")) + +(define (read-manifest) + (call-with-input-file (manifest-path) read)) + +(define (content-path relative-path) + (build-path architecture-directory relative-path)) + +(define (canonical-datum value) + (cond + [(hash? value) + (define keys + (sort (hash-keys value) + stringbytes/utf-8 + (format "~s" (canonical-datum value)))) + (bytes->hex-string (sha256-bytes source-bytes))) + +(define (read-page-source namespace specification) + (define slug (hash-ref specification 'slug)) + (architecture-page + (page-reference namespace slug) + (hash-ref specification 'title) + (file->string (content-path (hash-ref specification 'file))) + (hash-ref specification 'tags '()))) + +(define (read-concept-map-source specification) + (architecture-concept-map + (hash-ref specification 'slug) + (hash-ref specification 'title) + (call-with-input-file + (content-path (hash-ref specification 'file)) + read-json))) + +(define (load-architecture-content) + (define manifest (read-manifest)) + (unless (hash? manifest) + (error 'load-architecture-content "manifest.rktd must contain a hash")) + (define namespace (hash-ref manifest 'namespace)) + (define pages + (for/list ([specification (in-list (hash-ref manifest 'pages))]) + (read-page-source namespace specification))) + (define concept-maps + (for/list ([specification (in-list (hash-ref manifest 'concept-maps))]) + (read-concept-map-source specification))) + (values namespace pages concept-maps)) + +(define (duplicate-values values) + (define seen (mutable-set)) + (define duplicates (mutable-set)) + (for ([value (in-list values)]) + (if (set-member? seen value) + (set-add! duplicates value) + (set-add! seen value))) + (sort (set->list duplicates) stringstring item-ids))) + (unless (null? duplicate-item-ids) + (error 'validate-racket-wiki-architecture! + "CMap ~a has duplicate item ids: ~a" + (architecture-concept-map-slug concept-map) + (string-join duplicate-item-ids ", "))) + (define item-id-set (list->set item-ids)) + (for ([connector (in-list connectors)]) + (unless (hash? connector) + (error 'validate-racket-wiki-architecture! + "CMap ~a contains a non-object connector" + (architecture-concept-map-slug concept-map))) + (define source-id (hash-ref connector 'sourceId #f)) + (define target-id (hash-ref connector 'targetId #f)) + (unless (and (set-member? item-id-set source-id) + (set-member? item-id-set target-id)) + (error 'validate-racket-wiki-architecture! + "CMap ~a contains a connector with an unknown endpoint" + (architecture-concept-map-slug concept-map))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Validate every bundled page, link and CMap before importing. +; pre : The architecture files are present beside this module. +; post : No wiki or file state has been changed. +; result : Two values containing the validated pages and CMaps. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (validate-racket-wiki-architecture!) + (define-values (namespace pages concept-maps) + (load-architecture-content)) + (unless (string=? namespace "racket-wiki") + (error 'validate-racket-wiki-architecture! + "the architecture namespace must be racket-wiki")) + (define page-reference-list + (map architecture-page-reference pages)) + (define concept-map-slug-list + (map architecture-concept-map-slug concept-maps)) + (define duplicate-pages (duplicate-values page-reference-list)) + (define duplicate-concept-maps (duplicate-values concept-map-slug-list)) + (unless (null? duplicate-pages) + (error 'validate-racket-wiki-architecture! + "duplicate page references: ~a" + (string-join duplicate-pages ", "))) + (unless (null? duplicate-concept-maps) + (error 'validate-racket-wiki-architecture! + "duplicate CMap slugs: ~a" + (string-join duplicate-concept-maps ", "))) + (for ([reference (in-list page-reference-list)]) + (unless (valid-page-reference? reference) + (error 'validate-racket-wiki-architecture! + "invalid page reference: ~a" + reference))) + (for ([slug (in-list concept-map-slug-list)]) + (unless (valid-slug? slug) + (error 'validate-racket-wiki-architecture! + "invalid CMap slug: ~a" + slug))) + (define page-references (list->set page-reference-list)) + (define concept-map-slugs (list->set concept-map-slug-list)) + (for ([page (in-list pages)]) + (validate-page-links! page page-references concept-map-slugs)) + (for ([concept-map (in-list concept-maps)]) + (validate-concept-map! concept-map page-references)) + (values pages concept-maps)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Source ownership markers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (page-with-source-marker title markdown tags) + (string-append + "\n" + markdown)) + +(define (page-source-unmodified? title markdown tags) + (define match (regexp-match page-source-marker-pattern markdown)) + (and match + (let ([body (regexp-replace page-source-marker-pattern markdown "")] + [stored-hash (list-ref match 1)]) + (string=? stored-hash (source-hash (list title body tags)))))) + +(define (concept-map-source-marker? reference) + (and (hash? reference) + (string=? (hash-ref reference 'id "") + concept-map-source-marker-id))) + +(define (concept-map-without-source-marker document) + (define references (hash-ref document 'conceptMaps '())) + (hash-set document + 'conceptMaps + (filter (λ (reference) + (not (concept-map-source-marker? reference))) + references))) + +(define (concept-map-with-source-marker title document) + (define clean-document (concept-map-without-source-marker document)) + (define marker + (hash 'id concept-map-source-marker-id + 'kind "architecture-source" + 'sourceHash (source-hash (list title clean-document)))) + (hash-set clean-document + 'conceptMaps + (append (hash-ref clean-document 'conceptMaps '()) + (list marker)))) + +(define (concept-map-source-unmodified? title document) + (define marker + (findf concept-map-source-marker? + (hash-ref document 'conceptMaps '()))) + (and marker + (string=? (hash-ref marker 'sourceHash "") + (source-hash + (list title + (concept-map-without-source-marker document)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Import operations +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (result kind reference status message) + (architecture-import-result kind reference status message)) + +(define (page-content-equal? current page marked-markdown) + (and (string=? (hash-ref current 'title) + (architecture-page-title page)) + (string=? (hash-ref current 'markdown) + marked-markdown) + (equal? (hash-ref current 'tags '()) + (architecture-page-tags page)))) + +(define (import-page! config page author dry-run? overwrite-modified?) + (define reference (architecture-page-reference page)) + (define marked-markdown + (page-with-source-marker (architecture-page-title page) + (architecture-page-markdown page) + (architecture-page-tags page))) + (define current (read-page config reference)) + (cond + [(not current) + (if dry-run? + (result 'page reference 'would-create "Page would be created") + (begin + (create-page! config + reference + (architecture-page-title page) + marked-markdown + author + "Imported racket-wiki architecture documentation" + (architecture-page-tags page)) + (result 'page reference 'created "Page created")))] + [(page-content-equal? current page marked-markdown) + (result 'page reference 'unchanged "Page already matches the bundled source")] + [(and (not overwrite-modified?) + (not (page-source-unmodified? (hash-ref current 'title) + (hash-ref current 'markdown) + (hash-ref current 'tags '())))) + (result 'page + reference + 'skipped-modified + "Page was modified after import and was not overwritten")] + [dry-run? + (result 'page + reference + (if overwrite-modified? 'would-overwrite 'would-update) + "Page would be updated")] + [else + (update-page! config + reference + (architecture-page-title page) + marked-markdown + author + (hash-ref current 'currentVersion) + "Updated racket-wiki architecture documentation" + (architecture-page-tags page)) + (result 'page reference 'updated "Page updated")])) + +(define (concept-map-content-equal? current concept-map marked-document) + (and (string=? (hash-ref current 'title) + (architecture-concept-map-title concept-map)) + (equal? (hash-ref current 'document) + marked-document))) + +(define (import-concept-map! config concept-map author dry-run? overwrite-modified?) + (define slug (architecture-concept-map-slug concept-map)) + (define marked-document + (concept-map-with-source-marker + (architecture-concept-map-title concept-map) + (architecture-concept-map-document concept-map))) + (define current (read-concept-map config slug)) + (cond + [(not current) + (if dry-run? + (result 'concept-map slug 'would-create "CMap would be created") + (begin + (create-concept-map! config + slug + (architecture-concept-map-title concept-map) + marked-document + author) + (result 'concept-map slug 'created "CMap created")))] + [(concept-map-content-equal? current concept-map marked-document) + (result 'concept-map slug 'unchanged "CMap already matches the bundled source")] + [(and (not overwrite-modified?) + (not (concept-map-source-unmodified? + (hash-ref current 'title) + (hash-ref current 'document)))) + (result 'concept-map + slug + 'skipped-modified + "CMap was modified after import and was not overwritten")] + [dry-run? + (result 'concept-map + slug + (if overwrite-modified? 'would-overwrite 'would-update) + "CMap would be updated")] + [else + (update-concept-map! config + slug + (architecture-concept-map-title concept-map) + marked-document + author + (hash-ref current 'currentVersion) + "Updated racket-wiki architecture CMap" + "import") + (result 'concept-map slug 'updated "CMap updated")])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Import the bundled architecture set below namespace racket-wiki. +; pre : config points at an initialized or initializable PostgreSQL wiki. +; post : Missing managed pages/CMaps are created and unchanged managed +; sources can receive new versions; locally modified sources are +; skipped unless overwrite-modified? is true. +; result : A list of architecture-import-result values. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (import-racket-wiki-architecture! + config + #:author [author "racket-wiki architecture import"] + #:dry-run? [dry-run? #f] + #:overwrite-modified? [overwrite-modified? #f]) + (define-values (pages concept-maps) + (validate-racket-wiki-architecture!)) + (ensure-wiki-data! config) + (unless (database-settings-exist? config) + (error 'import-racket-wiki-architecture! + "PostgreSQL is not configured for data directory ~a" + (wiki-config-data-dir config))) + (initialize-database! config) + (append + (for/list ([page (in-list pages)]) + (import-page! config page author dry-run? overwrite-modified?)) + (for/list ([concept-map (in-list concept-maps)]) + (import-concept-map! config + concept-map + author + dry-run? + overwrite-modified?)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Import the architecture set using only a wiki data directory. +; pre : data-directory contains the wiki's database.rktd configuration. +; post : The same changes as import-racket-wiki-architecture! have occurred. +; result : A list of architecture-import-result values. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (import-racket-wiki-architecture-from-data-directory! + data-directory + #:author [author "racket-wiki architecture import"] + #:dry-run? [dry-run? #f] + #:overwrite-modified? [overwrite-modified? #f]) + (define config + (make-wiki-config #:data-dir data-directory)) + (import-racket-wiki-architecture! + config + #:author author + #:dry-run? dry-run? + #:overwrite-modified? overwrite-modified?)) + +(define (display-import-results results) + (for ([import-result (in-list results)]) + (displayln + (format "~a ~a ~a" + (architecture-import-result-status import-result) + (architecture-import-result-kind import-result) + (architecture-import-result-reference import-result)))) + (define skipped + (count (λ (import-result) + (eq? (architecture-import-result-status import-result) + 'skipped-modified)) + results)) + (displayln (format "Processed ~a architecture items." (length results))) + (when (> skipped 0) + (displayln + (format + "~a locally modified item(s) were preserved. Review them before using --overwrite-modified." + skipped)))) + +(module+ main + (define config (default-wiki-config)) + (define author "racket-wiki architecture import") + (define dry-run? #f) + (define overwrite-modified? #f) + + (command-line + #:program "racket-wiki architecture import" + #:once-each + [("--data") directory + "Wiki data directory" + (set! config + (struct-copy wiki-config config + [data-dir (path->complete-path directory)]))] + [("--author") name + "Author recorded in imported page and CMap versions" + (set! author name)] + [("--dry-run") + "Validate and report changes without changing wiki content" + (set! dry-run? #t)] + [("--overwrite-modified") + "Replace architecture items that were edited after import" + (set! overwrite-modified? #t)]) + + (display-import-results + (import-racket-wiki-architecture! + config + #:author author + #:dry-run? dry-run? + #:overwrite-modified? overwrite-modified?))) + +(module+ test + (require rackunit) + + (define-values (test-pages test-concept-maps) + (validate-racket-wiki-architecture!)) + + (check-equal? (length test-pages) 12) + (check-equal? (length test-concept-maps) 2) + + (define test-markdown "# Test\n\nBody.\n") + (define marked-test-markdown + (page-with-source-marker "Test" test-markdown '("test"))) + (check-true + (page-source-unmodified? "Test" marked-test-markdown '("test"))) + (check-false + (page-source-unmodified? "Test" + (string-append marked-test-markdown "Local edit\n") + '("test"))) + (check-false + (page-source-unmodified? "Changed title" + marked-test-markdown + '("test"))) + + (define test-document + (architecture-concept-map-document (car test-concept-maps))) + (define marked-test-document + (concept-map-with-source-marker "Test CMap" test-document)) + (check-true + (concept-map-source-unmodified? "Test CMap" marked-test-document)) + (check-false + (concept-map-source-unmodified? + "Test CMap" + (hash-set marked-test-document 'schemaVersion 2))) + (check-false + (concept-map-source-unmodified? "Changed title" marked-test-document))) diff --git a/architecture/import.rkt b/architecture/import.rkt new file mode 100644 index 0000000..7b79655 --- /dev/null +++ b/architecture/import.rkt @@ -0,0 +1,562 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Repeatable import of the bundled racket-wiki architecture pages and CMaps. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require file/sha1 + json + racket/cmdline + racket/file + racket/list + racket/path + racket/runtime-path + racket/set + racket/string + "../private/cmap-storage.rkt" + "../private/config.rkt" + "../private/database.rkt" + "../private/storage.rkt") + +(provide (struct-out architecture-import-result) + validate-racket-wiki-architecture! + import-racket-wiki-architecture-from-data-directory! + import-racket-wiki-architecture!) + +(struct architecture-import-result (kind reference status message) + #:transparent) + +(struct architecture-page (reference title markdown tags) + #:transparent) + +(struct architecture-concept-map (slug title document) + #:transparent) + +(define-runtime-path architecture-directory ".") + +(define page-source-marker-pattern + ;; \r and \n are Racket string escapes here. Writing them as \\r and + ;; \\n would pass alphabetic escapes to the regexp parser instead. + #px"^\r?\n") + +(define concept-map-embed-pattern + ;; The newline in the character class is also a Racket string escape. + #px"^\\s*\\{\\{cmap:([^{}\n]+)\\}\\}\\s*$") + +(define concept-map-source-marker-id + "racket-wiki-architecture-source") + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Content loading and validation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (manifest-path) + (build-path architecture-directory "manifest.rktd")) + +(define (read-manifest) + (call-with-input-file (manifest-path) read)) + +(define (content-path relative-path) + (build-path architecture-directory relative-path)) + +(define (canonical-datum value) + (cond + [(hash? value) + (define keys + (sort (hash-keys value) + stringbytes/utf-8 + (format "~s" (canonical-datum value)))) + (bytes->hex-string (sha256-bytes source-bytes))) + +(define (read-page-source namespace specification) + (define slug (hash-ref specification 'slug)) + (architecture-page + (page-reference namespace slug) + (hash-ref specification 'title) + (file->string (content-path (hash-ref specification 'file))) + (hash-ref specification 'tags '()))) + +(define (read-concept-map-source specification) + (architecture-concept-map + (hash-ref specification 'slug) + (hash-ref specification 'title) + (call-with-input-file + (content-path (hash-ref specification 'file)) + read-json))) + +(define (load-architecture-content) + (define manifest (read-manifest)) + (unless (hash? manifest) + (error 'load-architecture-content "manifest.rktd must contain a hash")) + (define namespace (hash-ref manifest 'namespace)) + (define pages + (for/list ([specification (in-list (hash-ref manifest 'pages))]) + (read-page-source namespace specification))) + (define concept-maps + (for/list ([specification (in-list (hash-ref manifest 'concept-maps))]) + (read-concept-map-source specification))) + (values namespace pages concept-maps)) + +(define (duplicate-values values) + (define seen (mutable-set)) + (define duplicates (mutable-set)) + (for ([value (in-list values)]) + (if (set-member? seen value) + (set-add! duplicates value) + (set-add! seen value))) + (sort (set->list duplicates) stringstring item-ids))) + (unless (null? duplicate-item-ids) + (error 'validate-racket-wiki-architecture! + "CMap ~a has duplicate item ids: ~a" + (architecture-concept-map-slug concept-map) + (string-join duplicate-item-ids ", "))) + (define item-id-set (list->set item-ids)) + (for ([connector (in-list connectors)]) + (unless (hash? connector) + (error 'validate-racket-wiki-architecture! + "CMap ~a contains a non-object connector" + (architecture-concept-map-slug concept-map))) + (define source-id (hash-ref connector 'sourceId #f)) + (define target-id (hash-ref connector 'targetId #f)) + (unless (and (set-member? item-id-set source-id) + (set-member? item-id-set target-id)) + (error 'validate-racket-wiki-architecture! + "CMap ~a contains a connector with an unknown endpoint" + (architecture-concept-map-slug concept-map))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Validate every bundled page, link and CMap before importing. +; pre : The architecture files are present beside this module. +; post : No wiki or file state has been changed. +; result : Two values containing the validated pages and CMaps. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (validate-racket-wiki-architecture!) + (define-values (namespace pages concept-maps) + (load-architecture-content)) + (unless (string=? namespace "racket-wiki") + (error 'validate-racket-wiki-architecture! + "the architecture namespace must be racket-wiki")) + (define page-reference-list + (map architecture-page-reference pages)) + (define concept-map-slug-list + (map architecture-concept-map-slug concept-maps)) + (define duplicate-pages (duplicate-values page-reference-list)) + (define duplicate-concept-maps (duplicate-values concept-map-slug-list)) + (unless (null? duplicate-pages) + (error 'validate-racket-wiki-architecture! + "duplicate page references: ~a" + (string-join duplicate-pages ", "))) + (unless (null? duplicate-concept-maps) + (error 'validate-racket-wiki-architecture! + "duplicate CMap slugs: ~a" + (string-join duplicate-concept-maps ", "))) + (for ([reference (in-list page-reference-list)]) + (unless (valid-page-reference? reference) + (error 'validate-racket-wiki-architecture! + "invalid page reference: ~a" + reference))) + (for ([slug (in-list concept-map-slug-list)]) + (unless (valid-slug? slug) + (error 'validate-racket-wiki-architecture! + "invalid CMap slug: ~a" + slug))) + (define page-references (list->set page-reference-list)) + (define concept-map-slugs (list->set concept-map-slug-list)) + (for ([page (in-list pages)]) + (validate-page-links! page page-references concept-map-slugs)) + (for ([concept-map (in-list concept-maps)]) + (validate-concept-map! concept-map page-references)) + (values pages concept-maps)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Source ownership markers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (page-with-source-marker title markdown tags) + (string-append + "\n" + markdown)) + +(define (page-source-unmodified? title markdown tags) + (define match (regexp-match page-source-marker-pattern markdown)) + (and match + (let ([body (regexp-replace page-source-marker-pattern markdown "")] + [stored-hash (list-ref match 1)]) + (string=? stored-hash (source-hash (list title body tags)))))) + +(define (concept-map-source-marker? reference) + (and (hash? reference) + (string=? (hash-ref reference 'id "") + concept-map-source-marker-id))) + +(define (concept-map-without-source-marker document) + (define references (hash-ref document 'conceptMaps '())) + (hash-set document + 'conceptMaps + (filter (λ (reference) + (not (concept-map-source-marker? reference))) + references))) + +(define (concept-map-with-source-marker title document) + (define clean-document (concept-map-without-source-marker document)) + (define marker + (hash 'id concept-map-source-marker-id + 'kind "architecture-source" + 'sourceHash (source-hash (list title clean-document)))) + (hash-set clean-document + 'conceptMaps + (append (hash-ref clean-document 'conceptMaps '()) + (list marker)))) + +(define (concept-map-source-unmodified? title document) + (define marker + (findf concept-map-source-marker? + (hash-ref document 'conceptMaps '()))) + (and marker + (string=? (hash-ref marker 'sourceHash "") + (source-hash + (list title + (concept-map-without-source-marker document)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Import operations +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (result kind reference status message) + (architecture-import-result kind reference status message)) + +(define (page-content-equal? current page marked-markdown) + (and (string=? (hash-ref current 'title) + (architecture-page-title page)) + (string=? (hash-ref current 'markdown) + marked-markdown) + (equal? (hash-ref current 'tags '()) + (architecture-page-tags page)))) + +(define (import-page! config page author dry-run? overwrite-modified?) + (define reference (architecture-page-reference page)) + (define marked-markdown + (page-with-source-marker (architecture-page-title page) + (architecture-page-markdown page) + (architecture-page-tags page))) + (define current (read-page config reference)) + (cond + [(not current) + (if dry-run? + (result 'page reference 'would-create "Page would be created") + (begin + (create-page! config + reference + (architecture-page-title page) + marked-markdown + author + "Imported racket-wiki architecture documentation" + (architecture-page-tags page)) + (result 'page reference 'created "Page created")))] + [(page-content-equal? current page marked-markdown) + (result 'page reference 'unchanged "Page already matches the bundled source")] + [(and (not overwrite-modified?) + (not (page-source-unmodified? (hash-ref current 'title) + (hash-ref current 'markdown) + (hash-ref current 'tags '())))) + (result 'page + reference + 'skipped-modified + "Page was modified after import and was not overwritten")] + [dry-run? + (result 'page + reference + (if overwrite-modified? 'would-overwrite 'would-update) + "Page would be updated")] + [else + (update-page! config + reference + (architecture-page-title page) + marked-markdown + author + (hash-ref current 'currentVersion) + "Updated racket-wiki architecture documentation" + (architecture-page-tags page)) + (result 'page reference 'updated "Page updated")])) + +(define (concept-map-content-equal? current concept-map marked-document) + (and (string=? (hash-ref current 'title) + (architecture-concept-map-title concept-map)) + (equal? (hash-ref current 'document) + marked-document))) + +(define (import-concept-map! config concept-map author dry-run? overwrite-modified?) + (define slug (architecture-concept-map-slug concept-map)) + (define marked-document + (concept-map-with-source-marker + (architecture-concept-map-title concept-map) + (architecture-concept-map-document concept-map))) + (define current (read-concept-map config slug)) + (cond + [(not current) + (if dry-run? + (result 'concept-map slug 'would-create "CMap would be created") + (begin + (create-concept-map! config + slug + (architecture-concept-map-title concept-map) + marked-document + author) + (result 'concept-map slug 'created "CMap created")))] + [(concept-map-content-equal? current concept-map marked-document) + (result 'concept-map slug 'unchanged "CMap already matches the bundled source")] + [(and (not overwrite-modified?) + (not (concept-map-source-unmodified? + (hash-ref current 'title) + (hash-ref current 'document)))) + (result 'concept-map + slug + 'skipped-modified + "CMap was modified after import and was not overwritten")] + [dry-run? + (result 'concept-map + slug + (if overwrite-modified? 'would-overwrite 'would-update) + "CMap would be updated")] + [else + (update-concept-map! config + slug + (architecture-concept-map-title concept-map) + marked-document + author + (hash-ref current 'currentVersion) + "Updated racket-wiki architecture CMap" + "import") + (result 'concept-map slug 'updated "CMap updated")])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Import the bundled architecture set below namespace racket-wiki. +; pre : config points at an initialized or initializable PostgreSQL wiki. +; post : Missing managed pages/CMaps are created and unchanged managed +; sources can receive new versions; locally modified sources are +; skipped unless overwrite-modified? is true. +; result : A list of architecture-import-result values. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (import-racket-wiki-architecture! + config + #:author [author "racket-wiki architecture import"] + #:dry-run? [dry-run? #f] + #:overwrite-modified? [overwrite-modified? #f]) + (define-values (pages concept-maps) + (validate-racket-wiki-architecture!)) + (ensure-wiki-data! config) + (unless (database-settings-exist? config) + (error 'import-racket-wiki-architecture! + "PostgreSQL is not configured for data directory ~a" + (wiki-config-data-dir config))) + (initialize-database! config) + (append + (for/list ([page (in-list pages)]) + (import-page! config page author dry-run? overwrite-modified?)) + (for/list ([concept-map (in-list concept-maps)]) + (import-concept-map! config + concept-map + author + dry-run? + overwrite-modified?)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Import the architecture set using only a wiki data directory. +; pre : data-directory contains the wiki's database.rktd configuration. +; post : The same changes as import-racket-wiki-architecture! have occurred. +; result : A list of architecture-import-result values. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (import-racket-wiki-architecture-from-data-directory! + data-directory + #:author [author "racket-wiki architecture import"] + #:dry-run? [dry-run? #f] + #:overwrite-modified? [overwrite-modified? #f]) + (define config + (make-wiki-config #:data-dir data-directory)) + (import-racket-wiki-architecture! + config + #:author author + #:dry-run? dry-run? + #:overwrite-modified? overwrite-modified?)) + +(define (display-import-results results) + (for ([import-result (in-list results)]) + (displayln + (format "~a ~a ~a" + (architecture-import-result-status import-result) + (architecture-import-result-kind import-result) + (architecture-import-result-reference import-result)))) + (define skipped + (count (λ (import-result) + (eq? (architecture-import-result-status import-result) + 'skipped-modified)) + results)) + (displayln (format "Processed ~a architecture items." (length results))) + (when (> skipped 0) + (displayln + (format + "~a locally modified item(s) were preserved. Review them before using --overwrite-modified." + skipped)))) + +(module+ main + (define config (default-wiki-config)) + (define author "racket-wiki architecture import") + (define dry-run? #f) + (define overwrite-modified? #f) + + (command-line + #:program "racket-wiki architecture import" + #:once-each + [("--data") directory + "Wiki data directory" + (set! config + (struct-copy wiki-config config + [data-dir (path->complete-path directory)]))] + [("--author") name + "Author recorded in imported page and CMap versions" + (set! author name)] + [("--dry-run") + "Validate and report changes without changing wiki content" + (set! dry-run? #t)] + [("--overwrite-modified") + "Replace architecture items that were edited after import" + (set! overwrite-modified? #t)]) + + (display-import-results + (import-racket-wiki-architecture! + config + #:author author + #:dry-run? dry-run? + #:overwrite-modified? overwrite-modified?))) + +(module+ test + (require rackunit) + + (define-values (test-pages test-concept-maps) + (validate-racket-wiki-architecture!)) + + (check-equal? (length test-pages) 12) + (check-equal? (length test-concept-maps) 2) + + (check-true + (regexp-match? + page-source-marker-pattern + "\nBody")) + (check-true + (regexp-match? + page-source-marker-pattern + "\r\nBody")) + (check-equal? + (regexp-match concept-map-embed-pattern + "{{cmap:racket-wiki-architectuur}}") + '("{{cmap:racket-wiki-architectuur}}" + "racket-wiki-architectuur")) + (check-false + (regexp-match? + concept-map-embed-pattern + "{{cmap:racket-wiki-architectuur\nextra}}")) + + (define test-markdown "# Test\n\nBody.\n") + (define marked-test-markdown + (page-with-source-marker "Test" test-markdown '("test"))) + (check-true + (page-source-unmodified? "Test" marked-test-markdown '("test"))) + (check-false + (page-source-unmodified? "Test" + (string-append marked-test-markdown "Local edit\n") + '("test"))) + (check-false + (page-source-unmodified? "Changed title" + marked-test-markdown + '("test"))) + + (define test-document + (architecture-concept-map-document (car test-concept-maps))) + (define marked-test-document + (concept-map-with-source-marker "Test CMap" test-document)) + (check-true + (concept-map-source-unmodified? "Test CMap" marked-test-document)) + (check-false + (concept-map-source-unmodified? + "Test CMap" + (hash-set marked-test-document 'schemaVersion 2))) + (check-false + (concept-map-source-unmodified? "Changed title" marked-test-document))) diff --git a/architecture/manifest.rktd b/architecture/manifest.rktd new file mode 100644 index 0000000..008e49e --- /dev/null +++ b/architecture/manifest.rktd @@ -0,0 +1,58 @@ +#hash( + (namespace . "racket-wiki") + (pages . + (#hash((slug . "architectuur") + (title . "Architectuur van racket-wiki") + (file . "pages/architectuur.md") + (tags . ("racket-wiki" "architectuur" "overzicht"))) + #hash((slug . "structuur-en-samenhang") + (title . "Structuur en samenhang") + (file . "pages/structuur-en-samenhang.md") + (tags . ("racket-wiki" "architectuur" "structuur"))) + #hash((slug . "gedrag") + (title . "Gedrag en hoofdscenario's") + (file . "pages/gedrag.md") + (tags . ("racket-wiki" "architectuur" "gedrag"))) + #hash((slug . "data-en-versies") + (title . "Data, transacties en versiebeheer") + (file . "pages/data-en-versies.md") + (tags . ("racket-wiki" "architectuur" "data"))) + #hash((slug . "modulariteit") + (title . "Modulariteit en afhankelijkheden") + (file . "pages/modulariteit.md") + (tags . ("racket-wiki" "architectuur" "modulariteit"))) + #hash((slug . "onderhoudbaarheid") + (title . "Onderhoudbaarheid") + (file . "pages/onderhoudbaarheid.md") + (tags . ("racket-wiki" "architectuur" "onderhoudbaarheid"))) + #hash((slug . "analyseerbaarheid") + (title . "Analyseerbaarheid en diagnose") + (file . "pages/analyseerbaarheid.md") + (tags . ("racket-wiki" "architectuur" "analyseerbaarheid"))) + #hash((slug . "testbaarheid") + (title . "Testbaarheid en teststrategie") + (file . "pages/testbaarheid.md") + (tags . ("racket-wiki" "architectuur" "testbaarheid"))) + #hash((slug . "performance") + (title . "Verwachte performance op lange termijn") + (file . "pages/performance.md") + (tags . ("racket-wiki" "architectuur" "performance"))) + #hash((slug . "beveiliging") + (title . "Beveiliging en vertrouwen") + (file . "pages/beveiliging.md") + (tags . ("racket-wiki" "architectuur" "beveiliging"))) + #hash((slug . "code-regels") + (title . "Regels bij het coderen") + (file . "pages/code-regels.md") + (tags . ("racket-wiki" "architectuur" "code"))) + #hash((slug . "beheer-en-evolutie") + (title . "Beheer, wijzigingen en architectuurevolutie") + (file . "pages/beheer-en-evolutie.md") + (tags . ("racket-wiki" "architectuur" "beheer"))))) + (concept-maps . + (#hash((slug . "racket-wiki-architectuur") + (title . "racket-wiki - structuur en samenhang") + (file . "cmaps/architectuur.json")) + #hash((slug . "racket-wiki-kwaliteitskenmerken") + (title . "racket-wiki - kwaliteitskenmerken") + (file . "cmaps/kwaliteitskenmerken.json"))))) diff --git a/architecture/pages/analyseerbaarheid.md b/architecture/pages/analyseerbaarheid.md new file mode 100644 index 0000000..f85d342 --- /dev/null +++ b/architecture/pages/analyseerbaarheid.md @@ -0,0 +1,76 @@ +# Analyseerbaarheid en diagnose + +Analyseerbaarheid is het vermogen om van een symptoom naar de verantwoordelijke laag en toestand te komen. racket-wiki heeft daarvoor een gunstige eenvoudige processtructuur, maar nog geen volledige observabilitylaag. + +## Begin bij de grens + +Classificeer een probleem eerst op basis van het eerste aantoonbaar onjuiste resultaat: + +| Waarneming | Eerste controle | +| --- | --- | +| Server start of compileert niet | Racket-fout, ontbrekende `require`, migratie en configuratie | +| Request geeft 4xx/5xx | Route, sessie/rol/CSRF, requestbody en serverexception | +| Database-inhoud is onjuist | Opslagprocedure, basisversie en transactiestappen | +| API-JSON klopt maar scherm niet | `wiki.js`-state, route, DOM-id en renderfunctie | +| Alleen CMap-interactie faalt | hit-test, selectie, editorrecord, document vóór/na de handeling | +| Alleen Markdownweergave faalt | expansiestappen, Marked/EasyMDE, DOMPurify en hydratatie | +| Mail zonder STARTTLS werkt wel | TLS-context, certificaatketen, hostnaam en SMTP-capabilities | + +Deze aanpak voorkomt dat opslagcode wordt aangepast om een browserregressie te maskeren, of andersom. + +## Beschikbare signalen + +De backend schrijft start- en setupinformatie en laat technische uitzonderingen in de serverconsole zichtbaar. De frontend schrijft gerichte CMap-diagnostiek met een component- en versieprefx. De browser Network-tab toont requestmethode, status en JSON-response. PostgreSQL kan actuele rijen, versies en constraints rechtstreeks laten zien. + +De applicatie heeft daarnaast functionele diagnosebronnen: + +- pagina- en CMaphistorie toont welke actor op welk moment een versie schreef; +- `action` en `summary` onderscheiden create, edit, rename, autosave, snapshot en import; +- aliases verklaren waarom een oud pagina-adres nog opent; +- bijlagebeheer toont huidige en historische referenties; +- `/api/ping` onderscheidt browserconnectiviteit van een volledig vastgelopen UI; +- SMTP-testmail is synchroon en geeft de concrete verbindings- of authenticatiefout terug. + +## Reproduceerbare diagnose + +Leg voor een regressie ten minste vast: + +1. softwareversie en databaseschemaversie; +2. rol en route; +3. minimale handelingen vanaf een bekende toestand; +4. verwachte en werkelijke toestand; +5. relevante request/response; +6. actuele en voorgaande versie van het betrokken document; +7. console-uitvoer zonder geheimen. + +Voor een CMap-probleem is een klein document met twee of drie items beter dan een productiemap met honderden nodes. Voor TLS is een zelfstandige STARTTLS-spike beter dan meteen de volledige herstelmailketen te wijzigen. + +## Logregels + +Een diagnostische melding bevat component, softwareversie, bewerking en veilige identifiers zoals pagina- of CMapslug. Log nooit wachtwoorden, ruwe sessietokens, CSRF-tokens, herstelcodes, SMTP-wachtwoorden of volledige databaseconfiguratie. + +Gebruik consistente niveaus: + +- `info` voor start, migratie, import en afgeronde beheeracties; +- `warning` voor herstelbare afwijkingen, ontbrekende optionele configuratie en overgeslagen importitems; +- `error` voor een mislukte bewerking met voldoende technische oorzaak; +- uitvoerige CMap-events alleen achter een gerichte debugschakelaar wanneer het volume toeneemt. + +## Bekende blinde vlekken + +Er is momenteel geen request-id die browser, handler en SQL-bewerking verbindt. Er zijn geen structurele timings per endpoint, geen connectionpoolstatistieken en geen centrale foutregistratie. De frontend-CMapdiagnostiek is bruikbaar maar relatief uitvoerig en niet voor alle subsystemen gelijkvormig. + +Voeg observability incrementeel toe: + +1. eerst een request-id en gestandaardiseerde foutregel; +2. daarna tijdmetingen rond trage endpoints; +3. vervolgens database- en graphmetingen wanneer echte belasting dat vereist; +4. pas dan een externe metrics- of tracingstack wanneer lokaal loggen onvoldoende blijkt. + +## Analyseerbare code + +Expliciete tussenwaarden, herkenbare domeinfouten en kleine contracten zijn diagnosehulpmiddelen. Een exception `version-conflict` zegt meer dan een generieke SQL-fout. Een procedure die zelf de transactiegrens bevat, maakt duidelijk of een halve opslag mogelijk is. + +Gebruik geen brede `with-handlers` die een technische fout omzet in `#f` zonder context. Vang alleen een fout die op die laag betekenisvol vertaald kan worden; laat de oorspronkelijke exception anders door. + +Zie [Onderhoudbaarheid](racket-wiki:onderhoudbaarheid) voor de wijzigingswerkwijze en [Testbaarheid](racket-wiki:testbaarheid) voor het omzetten van een diagnose in een regressietest. diff --git a/architecture/pages/architectuur.md b/architecture/pages/architectuur.md new file mode 100644 index 0000000..5ef5172 --- /dev/null +++ b/architecture/pages/architectuur.md @@ -0,0 +1,52 @@ +# Architectuur van racket-wiki + +Deze documentatieset beschrijft de architectuur van racket-wiki zoals die in versie 0.2.94 bestaat. Zij is tegelijk een wegwijzer voor onderhoud: iedere pagina maakt onderscheid tussen bestaand gedrag, bekende grenzen en regels voor verdere ontwikkeling. + +{{cmap:racket-wiki-architectuur}} + +## Architectuur in één alinea + +racket-wiki is een kleine, zelf gehoste webapplicatie. Eén Racket-proces levert de HTTP-server, authenticatie, autorisatie, setup, databaseaanroepen en statische bestanden. PostgreSQL bevat de duurzame toestand: gebruikers, sessies, pagina's, onveranderlijke paginaversies, conceptmaps, CMap-versies, bijlagen en beheerinstellingen. De browser bevat de interactieve applicatie, Markdown-editor en CMap-editor. De grens tussen browser en backend is een JSON/HTTP-API. Normaal gebruik vereist geen externe CDN of afzonderlijke applicatieservice. + +## Leeswijzer + +| Onderwerp | Vraag die de pagina beantwoordt | +| --- | --- | +| [Structuur en samenhang](racket-wiki:structuur-en-samenhang) | Welke onderdelen zijn er en hoe zijn zij verbonden? | +| [Gedrag en hoofdscenario's](racket-wiki:gedrag) | Wat gebeurt er bij starten, lezen, wijzigen en herstellen? | +| [Data, transacties en versiebeheer](racket-wiki:data-en-versies) | Welke toestand wordt waar bewaard en welke invarianten gelden? | +| [Modulariteit en afhankelijkheden](racket-wiki:modulariteit) | Waar liggen de modulegrenzen en waar zitten risico's? | +| [Onderhoudbaarheid](racket-wiki:onderhoudbaarheid) | Hoe blijft de code begrijpelijk en wijzigbaar? | +| [Analyseerbaarheid en diagnose](racket-wiki:analyseerbaarheid) | Hoe wordt een storing of regressie gelokaliseerd? | +| [Testbaarheid en teststrategie](racket-wiki:testbaarheid) | Welke tests passen bij welke laag? | +| [Verwachte performance](racket-wiki:performance) | Waar schaalt het ontwerp goed en waar ontstaan knelpunten? | +| [Beveiliging en vertrouwen](racket-wiki:beveiliging) | Welke vertrouwensgrenzen en beveiligingsmaatregelen bestaan? | +| [Regels bij het coderen](racket-wiki:code-regels) | Welke concrete ontwerp- en stijlregels gelden voor nieuwe code? | +| [Beheer en evolutie](racket-wiki:beheer-en-evolutie) | Hoe worden schema, frontend-assets en architectuur gewijzigd? | + +{{cmap:racket-wiki-kwaliteitskenmerken}} + +## Belangrijkste architectuurbeslissingen + +De huidige vorm rust op een klein aantal bewuste keuzes: + +1. PostgreSQL is de enige duurzame inhoudsopslag. Ook bijlagen staan als `BYTEA` in de database. +2. Huidige pagina's en CMaps zijn snel leesbare projecties; iedere opslag maakt daarnaast een volledige, onveranderlijke versie. +3. Schrijven gebeurt met optimistic locking. De browser moet het bekende versienummer meesturen. +4. De backend bepaalt authenticatie, rollen, CSRF-controle en database-invarianten. De browser is niet de beveiligingsgrens. +5. Markdown wordt in de browser gerenderd en daarna met DOMPurify gesaneerd. +6. Browserbibliotheken worden tijdens setup vastgepind en lokaal geserveerd. +7. Pagina-adressen bestaan uit een namespace en stabiele slug. Deze set gebruikt de namespace `racket-wiki`. +8. Conceptmaps blijven native CMap-documenten. `{{cmap:...}}` sluit een read-only weergave in Markdown in zonder het CMap-formaat tot Mermaid te reduceren. + +## Kwaliteitsbeeld + +De architectuur past goed bij een persoonlijke of teamwiki: weinig processen, een duidelijke databasebron en volledige historie. De sterkste punten zijn de transactionele inhoudsopslag, eenvoudige deployment en lokale frontend-assets. De voornaamste ontwikkelpunten zijn de omvang van `server.rkt` en `static/js/wiki.js`, het ontbreken van een volwaardige geautomatiseerde testsuite, een nieuwe databaseverbinding per opslagbewerking en de N+1-aanpak waarmee de volledige navigatiegraaf wordt opgebouwd. + +Deze punten zijn geen reden voor een voorafgaande grote herbouw. De ontwikkelregel is: meet eerst, isoleer het concrete probleem en splits een module wanneer een wijziging daar aantoonbaar eenvoudiger of beter testbaar door wordt. + +## Eigenaarschap van deze documentatieset + +De bestanden onder `architecture/` zijn de bron van deze set. De importmodule plaatst een bronmarkering in iedere geïmporteerde pagina en CMap. Een herimport werkt alleen automatisch bij als de geïmporteerde inhoud sindsdien niet handmatig is gewijzigd. Lokale wijzigingen worden standaard behouden en gerapporteerd. + +Gebruik [Beheer en evolutie](racket-wiki:beheer-en-evolutie) voor de importprocedure en de regels om deze documentatie gelijk te laten lopen met de code. diff --git a/architecture/pages/beheer-en-evolutie.md b/architecture/pages/beheer-en-evolutie.md new file mode 100644 index 0000000..1994345 --- /dev/null +++ b/architecture/pages/beheer-en-evolutie.md @@ -0,0 +1,99 @@ +# Beheer, wijzigingen en architectuurevolutie + +## Deze documentatieset importeren + +Start de import vanuit de pakketmap met dezelfde datamap als de wiki: + +```text +racket architecture/import.rkt --data ./wiki-data --author "Hans Dijkema" +``` + +Voer eerst een controle zonder inhoudswijzigingen uit: + +```text +racket architecture/import.rkt --data ./wiki-data --dry-run +``` + +De import valideert alle bronbestanden, interne links, CMap-embeds, node-id's en connector-endpoints voordat hij wiki-inhoud schrijft. Daarna maakt hij de twaalf pagina's onder namespace `racket-wiki` en twee CMaps aan of werkt hij ze bij. + +## Bescherming van lokale wijzigingen + +Iedere geïmporteerde pagina en CMap bevat een bronhash. Bij herimport wordt de actuele inhoud opnieuw gehasht. + +- Ongewijzigde geïmporteerde inhoud kan veilig naar de nieuwe pakketversie worden bijgewerkt. +- Inhoud die al exact overeenkomt, krijgt geen zinloze extra versie. +- Handmatig gewijzigde inhoud wordt als `skipped-modified` gemeld en blijft staan. +- Alleen `--overwrite-modified` vervangt bewust zo'n lokale wijziging. + +Gebruik overschrijven pas na vergelijking met de pagina- of CMaphistorie: + +```text +racket architecture/import.rkt --data ./wiki-data \ + --author "Hans Dijkema" --overwrite-modified +``` + +De importmodule gebruikt de normale procedures uit `storage.rkt` en `cmap-storage.rkt`. Daardoor ontstaan gewone onveranderlijke versies en blijven optimistic locking en afgeleide pagina-indices actief. De import schrijft geen eigen SQL buiten die opslaglaag. + +## Bron aanpassen + +De bron staat onder `architecture/`: + +```text +architecture/ + manifest.rktd + import.rkt + pages/ + cmaps/ +``` + +Voeg een pagina of CMap eerst aan `manifest.rktd` toe en maak daarna het genoemde bestand. Alle architectuurpagina's gebruiken expliciete Markdown-links naar `racket-wiki:...`. Een ingesloten kaart gebruikt `{{cmap:racket-wiki-...}}` op een eigen regel. + +Voer na een wijziging uit: + +```text +raco test architecture/import.rkt +racket architecture/import.rkt --data ./wiki-data --dry-run +``` + +## Releasebeheer + +`info.rkt` is de primaire softwareversie. Frontenddiagnostiek bevat dezelfde versie om een browserfout aan het geleverde component te kunnen koppelen. De README houdt een beknopte chronologische changelog bij. + +Een releasepakket bevat broncode, statische basisbestanden, Scribble-documentatie en architectuurbronnen. Het bevat niet: + +- `wiki-data` of `database.rktd`; +- lokale vendor-downloads uit de datamap; +- gecompileerde output; +- editorback-ups; +- wachtwoorden, tokens of productiedata. + +## Database-evolutie + +Voor iedere schemawijziging komt een nieuwe migratie aan het einde van de keten. Test zowel een lege database als een upgrade vanaf de vorige release. Een migratie moet de applicatie-invarianten herstellen voordat zij haar versienummer registreert. + +Maak vóór een niet-triviale productiemigratie een PostgreSQL-back-up en noteer de herstelprocedure. Omdat inhoud, historie en bijlagen in PostgreSQL staan, is alleen een kopie van de pakketmap geen inhoudsback-up. + +## Frontend-evolutie + +Vendorbibliotheken zijn vastgepind. Een upgrade van EasyMDE, DOMPurify, highlight.js, diff2html, Lucide of de CMapbasis is een functionele wijziging en vereist gerichte smokechecks. Controleer vooral Markdownpariteit tussen preview, leesweergave en historie, en CMapselectie/hit-testing na een grafische update. + +Wanneer browsercode in ES-modules wordt gesplitst, moeten setup, statische routes, cachegedrag en versiecontrole als één wijziging worden behandeld. + +## Architectuurbesluiten + +Leg een besluit op de relevante architectuurpagina vast wanneer het één van deze zaken verandert: + +- proces- of deploymentstructuur; +- modulegrens of afhankelijkheidsrichting; +- database-invariant of versieformaat; +- vertrouwensgrens of beveiligingsstandaard; +- teststrategie; +- schaalverwachting of bewaarbeleid. + +Een afzonderlijk zwaar ADR-systeem is nu niet nodig. Een compacte sectie met context, keuze, gevolgen en eventuele terugweg op de betrokken pagina is voldoende. + +## Periodieke controle + +Controleer bij enkele releases of de documentatie nog overeenkomt met modulelijst, tabellen, routes en actuele knelpunten. Verwijder achterhaalde risico's wanneer zij aantoonbaar zijn opgelost en voeg geen toekomstige componenten als huidige architectuur toe. + +De overzichtspagina [Architectuur van racket-wiki](racket-wiki:architectuur) blijft het ingangspunt. [Onderhoudbaarheid](racket-wiki:onderhoudbaarheid) bepaalt wanneer documentatie bij een codewijziging hoort; [Verwachte performance](racket-wiki:performance) bepaalt dat schaalmaatregelen op metingen moeten volgen. diff --git a/architecture/pages/beveiliging.md b/architecture/pages/beveiliging.md new file mode 100644 index 0000000..2255d2c --- /dev/null +++ b/architecture/pages/beveiliging.md @@ -0,0 +1,60 @@ +# Beveiliging en vertrouwen + +## Vertrouwensgrenzen + +De browser, requestparameters, Markdown, CMap-JSON, bestandsnamen en SMTP-serverreacties zijn onbetrouwbare invoer. De Racket-backend bewaakt identiteit, rol, CSRF en domeinvalidatie. PostgreSQL bewaakt relationele constraints en transacties. Een reverse proxy bewaakt in een productieopstelling doorgaans de externe TLS-verbinding. + +Een verborgen knop is geen autorisatie. Een door DOMPurify gesaneerde preview is geen reden om raw HTML elders ongesaneerd in te voegen. Een client-side versienummer is geen waarheid totdat de backend het onder rijlocking met PostgreSQL heeft vergeleken. + +## Authenticatie en sessies + +Wachtwoorden worden met PBKDF2-HMAC-SHA256 en een hoge iteratiewaarde gehasht. Sessietokens zijn willekeurig; alleen hun SHA-256-hash wordt opgeslagen. Een sessie bevat daarnaast een apart CSRF-token en vervaltijd. Uitgeschakelde gebruikers en verlopen sessies worden niet geaccepteerd. + +De sessiecookie moet `Secure` zijn wanneer de externe site HTTPS gebruikt. Cookiebeleid en reverse-proxyheaders moeten bij deployment samen worden getest. + +## Rollen + +De rangorde is `reader < editor < admin`. Iedere beschermde handler gebruikt server-side rolcontrole. Schrijvende handlers vereisen bovendien CSRF. Nieuwe endpoints krijgen een expliciete minimale rol; zij erven niet toevallig veiligheid doordat de frontend de route niet toont. + +## Inhoud en rendering + +Markdown wordt door EasyMDE/Marked gerenderd en daarna door DOMPurify gesaneerd. Interne WikiWords, namespaced links, todo-markeringen en CMap-embeds worden via gecontroleerde transformaties toegevoegd. Attributen die na sanering programmatisch worden gemaakt, krijgen alleen gevalideerde slugs en lokaal opgebouwde routes. + +Uploads krijgen een server-side veilige opgeslagen naam en MIME-type op basis van bekende extensies. Downloadroutes verwerpen padseparators. Bij uitbreiding met inline actieve formaten zoals SVG of HTML moet afzonderlijk worden beoordeeld of download, sandboxing of sanering nodig is. + +## Database en geheimen + +SQL gebruikt parameters voor inhoudelijke waarden. Databasecredentials staan in `wiki-data/database.rktd`; de code probeert de bestandsrechten te beperken. De datamap is daarmee geheim en hoort niet in een ZIP, publieke repository of statische webroot. + +Ook SMTP-wachtwoorden kunnen in `wiki_settings` of omgevingsvariabelen staan. Zij mogen niet via het beheer-API worden teruggegeven en niet in logregels verschijnen. Een lege wachtwoordinvoer bij de testmail betekent: gebruik het reeds opgeslagen geheim. + +## Wachtwoordherstel + +De publieke aanvraag meldt niet of gebruiker of e-mailadres bestaat. Herstelcodes zijn willekeurig, alleen gehasht opgeslagen, één uur geldig en eenmalig. Rate limiting begrenst aanvragen per gebruiker. Een succesvolle reset trekt bestaande sessies in. + +De publieke basis-URL voor e-mail moet expliciet worden ingesteld; een door de request aangeleverde Host-header is geen betrouwbare basis voor beveiligingslinks. + +## SMTP en STARTTLS + +Met STARTTLS en **Onvertrouwde certificaten accepteren** uit gebruikt de wiki `ssl-secure-client-context`: certificaatketen en SMTP-hostnaam worden gecontroleerd. Met het vinkje aan gebruikt de wiki `ssl-make-client-context 'auto`: verkeer is versleuteld, maar de serveridentiteit is niet bewezen. + +De uitzonderingsoptie is uitsluitend bedoeld voor een bewust vertrouwde lokale mailserver. Een eigen lokale CA toevoegen aan de trust store is veiliger dan verificatie uitschakelen. + +## Beschikbaarheid en misbruik + +Er gelden al document- en veldvalidaties, maar een algemeen uploadmaximum en request-rate limiting zijn toekomstige versterkingen. De CMap-opslag begrenst het JSON-document tot 10 MiB. Grote Markdown, bijlagen, graph-opbouw en dure zoekvragen kunnen anders geheugen of verwerkingstijd gebruiken. + +Voor een publiek bereikbare installatie horen reverse-proxylimits, databaseback-ups, logrotatie en monitoring bij het beveiligingsmodel. Beschikbaarheid is ook een beveiligingseigenschap. + +## Beveiligingsregels voor wijzigingen + +1. Behoud parametrische SQL. +2. Valideer opnieuw aan de servergrens, ook als de browser valideert. +3. Log nooit geheimen of ruwe tokens. +4. Gebruik veilige standaardinstellingen; een uitzondering vereist een expliciete keuze en uitleg. +5. Maak accountenumeratie niet mogelijk via tekst, statuscode of meetbaar afwijkend gedrag waar praktisch. +6. Test iedere nieuwe write-route op sessie, rol en CSRF. +7. Houd vendorversies vastgepind en controleer downloads via HTTPS. +8. Behandel archiveren, herstellen en importeren als mutaties met auditinformatie. + +Zie [Data en versies](racket-wiki:data-en-versies) voor opslaginvarianten en [Testbaarheid](racket-wiki:testbaarheid) voor de noodzakelijke securitytests. diff --git a/architecture/pages/code-regels.md b/architecture/pages/code-regels.md new file mode 100644 index 0000000..c7d908b --- /dev/null +++ b/architecture/pages/code-regels.md @@ -0,0 +1,102 @@ +# Regels bij het coderen + +Deze regels gelden voor nieuwe code en voor code die inhoudelijk wordt gewijzigd. Bestaande code hoeft niet mechanisch te worden herschreven zonder functionele reden. + +## Algemeen ontwerp + +1. Los het probleem op in de laag die eigenaar is van de invariant. +2. Houd de oplossing klein en samenhangend; bouw geen framework voor één geval. +3. Gebruik statische `require`-afhankelijkheden. Vermijd `dynamic-require` als middel om moduleontwerp uit te stellen. +4. Geef configuratie en betekenisvolle dependencies expliciet door. +5. Bewaar beveiliging en dataintegriteit server-side. +6. Maak een databasebewerking atomair wanneer haar afgeleide gegevens samen met de bron moeten veranderen. +7. Behoud backward compatibility van database, CMap-document en API tenzij een migratie expliciet is ontworpen. + +## Racket-stijl + +Kies expliciete, goed leesbare constructies boven compacte combinaties van kleine idiomen. Gebruik benoemde tussenwaarden wanneer zij de reden van een stap duidelijk maken. Gebruik het Unicode-symbool `λ` voor anonieme procedures. + +Schrijf `cond`-clausules met blokhaken: + +```racket +(cond + [(not page) #f] + [(archived? page) (archive-result page)] + [else (current-result page)]) +``` + +Vermijd constructies waarin `and`, falsy waarden en filtering tegelijk de datastructuur vormen. Schrijf de bedoelde keuze expliciet: + +```racket +(filter (λ (value) value) + (list (if include-title? 'title #f) + (if include-tags? 'tags #f))) +``` + +Gebruik mutatie wanneer die de toestand werkelijk modelleert en lokaal blijft. Verberg een eenvoudige `hash-set!` of `set!` niet achter een abstractie die geen domeinbetekenis toevoegt. + +## Procedures en contractcommentaar + +Een publieke of niet-triviale procedure beschrijft waar nuttig: + +```text +goal : Waarom bestaat deze procedure? +pre : Welke invoer en toestand worden verondersteld? +post : Welke toestand is na succes veranderd? +result : Welke waarde of fout krijgt de aanroeper? +``` + +Commentaar verklaart beslissingen, grenzen en afwijkend gedrag. Het herhaalt niet regel voor regel de code. + +Gebruik betekenisvolle namen zoals `valid-page-reference?`, `read-concept-map` en `replace-current-attachment-references!`. Een predicaat eindigt op `?`; een procedure die duurzame toestand wijzigt meestal op `!`. + +## Module-interfaces + +Exporteer alleen wat een andere module gebruikt of als publieke API bedoeld is. Een opslagmodule accepteert domeinwaarden en `config`, niet een HTTP-request. Een HTTP-handler bouwt geen SQL. + +Maak een helper wanneer: + +- dezelfde invariant op meerdere plaatsen identiek moet blijven; +- de naam domeinbetekenis toevoegt; +- een pure, testbare grens ontstaat; +- foutafhandeling daardoor op de juiste laag komt. + +Maak geen helper die slechts één aanroep doorgeeft zonder extra contract. + +## SQL en transacties + +Gebruik queryparameters voor alle waarden. Dynamisch samengestelde SQL is alleen toegestaan voor vaste, door de code gekozen fragmenten zoals een bekende kolomlijst. + +Lees bij een optimistic-lock-write de rij met `FOR UPDATE`, controleer de basisversie en schrijf actuele toestand, historie en afgeleide indices binnen dezelfde transactie. Zet een normale domeinfout om in een herkenbare fout zoals `version-conflict`; verberg onverwachte databasefouten niet. + +Een migratie krijgt een nieuw oplopend nummer. Wijzig een reeds uitgebrachte migratie niet alsof installaties haar nog niet hebben uitgevoerd. + +## JavaScript en DOM + +Gebruik de centrale `api()`-functie voor JSON-calls, zodat CSRF en uniforme foutafhandeling behouden blijven. Routeer via de centrale hashnavigatie; wijzig niet alleen de zichtbare view terwijl de URL achterblijft. + +Alle HTML uit Markdown gaat door DOMPurify. Gebruik `textContent` voor gewone tekst en bouw elementen expliciet. Als `innerHTML` noodzakelijk is, moet de bron en sanering in dezelfde functie zichtbaar zijn. + +Een nieuwe DOM-id wordt tegelijk in `index.html` en de bijbehorende code toegevoegd. Een nieuwe vertaalkey krijgt ten minste Engelse en Nederlandse standaardtekst. + +CMap-bewerkingen gebruiken de editor-API en eindigen als één herkenbare Undo-transactie. Verander niet rechtstreeks een grafisch element zonder het bijbehorende editorrecord en de serialisatie bij te werken. + +## Fouten en logging + +Vang alleen uitzonderingen die op de huidige laag betekenisvol kunnen worden vertaald. Geef bij een technische fout component en veilige identifier mee. Log geen wachtwoord, databasecredential, sessietoken, CSRF-token of herstelcode. + +Browsermeldingen zijn begrijpelijk voor de gebruiker. Technische details mogen aanvullend in console of serverlog staan, maar niet in plaats van een concrete uitleg. + +## Versie en release + +Bij iedere release: + +1. verhoog `info.rkt`; +2. werk zichtbare frontenddiagnostiek en componentversies bij; +3. voeg een README-changelogitem toe; +4. pas relevante architectuurpagina's aan; +5. compileer Racket en draai tests; +6. controleer JavaScript, DOM-id's en vertaalkeys; +7. inspecteer het ZIP-archief op volledigheid en geheimen. + +Zie [Onderhoudbaarheid](racket-wiki:onderhoudbaarheid) voor de werkwijze en [Modulariteit](racket-wiki:modulariteit) voor de gewenste afhankelijkheidsrichting. diff --git a/architecture/pages/data-en-versies.md b/architecture/pages/data-en-versies.md new file mode 100644 index 0000000..6c36879 --- /dev/null +++ b/architecture/pages/data-en-versies.md @@ -0,0 +1,86 @@ +# Data, transacties en versiebeheer + +## PostgreSQL als bron van waarheid + +De database bevat zowel de huidige toestand als de auditgeschiedenis. De browsercache, Undo/Redo-stacks en ingesloten CMaps zijn afgeleide of tijdelijke weergaven en mogen nooit als enige bron van inhoud gelden. + +| Tabel | Betekenis | +| --- | --- | +| `users` | Identiteit, profiel, rol, status en wachtwoordhash | +| `sessions` | Gehashte sessietokens, CSRF-token en vervaltijd | +| `pages` | Actuele pagina, namespace, slug, tags, versieteller en zoekvector | +| `page_versions` | Volledige onveranderlijke snapshots van titel, Markdown en tags | +| `page_aliases` | Oude adressen die naar dezelfde pagina-id blijven verwijzen | +| `todo_items` | Afgeleide index van huidige `todo(...)`-markeringen | +| `bookmarks` | Gebruikersspecifieke verwijzingen naar pagina-id's | +| `attachments` | Metadata en volledige binaire inhoud van uploads | +| `attachment_references` | Huidige en historische verwijzingen vanuit paginaversies | +| `concept_maps` | Actuele titel, JSONB-document en versieteller per CMap | +| `concept_map_versions` | Volledige onveranderlijke CMap-snapshots | +| `password_reset_tokens` | Gehashte, tijdelijke en eenmalige herstelcodes | +| `wiki_settings` | Beheerinstellingen, momenteel vooral e-mail | +| `wiki_schema` | Geïnstalleerde migratieversie | + +## Pagina-identiteit + +Een pagina wordt intern door `pages.id` geïdentificeerd. Het externe adres is `namespace:slug`, of alleen `slug` in de rootnamespace. Namespace en slug zijn afzonderlijke kolommen en samen uniek. Titelwijziging verandert de slug niet automatisch. Bij een echte adreswijziging blijft het oude adres als alias naar dezelfde id bestaan. + +Deze scheiding voorkomt dat links bij iedere redactionele titelwijziging breken. Code die relaties bewaart, gebruikt waar mogelijk de id; Markdown en CMaps gebruiken het externe, leesbare paginareferentieformaat. + +## Schrijftransactie van een pagina + +Een paginaopslag is één atomaire transactie: + +1. Zoek en vergrendel de actuele pagina met `FOR UPDATE`. +2. Vergelijk `current_version` met de basisversie van de editor. +3. Verhoog de versie en werk de actuele projectie bij. +4. Voeg dezelfde inhoud toe aan `page_versions`. +5. Bouw de todo-index voor deze pagina opnieuw op. +6. Vervang huidige bijlageverwijzingen en leg historische verwijzingen voor de nieuwe versie vast. +7. Commit alles, of niets. + +Een CMap-opslag volgt hetzelfde kernpatroon: rij vergrendelen, versienummer vergelijken, actuele JSONB bijwerken en dezelfde volledige toestand aan `concept_map_versions` toevoegen. + +## Waarom volledige snapshots + +Volledige snapshots zijn eenvoudig te begrijpen, herstellen en vergelijken. Er is geen keten van patches nodig om versie 37 te reconstrueren en een defecte diff kan de historie niet onleesbaar maken. Voor de verwachte wikiomvang is deze eenvoud belangrijker dan maximale opslagcompactheid. + +De prijs is lineaire databasegroei met het aantal versies maal de documentgrootte. Vooral autosave van grote CMaps kan daardoor op termijn veel JSONB opslaan. Bewaarbeleid of deduplicatie hoort pas te worden ontworpen nadat echte datagroei is gemeten; zie [Verwachte performance](racket-wiki:performance). + +## Afgeleide gegevens + +`pages.search_document`, `todo_items` en `attachment_references.current_reference` zijn afgeleide gegevens. Zij moeten in dezelfde transactie als hun bron worden aangepast. Een los herstelcommando mag ze opnieuw kunnen opbouwen, maar gewone reads mogen niet afhankelijk zijn van een toevallig later achtergrondproces. + +De gecombineerde navigatiegraaf is momenteel volledig afgeleid in de browser. Zij wordt niet als databasegraaf bewaard. + +## Bijlagen + +Een upload hoort bij de id van de eigenaarpagina en krijgt een veilige opgeslagen naam. PostgreSQL bewaart zowel metadata als bytes. Markdown verwijst via de pagina en opgeslagen naam. Huidige en historische referenties worden afzonderlijk gevolgd, zodat beheer kan zien of een bestand alleen nog in oude versies voorkomt. + +Het huidige downloadpad leest de volledige `BYTEA` in geheugen voordat de response wordt gemaakt. Dit is eenvoudig en correct voor gebruikelijke wiki-afbeeldingen en documenten, maar vraagt begrenzing of streaming wanneer grote bestanden een doel worden. + +## Migraties + +`private/migrations.rkt` bevat een oplopende keten. Iedere stap moet herhaalbare SQL gebruiken waar dat nodig is, de nieuwe schemaversie pas na succesvolle omzetting registreren en binnen de omvattende transactie blijven. Oude stappen worden niet achteraf inhoudelijk herschreven, omdat bestaande installaties ze al uitgevoerd kunnen hebben. + +Een nieuw schema vereist: + +1. een nieuwe migratiestap; +2. aanpassing van `migrate-database!`; +3. controles voor een verse database én een upgrade vanaf de vorige versie; +4. documentatie van nieuwe tabellen, kolommen, indexen en herstelgedrag; +5. een back-up- en terugrolnotitie wanneer de omzetting niet triviaal omkeerbaar is. + +## Invarianten + +De volgende regels mogen niet alleen in de browser staan: + +- iedere actuele versie heeft een overeenkomstige onveranderlijke snapshot; +- versienummers nemen per object strikt toe; +- een stale editor overschrijft geen nieuwere toestand; +- e-mailadressen zijn hoofdletterongevoelig uniek wanneer ingevuld; +- sessies en herstelcodes worden alleen gehasht opgeslagen; +- een bijlage die nog vanuit huidige inhoud wordt gebruikt, wordt niet als orphan verwijderd; +- een gearchiveerde pagina of CMap verschijnt niet in normale lees- en lijstoperaties. + +Zie [Beveiliging en vertrouwen](racket-wiki:beveiliging) voor geheimen en [Testbaarheid](racket-wiki:testbaarheid) voor de tests die deze invarianten moeten afdekken. diff --git a/architecture/pages/gedrag.md b/architecture/pages/gedrag.md new file mode 100644 index 0000000..d6a76a3 --- /dev/null +++ b/architecture/pages/gedrag.md @@ -0,0 +1,61 @@ +# Gedrag en hoofdscenario's + +Deze pagina beschrijft gedrag als ketens van waarneembare stappen. De backend blijft in iedere keten verantwoordelijk voor autorisatie en duurzame invarianten; browserstatus is slechts tijdelijke presentatietoestand. + +## Start en eerste setup + +1. `main.rkt` maakt de datamap aan en leest `database.rktd` wanneer die bestaat. +2. Bij bekende database-instellingen voert `initialize-database!` alle nog ontbrekende migraties in volgorde uit en verwijdert het verlopen sessies. +3. `server.rkt` controleert of schema, een ingeschakelde administrator en alle vendor-assets aanwezig zijn. +4. Zolang een voorwaarde ontbreekt, gaan normale requests naar `/setup`. +5. Setup test PostgreSQL, installeert het schema, maakt de eerste administrator en downloadt de vastgepinde frontendbestanden. +6. Na een volledige setup gaat de browser naar `/login`. + +Setup is daarmee ook een reparatiepad voor ontbrekende browserassets. Het is geen anonieme route naar wiki-inhoud. + +## Aanmelden en een request uitvoeren + +Bij succesvolle authenticatie genereert de backend een willekeurig sessietoken en CSRF-token. Alleen de SHA-256-hash van het sessietoken staat in PostgreSQL; het ruwe token gaat in de cookie naar de browser. Iedere API-handler die inhoud schrijft vereist een geldige sessie, voldoende rol en het CSRF-token uit die sessie. + +`reader` leest. `editor` erft lezen en mag pagina's en CMaps creëren, wijzigen, archiveren en bestanden uploaden. `admin` erft editorrechten en beheert gebruikers en systeeminstellingen. + +## Pagina lezen + +1. De browser haalt eerst de paginacatalogus op zonder alle Markdown. +2. Navigatie naar een pagina vraagt de actuele pagina met Markdown op. +3. Namespaced links en WikiWords worden naar interne routes vertaald. +4. EasyMDE/Marked rendert dezelfde Markdown voor leesweergave, preview en historische versies. +5. DOMPurify saneert de HTML voordat zij in het document komt. +6. Fenced code wordt met highlight.js gemarkeerd en `{{cmap:slug}}` wordt daarna met een read-only CMap gehydrateerd. + +Een dubbelklik op een ingebedde CMap opent de volledige editor. De ingesloten kaart is niet een tweede opslagvorm. + +## Pagina maken of wijzigen + +Een nieuwe pagina krijgt een namespace en stabiele slug. Bij opslaan stuurt de browser titel, Markdown, tags en bij een bestaande pagina het bekende `currentVersion` mee. + +De opslagmodule vergrendelt de actuele rij binnen een PostgreSQL-transactie. Alleen als de meegestuurde basisversie gelijk is aan de databaseversie worden actuele toestand, nieuwe onveranderlijke versie, todo-index en bijlageverwijzingen samen vastgelegd. Is iemand anders eerder geweest, dan volgt `version-conflict`; de oudere editor mag de nieuwere versie niet stilzwijgend overschrijven. + +Hernoemen of verplaatsen behoudt dezelfde pagina-id en maakt een alias voor het oude adres. Archiveren verwijdert de pagina niet fysiek en behoudt historie en bijlagen. + +## Conceptmap bewerken + +De CMap-editor houdt items, verbindingszinnen, connectors, groepen en sub-CMaps in één document met `schemaVersion: 1`. Bewerkingen gaan eerst door een lokale Undo/Redo-historie. Na een afgeronde handeling plant de browser een autosave; een nieuwe handeling verschuift die timer. Een lopende save kan een volgende save noodzakelijk maken, zodat tussentijdse wijzigingen niet verdwijnen. + +Iedere serveropslag controleert opnieuw het basisversienummer en schrijft een volledige CMap-snapshot. Autosave beschermt de werkstand. **Snapshot maken** schrijft ook zonder inhoudsverschil een herkenbaar historisch moment met een omschrijving. Een historische versie laden verandert alleen de editor; pas een volgende opslag maakt daarvan een nieuwe actuele versie. + +## Zoeken, Recent en navigatiegraaf + +Paginazoeken gebruikt een opgeslagen gewogen `tsvector` en GIN-index. CMap-zoeken verzamelt titel, slug, labels en synopses uit JSONB. Recent voegt de nieuwste pagina- en CMapwijzigingen samen. + +De gecombineerde navigatiegraaf leest verwijzingen uit alle huidige pagina's en CMaps. Hij wordt in de browser gecachet totdat een catalogus opnieuw wordt geladen. Dit levert rijke navigatie op, maar is het belangrijkste schaalrisico; zie [Verwachte performance](racket-wiki:performance). + +## Wachtwoordherstel en mail + +De publieke herstelactie geeft altijd dezelfde reactie, ook als een account niet bestaat. Een echte aanvraag maakt een gehashte, eenmalige code die één uur geldig is en past rate limiting per gebruiker toe. Na succesvol herstel worden bestaande sessies ingetrokken. + +SMTP kan STARTTLS gebruiken. Standaard gebruikt mail `ssl-secure-client-context` en controleert certificaatketen en hostnaam. Alleen voor een bewust vertrouwde lokale server kan de administrator onvertrouwde certificaten accepteren; dan gebruikt de verbinding moderne TLS zonder serverauthenticatie. De testmail gebruikt eerst de nog niet opgeslagen formulierwaarden. + +## Foutgedrag + +Verwachte domeinfouten worden zo dicht mogelijk bij hun grens herkend: ongeldige input als 400, ontbrekende authenticatie als 401, onvoldoende rechten als 403, ontbrekende inhoud als 404 en versieconflict als 409. Technische uitzonderingen moeten voldoende context in de serverconsole behouden zonder wachtwoorden, tokens of databasegeheimen te loggen. diff --git a/architecture/pages/modulariteit.md b/architecture/pages/modulariteit.md new file mode 100644 index 0000000..d9935e5 --- /dev/null +++ b/architecture/pages/modulariteit.md @@ -0,0 +1,62 @@ +# Modulariteit en afhankelijkheden + +## Huidige modulegrenzen + +De backend is functioneel opgesplitst. Configuratie, databaseverbinding, migraties, authenticatie, paginaopslag, CMap-opslag, mail, setup en vendorbeheer hebben ieder een herkenbare module. Deze modules communiceren hoofdzakelijk met gewone Racket-waarden: structs, hashes, lijsten en strings. + +De belangrijkste grens is die tussen `server.rkt` en de opslagmodules. `server.rkt` hoort HTTP te begrijpen; `storage.rkt`, `cmap-storage.rkt` en `auth.rkt` horen domeinbewerkingen en database-invarianten te begrijpen. Geen opslagprocedure mag een webrequest nodig hebben. + +De frontend heeft een vergelijkbare grens. `cmap-racket-wiki.js` implementeert een editorcomponent met callbacks voor openen, selectie en wijzigingen. `wiki.js` koppelt die callbacks aan routes, API's en dialoogvensters. + +## Sterke punten + +De volgende onderdelen zijn al goed geïsoleerd: + +- `wiki-config` bundelt runtimekeuzes en voorkomt verspreide globale configuratie. +- `call-with-wiki-database` centraliseert openen en sluiten van verbindingen. +- opslagprocedures kapselen SQL en transacties in. +- de CMap-documentvorm is een expliciet JSON-contract met `schemaVersion`. +- Markdownrendering gaat via één frontendfunctie voor lezen, preview en historie. +- vertalingen hebben één effectieve bron voor backend en frontend. +- frontend-vendorbestanden worden door één module beheerd en gecontroleerd. + +## Huidige concentraties + +`server.rkt` bevat zowel routing als veel requestparsing en handlerlogica. `static/js/wiki.js` bevat vrijwel de hele single-page applicatie. Dat maakt zoeken eenvoudig, maar vergroot de kans dat een wijziging onverwacht een ander view- of routepad raakt. + +`private/storage.rkt` combineert pagina's, historie, zoeken, bookmarks, aliases en uploads. Die onderdelen delen pagina-identiteit en transacties, maar hoeven niet onbeperkt samen te groeien. + +Deze concentraties zijn technische schuld, geen automatische opdracht tot een grote opsplitsing. Een splitsing moet een concreet voordeel hebben: een kleiner contract, onafhankelijke tests of duidelijker eigenaarschap. + +## Gewenste afhankelijkheidsregels + +1. Een buitenste laag mag een binnenste dienst kennen; omgekeerd niet. HTTP kent opslag, opslag kent geen HTTP. +2. SQL blijft in database- en opslagmodules. JavaScript bouwt geen SQL-achtige querysemantiek na. +3. Authenticatie en autorisatie blijven server-side. UI-zichtbaarheid is alleen gebruiksgemak. +4. Cross-cutting gedrag krijgt één expliciete helper wanneer er werkelijk meerdere gebruikers zijn. Maak geen wrapper voor één triviale aanroep. +5. Een module exporteert alleen procedures en structs die een andere module werkelijk nodig heeft. +6. Gebruik geen `dynamic-require` om een heldere statische afhankelijkheid te verbergen. +7. Een nieuwe module krijgt één samenhangende reden om te wijzigen; een verzameling toevallige helpers is geen moduleontwerp. + +## Waarschijnlijke toekomstige extracties + +Wanneer `server.rkt` verder groeit, ligt opsplitsing per adaptergebied voor de hand: sessie/profiel, pagina's, CMaps en beheer. De centrale router kan dan dun blijven. Handlers ontvangen `config` expliciet en roepen dezelfde bestaande diensten aan. + +Wanneer `wiki.js` verder groeit, zijn route/state, pagina-editor, CMap-host, graph-view en admin-views natuurlijke grenzen. Splits alleen met native ES-modules wanneer de setup- en cacheversies van alle scripts tegelijk beheerst worden. + +Wanneer paginaopslag wordt aangepast, kunnen bookmarks, aliases en uploads later eigen modules krijgen. De pagina-schrijftransactie en afgeleide indices moeten daarbij als één consistente operatie behouden blijven; opsplitsing van bestanden mag geen opsplitsing van de transactie veroorzaken. + +## Contracten tussen modules + +Een goed contract specificeert: + +- geldige invoer en normalisatie; +- welke toestand wordt gelezen of gewijzigd; +- transactiegrens en concurrencygedrag; +- resultaatvorm; +- herkenbare domeinfouten; +- maximale document- of uploadgrootte waar relevant. + +De bestaande `goal / pre / post / result`-commentaren zijn hiervoor geschikt, zolang zij het niet-zichtbare contract beschrijven en niet slechts de procedurecode navertellen. + +Zie [Regels bij het coderen](racket-wiki:code-regels) voor de concrete stijl en [Testbaarheid](racket-wiki:testbaarheid) voor het testen per grens. diff --git a/architecture/pages/onderhoudbaarheid.md b/architecture/pages/onderhoudbaarheid.md new file mode 100644 index 0000000..fcaba7f --- /dev/null +++ b/architecture/pages/onderhoudbaarheid.md @@ -0,0 +1,69 @@ +# Onderhoudbaarheid + +Onderhoudbaarheid betekent hier dat een ontwikkelaar een wijziging kan lokaliseren, de gevolgen kan overzien en het resultaat kan controleren zonder eerst de hele applicatie te herschrijven. + +## Wat al helpt + +De applicatie heeft één codebase, één backendproces en één duurzame database. Versies staan centraal in `info.rkt`; schemawijzigingen hebben een oplopende migratieketen. Pagina- en CMapversies zijn volledig en onveranderlijk, waardoor fouten in de actuele toestand niet meteen de historie vernietigen. + +Backendprocedures hebben vaak een contractcommentaar met doel, preconditie, postconditie en resultaat. De browsercode gebruikt vergelijkbare JSDoc-blokken bij ingewikkelde functies. Frontendbibliotheken zijn vastgepind, zodat een CDN-update niet onverwacht productiegedrag verandert. + +## Onderhoudsrisico's + +De grootste risico's zijn concentratie en impliciete koppeling: + +- `server.rkt` en `wiki.js` kennen veel scenario's; +- HTML-id's in `index.html` en `$()`-aanroepen in `wiki.js` vormen een compileertijd-onzichtbaar contract; +- vertaalkeys worden tussen Racket, HTML en JavaScript gedeeld; +- CMap-documentvelden worden zowel door opslag als editorcode verondersteld; +- versiestempels staan behalve in `info.rkt` ook in frontenddiagnostiek; +- de huidige geautomatiseerde testdekking is beperkt tot de nieuwe architectuurimportmodule. + +Deze koppelingen moeten bij iedere release statisch of geautomatiseerd worden gecontroleerd. + +## Wijzigingswerkwijze + +Een onderhoudbare wijziging volgt deze volgorde: + +1. Beschrijf eerst de waarneembare regressie of gewenste invariant. +2. Zoek de laag die eigenaar is van die invariant. +3. Maak de kleinste samenhangende wijziging op die laag. +4. Voeg een regressietest toe op het laagst mogelijke niveau. +5. Controleer aangrenzende contracten: database, API, browserstate, DOM-id's en vertalingen. +6. Werk versienummer, changelog en relevante architectuurpagina bij. +7. Voer compileer-, JavaScript-, import- en smokecontroles uit. + +Bij een CMap-interactieregressie hoort bijvoorbeeld eerst een editorcomponenttest of gerichte browser-spike. Een aanpassing in opslagcode is alleen passend wanneer het opgeslagen document onjuist is. + +## Leesbaarheid als ontwerpkeuze + +Nieuwe Racket-code kiest expliciete, leesbare constructies boven compacte combinaties van idiomen. Een afzonderlijke `if`, benoemde tussenwaarde of kleine inhoudelijke helper is beter dan een korte expressie waarvan falsy filtering, mutatie en datastructuurkennis tegelijk begrepen moeten worden. Gebruik `λ` voor anonieme procedures. + +Leesbaarheid betekent niet dat iedere aanroep een wrapper krijgt. Een helper verdient een naam wanneer die naam domeinbetekenis toevoegt, herhaling veilig centraliseert of een testbare grens maakt. + +Zie [Regels bij het coderen](racket-wiki:code-regels) voor de volledige set. + +## Versies en compatibiliteit + +Een release verandert `info.rkt` en alle zichtbare frontendversiestempels. Bestaande databases worden uitsluitend via een nieuwe migratie vooruitgebracht. Een CMap-documentwijziging verhoogt `schemaVersion` en vereist expliciete leescompatibiliteit of migratie; onbekende velden mogen niet zonder besluit verdwijnen. + +API-contracten worden bij voorkeur compatibel uitgebreid. Wanneer een veld verplicht wordt, moeten oude browserassets en nieuwe backend niet tijdelijk onverenigbaar kunnen worden geserveerd. Cacheversies en deploymentvolgorde horen daarom bij de wijziging. + +## Documentatie als onderdeel van de code + +Deze architectuurset staat als bronbestanden in het pakket. De importmodule valideert interne paginalinks, CMap-embeds, nodeverwijzingen en connector-endpoints voordat zij iets schrijft. Daardoor is documentatiesamenhang niet alleen een handmatige afspraak. + +Een wijziging die een modulegrens, gegevensinvariant, beveiligingsregel, teststrategie of schaalverwachting verandert, is pas afgerond wanneer de bijbehorende pagina is aangepast. Gebruik [Beheer en evolutie](racket-wiki:beheer-en-evolutie) om de set opnieuw te importeren zonder lokale wijzigingen stilzwijgend te overschrijven. + +## Vermijd voortijdige herbouw + +Onderhoudbaarheid neemt niet automatisch toe door meer lagen, dependency injection of generieke frameworks. Voor racket-wiki blijft de voorkeur: + +- directe statische dependencies; +- gewone Racket-waarden; +- kleine publieke interfaces; +- transacties rond echte invarianten; +- extractie na aantoonbare herhaling of testbehoefte; +- meting vóór performance-architectuur. + +Dat houdt de applicatie passend bij haar doel: een begrijpelijke, zelf te beheren wiki en geen platform dat zijn eigen uitbreiding belangrijker maakt dan de inhoud. diff --git a/architecture/pages/performance.md b/architecture/pages/performance.md new file mode 100644 index 0000000..92493db --- /dev/null +++ b/architecture/pages/performance.md @@ -0,0 +1,80 @@ +# Verwachte performance op lange termijn + +## Status van deze verwachting + +De onderstaande schaalinschatting is een architectuuranalyse, geen benchmark. Werkelijke grenzen hangen af van PostgreSQL, netwerkvertraging, documentgrootte, aantallen versies, browser en gelijktijdige gebruikers. Optimaliseer pas na meting met representatieve data. + +## Verwacht schaalbeeld + +| Omvang | Verwachting met huidige architectuur | +| --- | --- | +| Honderden pagina's, tientallen CMaps | Normale lees-, schrijf- en zoekacties horen ruim voldoende te zijn op een gewone lokale server. | +| Enkele duizenden pagina's, honderden CMaps | Paginaweergave en geïndexeerd paginazoeken blijven waarschijnlijk goed; volledige graph-opbouw, CMap-zoeken, catalogusgrootte en verbindingsopbouw worden zichtbaar. | +| Tienduizenden pagina's of veel grote CMaps | Paginering, server-side graphindex, connectionpooling, geïndexeerd CMap-zoeken en versie-/bijlagebeleid worden waarschijnlijk noodzakelijk. | + +Het ontwerp is dus passend voor een persoonlijke of teamwiki. Het is niet zonder aanvullende maatregelen ontworpen als internetbrede kennisbank met zeer veel gelijktijdige gebruikers. + +## Sterke performance-eigenschappen + +De actuele pagina staat direct in `pages`; voor normaal lezen hoeft geen versiegeschiedenis te worden opgebouwd. Titel en Markdown leveren een opgeslagen gewogen zoekvector met GIN-index. PostgreSQL verwerkt transacties en locking dicht bij de data. De browser ontvangt bij de paginacatalogus alleen metadata en haalt Markdown pas op bij gebruik. + +Vendor-assets worden lokaal geserveerd en veranderen niet tijdens normaal gebruik. De browser cachet de opgebouwde navigatiegraaf totdat pagina- of CMapcatalogus opnieuw wordt geladen. + +## Belangrijkste toekomstige knelpunten + +### Volledige navigatiegraaf + +`loadGraphData()` haalt momenteel iedere pagina en iedere CMap sequentieel via een eigen API-request op en extraheert daarna links in de browser. Voor `P` pagina's en `C` CMaps zijn dat `P + C` inhoudsrequests naast de catalogi. Latency groeit daardoor lineair en netwerkvertraging telt herhaaldelijk op. + +De duurzame oplossing is een server-side relationele linkindex die in dezelfde schrijftransactie als pagina of CMap wordt bijgewerkt. Een graph-endpoint kan dan alle nodes en edges in één compacte response leveren. + +### CMap-zoeken + +Paginazoeken gebruikt een opgeslagen index. CMap-zoeken bouwt per query tekst uit JSONB-items en maakt daar op dat moment een `tsvector` van. Bij veel of grote kaarten wordt dit een volledige scan. Voeg dan een opgeslagen zoektekst/`tsvector` en GIN-index aan `concept_maps` toe, bijgewerkt tijdens CMap-opslag. + +### Databaseverbindingen + +`call-with-wiki-database` opent en sluit voor iedere dienstaanroep een PostgreSQL-verbinding. Dit houdt lifecycle en foutisolatie eenvoudig, maar connection setup wordt bij hogere requestfrequentie overhead. Een begrensde connectionpool is dan logischer dan één globale verbinding. Meet eerst connecttijd, querytijd en gelijktijdigheid afzonderlijk. + +### Catalogi + +`list-pages` en `list-concept-maps` leveren alle huidige metadata. Dat is prettig voor client-side navigatie, WikiWords en comboboxen. Bij tienduizenden objecten worden responsegrootte, browsergeheugen en sorteren merkbaar. Mogelijke vervolgstappen zijn server-side paginering, een compacte referentiecatalogus en lazy loading per namespace. + +### Volledige versies + +Iedere pagina- en CMapsave bewaart een volledige snapshot. Opslag groeit lineair; CMap-autosave kan veel bijna gelijke JSONB-documenten opleveren. Meet per object het aantal versies, bytes en savefrequentie. Een later beleid kan oude autosaves uitdunnen terwijl expliciete snapshots en betekenisvolle saves blijven bestaan. Zo'n beleid vereist eerst een expliciete bewaargarantie en back-up. + +### Bijlagen + +Bijlagebytes staan in PostgreSQL en worden volledig in geheugen gelezen voor de response. Stel een maximale uploadgrootte in voordat grote media worden ondersteund. Voor echt grote objecten zijn streaming of een object store mogelijk, maar dat verandert back-up, autorisatie en referentiebeheer en is dus geen kleine optimalisatie. + +### Browserrendering + +Grote CMaps tekenen veel DOM/SVG-objecten en connectors. Volledige maps en graphs hebben uiteindelijk layoutkosten die niet met een snellere database verdwijnen. Meet nodeaantal, rendertijd en interactieframes. Sub-CMaps, viewportculling of vereenvoudigde read-only rendering zijn dan gerichte opties. + +## Meetplan + +Voeg vóór optimalisatie minimaal deze metingen toe: + +- requestduur per endpoint en responsebytes; +- connecttijd versus SQL-tijd; +- aantallen pagina's, CMaps, graph-edges en versies; +- gemiddelde en p95 Markdown-, JSONB- en bijlagegrootte; +- tijd voor volledige graph-opbouw; +- tijd voor CMap-zoekquery's; +- browserrendertijd bij representatieve CMaps. + +Gebruik datasets met echte linkdichtheid en documentgroottes. Duizend lege pagina's voorspellen het gedrag van duizend technische documenten niet. + +## Optimalisatievolgorde + +De verwachte volgorde bij groei is: + +1. graph-edges tijdens opslag indexeren en in één request leveren; +2. CMap-zoekvector opslaan en indexeren; +3. databaseconnectionpool toevoegen; +4. catalogi pagineren of per namespace laden; +5. expliciet versie- en uploadbeleid ontwerpen; +6. pas daarna horizontale of servicegerichte architectuur overwegen. + +Deze volgorde behoudt de eenvoud van [Structuur en samenhang](racket-wiki:structuur-en-samenhang) zolang die nog waardevol is. diff --git a/architecture/pages/structuur-en-samenhang.md b/architecture/pages/structuur-en-samenhang.md new file mode 100644 index 0000000..3a3103b --- /dev/null +++ b/architecture/pages/structuur-en-samenhang.md @@ -0,0 +1,67 @@ +# Structuur en samenhang + +## Context + +racket-wiki bestaat tijdens normaal gebruik uit drie uitvoerende delen: + +| Deel | Verantwoordelijkheid | +| --- | --- | +| Browser | Navigatie, lokale UI-toestand, EasyMDE, Markdownweergave, CMap-bewerking en grafische relaties | +| Racket-proces | HTTP-routing, sessies, rollen, CSRF, validatie, opslagorkestratie, setup en statische bestanden | +| PostgreSQL | Duurzame toestand, relationele integriteit, transacties, historie, zoekindexen en binaire bijlagen | + +Er is geen afzonderlijke Node-server, Markdownservice of object store. Na setup worden frontendbibliotheken lokaal door hetzelfde Racket-proces geserveerd. + +## Bronstructuur + +`main.rkt` is het programma- en bibliotheekingangspunt. Het bouwt een `wiki-config`, initialiseert zo nodig de database en start `server.rkt`. + +`server.rkt` vormt de HTTP-adapter. Het koppelt URL's en methoden aan handlers, vertaalt requests naar domeinbewerkingen en vertaalt resultaten of fouten naar HTML/JSON-responses. Setup, login en wachtwoordherstel zijn server-rendered; de normale wiki is een single-page browserapplicatie. + +De map `private/` bevat de backendonderdelen: + +| Module | Hoofdtaak | +| --- | --- | +| `config.rkt` | Runtimeconfiguratie en paden | +| `database.rkt` | PostgreSQL-instellingen, verbinding en initialisatie | +| `migrations.rkt` | Opeenvolgende, transactionele schemasprongen | +| `auth.rkt` | Gebruikers, wachtwoorden, rollen, sessies, CSRF en herstelcodes | +| `storage.rkt` | Pagina's, historie, zoeken, bookmarks, uploads en aliases | +| `cmap-storage.rkt` | Conceptmaps en onveranderlijke CMap-versies | +| `attachment-references.rkt` | Huidige en historische verwijzingen naar bijlagen | +| `todo.rkt` | Herkennen van wiki-brede `todo(...)`-markeringen | +| `mail.rkt` | SMTP-instellingen, STARTTLS, testmail en herstelmail | +| `setup.rkt` | Eerste websetup en reparatiepad | +| `vendor.rkt` | Ophalen en controleren van vastgepinde browserbibliotheken | +| `http-util.rkt` | Gemeenschappelijke response- en requesthulpen | +| `version.rkt` | Softwareversie uit `info.rkt` | + +`translate.rkt` staat bewust aan de publieke rand: zowel backend als frontend gebruiken dezelfde effectieve vertaaltabel. + +De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routing, API-aanroepen, editor- en paginatoestand en speciale views. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak. + +## Afhankelijkheidsrichting + +De bedoelde richting is: + +1. `main.rkt` kent configuratie, database-initialisatie en server. +2. `server.rkt` kent backenddiensten, maar backendopslag kent geen HTTP-requests. +3. Opslagmodules kennen `database.rkt` en dataconversies, maar geen browserdetails. +4. De browser kent alleen HTTP-contracten en de CMap-component; hij kent geen SQL. +5. PostgreSQL kent alleen schema en constraints; het kent geen HTML of routes. + +Deze richting houdt de belangrijkste domeinregels buiten de UI. Een rolcontrole die uitsluitend een knop verbergt, is bijvoorbeeld onvoldoende: `server.rkt` moet dezelfde bewerking weigeren. + +## Samenhang via hoofdgegevens + +Een pagina heeft een database-id, namespace, slug, actuele Markdown en een versieteller. `page_versions` verwijst naar dezelfde pagina-id. Todo's, bookmarks, aliases en bijlageverwijzingen sluiten via die id aan. + +Een CMap heeft een stabiele slug, titel, JSONB-document en versieteller. `concept_map_versions` bewaart volledige JSONB-snapshots. CMap-items kunnen via `pageSlug` naar een wikipagina verwijzen en via `cmapSlug` naar een andere CMap. De browser gebruikt deze verwijzingen voor navigatie en de gecombineerde sitegraph. + +Zie [Data, transacties en versiebeheer](racket-wiki:data-en-versies) voor de invarianten en [Modulariteit en afhankelijkheden](racket-wiki:modulariteit) voor de gewenste grenzen bij uitbreiding. + +## Deploymentstructuur + +De installatie bevat code en statische basisbestanden. De configureerbare datamap bevat `database.rktd`, de gekozen taal en lokaal gedownloade vendor-assets. Inhoud en bijlagen staan in PostgreSQL. Daardoor moet een volledige back-up zowel de database als de kleine datamap met configuratie bevatten. + +De Racket-server kan rechtstreeks luisteren, maar in productie ligt HTTPS gewoonlijk bij een reverse proxy. `secure-cookie?` moet dan aan staan en de publieke URL voor herstelmail moet naar de externe HTTPS-URL wijzen. diff --git a/architecture/pages/testbaarheid.md b/architecture/pages/testbaarheid.md new file mode 100644 index 0000000..d84bca6 --- /dev/null +++ b/architecture/pages/testbaarheid.md @@ -0,0 +1,66 @@ +# Testbaarheid en teststrategie + +## Huidige toestand + +De backendfuncties zijn meestal procedureel en krijgen `config` expliciet mee. Dat is een goede basis voor tests. PostgreSQL-invarianten zitten echter in concrete opslagprocedures en er is nog geen projectbrede geautomatiseerde testsuite met tijdelijke database. + +Versie 0.2.93 introduceert wel tests in `architecture/import.rkt`. Die controleren de manifestset, interne paginalinks, CMap-embeds, unieke node-id's, connector-endpoints en de bronmarkeringen waarmee lokaal gewijzigde importinhoud wordt beschermd. Dit is een begin, geen voldoende dekking van de wiki. + +## Testpiramide voor racket-wiki + +| Laag | Doel | Voorbeelden | +| --- | --- | --- | +| Pure unit tests | Deterministische omzettingen snel controleren | slugvorming, linkextractie, todoherkenning, markerhashes, TLS-keuze | +| Componenttests | Eén module met echte randvoorwaarden | CMap-document laden/bewaren, Markdowntransformaties, requestparsing | +| PostgreSQL-integratietests | Transacties en constraints bewijzen | create/update/conflict, historie, aliases, bijlagen, migraties | +| HTTP-integratietests | Rollen en API-contracten bewijzen | 401/403/409, CSRF, JSON-vormen, upload/download | +| Browser-smoketests | Kritische gebruikerspaden bewijzen | login, split editor, CMap drag/undo/autosave/snapshot, beheer | + +De meeste varianten horen onderin. Een browsertest is te duur om alle slug- of linkgevallen af te dekken; een pure test kan niet bewijzen dat een SQL-transactie werkelijk atomair is. + +## Prioriteit voor regressietests + +De eerste volwaardige suite moet deze risico's afdekken: + +1. twee editors op dezelfde pagina of CMap, waarbij de tweede een 409 krijgt; +2. actuele toestand en onveranderlijke versie worden samen geschreven of samen teruggedraaid; +3. pagina hernoemen behoudt id en maakt een werkende alias; +4. todo- en bijlageindices volgen exact de actuele Markdown; +5. reader, editor en admin kunnen uitsluitend hun toegestane endpoints gebruiken; +6. sessie-, CSRF- en herstelcodes worden correct gevalideerd en ingetrokken; +7. CMap-documenten maken een stabiele load/save-roundtrip; +8. slepen op een sub-CMapframe verplaatst alle afstammelingen, slepen op het hoofdconcept niet; +9. autosave, handmatige save en snapshot krijgen het juiste historische `action` en `summary`; +10. migratie vanaf iedere ondersteunde schemaversie levert dezelfde actuele structuur. + +## Tijdelijke PostgreSQL-database + +Integratietests gebruiken een afzonderlijke database of tijdelijk schema en nooit de ontwikkel- of productiedatabase. Iedere test begint vanuit een bekende migratiestand en ruimt eigen data op. De testconfiguratie komt uit expliciete testomgevingsvariabelen; wachtwoorden worden niet in de repository opgenomen. + +Een praktische eerste stap is één testdatabase per testrun, met unieke namespace of schema per proces. Tests die locking en conflicts controleren gebruiken twee echte verbindingen. + +## Testbare grenzen verbeteren + +Maak pure omzettingen los van I/O wanneer zij voldoende domeinbetekenis hebben. Voorbeeld: validatie en normalisatie van een paginabewerking kan als pure procedure worden getest; de opslagprocedure gebruikt het resultaat binnen de transactie. + +Introduceer geen groot mockframework. Gewone procedures en expliciete parameters zijn voldoende. Wanneer tijd of willekeur een test blokkeert, geef een kleine `current-clock`- of token-generatorparameter door op de laag die deze bron bezit. + +Voor frontendcode zijn browsercomponenttests met een echte DOM geschikter dan het namaken van ieder element. De CMap-adapter moet met een klein vast document getest kunnen worden zonder de hele wiki te starten. + +## Contract- en structuurcontroles + +Naast functionele tests hoort de releasecontrole automatisch te verifiëren: + +- alle Racket-modules compileren met `raco make`; +- `raco test` vindt en draait de tests; +- JavaScript kan syntactisch worden geparseerd; +- iedere `$("id")`-referentie heeft een overeenkomstig HTML-element; +- vertaalkeys die de UI gebruikt bestaan; +- alle bestanden in het architectuurmanifest bestaan en verwijzen onderling geldig; +- het ZIP-archief bevat geen `wiki-data`, wachtwoorden, tijdelijke bestanden of buildoutput. + +## Wanneer is een wijziging klaar? + +Een bugfix is pas klaar wanneer het oorspronkelijke scenario reproduceerbaar is en de test vóór de fix faalt of aantoonbaar het defecte pad raakt. Een nieuw endpoint heeft minimaal opslag-/domeintests en rol-/CSRF-tests. Een schemawijziging heeft zowel verse-installatie- als upgrademigratietests. + +Zie [Analyseerbaarheid](racket-wiki:analyseerbaarheid) voor het verzamelen van een minimale reproductie en [Regels bij het coderen](racket-wiki:code-regels) voor testvriendelijke codevormen. diff --git a/info.rkt b/info.rkt index 7bb8b41..1460a34 100644 --- a/info.rkt +++ b/info.rkt @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.2.84") +(define version "0.2.94") (define license 'MIT) (define collection "racket-wiki") (define pkg-desc @@ -16,6 +16,7 @@ "db-lib" "net-lib" "net-cookies-lib" + "openssl-lib" "web-server-lib")) (define build-deps diff --git a/private/auth.rkt b/private/auth.rkt index 12d91eb..44eb22b 100644 --- a/private/auth.rkt +++ b/private/auth.rkt @@ -26,9 +26,13 @@ create-user! upsert-user! update-user! + update-own-profile! + request-password-reset! + cancel-password-reset! + reset-password! delete-user!) -(struct wiki-user (id username display-name role enabled?) #:transparent) +(struct wiki-user (id username display-name email role enabled?) #:transparent) (struct wiki-session (user csrf-token expires-at token) #:transparent) (crypto-factories (list libcrypto-factory)) @@ -76,8 +80,16 @@ (wiki-user (vector-ref row 0) (vector-ref row 1) (vector-ref row 2) - (string->symbol (vector-ref row 3)) - (vector-ref row 4))) + (sql-null->false (vector-ref row 3)) + (string->symbol (vector-ref row 4)) + (vector-ref row 5))) + +(define (sql-null->false value) + (if (sql-null? value) #f value)) + +(define (normalized-email email) + (define value (string-downcase (string-trim (or email "")))) + (if (string=? value "") sql-null value)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Authenticate an enabled wiki user. @@ -92,17 +104,18 @@ (define row (query-maybe-row db - "SELECT id, username, display_name, role, enabled, password_hash FROM users WHERE username = $1" + "SELECT id, username, display_name, email, role, enabled, password_hash FROM users WHERE username = $1" username)) (cond ((not row) #f) - ((not (vector-ref row 4)) #f) - ((not (password-valid? password (vector-ref row 5))) #f) + ((not (vector-ref row 5)) #f) + ((not (password-valid? password (vector-ref row 6))) #f) (else (wiki-user (vector-ref row 0) (vector-ref row 1) (vector-ref row 2) - (string->symbol (vector-ref row 3)) + (sql-null->false (vector-ref row 3)) + (string->symbol (vector-ref row 4)) #t)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -168,7 +181,7 @@ (query-maybe-row db #<symbol (vector-ref row 3)) - (vector-ref row 4)) - (vector-ref row 5) + (sql-null->false (vector-ref row 3)) + (string->symbol (vector-ref row 4)) + (vector-ref row 5)) (vector-ref row 6) + (vector-ref row 7) token) #f))) #f)) @@ -211,7 +225,7 @@ SQL config (λ (db) (for/list ([row (in-list (query-rows db - "SELECT id, username, display_name, role, enabled FROM users ORDER BY username"))]) + "SELECT id, username, display_name, email, role, enabled FROM users ORDER BY username"))]) (row->user row))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -234,7 +248,7 @@ SQL ; post : The new user and password hash have been stored. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (create-user! config username display-name password role status) +(define (create-user! config username display-name password role status [email #f]) (define now (current-seconds)) (define enabled (eq? status 'enabled)) (define hash (password-hash password)) @@ -243,9 +257,10 @@ SQL (λ (db) (query-exec db - "INSERT INTO users(username, display_name, password_hash, role, enabled, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)" + "INSERT INTO users(username, display_name, email, password_hash, role, enabled, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)" username display-name + (normalized-email email) hash (symbol->string role) enabled @@ -285,7 +300,7 @@ SQL ; post : Display name, role and status are updated; a non-empty password replaces the password hash. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (update-user! config id display-name role status [password #f]) +(define (update-user! config id display-name role status [password #f] [email #f]) (define enabled (eq? status 'enabled)) (define now (current-seconds)) (call-with-wiki-database @@ -293,21 +308,123 @@ SQL (λ (db) (if (and password (not (string=? password ""))) (query-exec db - "UPDATE users SET display_name = $1, role = $2, enabled = $3, password_hash = $4, updated_at = $5 WHERE id = $6" + "UPDATE users SET display_name = $1, email = $2, role = $3, enabled = $4, password_hash = $5, updated_at = $6 WHERE id = $7" display-name + (normalized-email email) (symbol->string role) enabled (password-hash password) now id) (query-exec db - "UPDATE users SET display_name = $1, role = $2, enabled = $3, updated_at = $4 WHERE id = $5" + "UPDATE users SET display_name = $1, email = $2, role = $3, enabled = $4, updated_at = $5 WHERE id = $6" display-name + (normalized-email email) (symbol->string role) enabled now id))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Update the authenticated user's profile and optionally password. +; pre : user-id and session-token identify the active account/session. +; post : Name/email are updated; password changes retain only the active session. +; result : void; an invalid current password raises an exception. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (update-own-profile! config user-id session-token display-name email current-password new-password) + (define change-password? + (and new-password (not (string=? new-password "")))) + (call-with-wiki-database + config + (λ (db) + (call-with-transaction + db + (λ () + (when change-password? + (define stored-hash + (query-maybe-value db "SELECT password_hash FROM users WHERE id = $1" user-id)) + (unless (and stored-hash current-password (password-valid? current-password stored-hash)) + (error 'update-own-profile! "The current password is incorrect"))) + (if change-password? + (query-exec db + "UPDATE users SET display_name = $1, email = $2, password_hash = $3, updated_at = $4 WHERE id = $5" + display-name (normalized-email email) (password-hash new-password) (current-seconds) user-id) + (query-exec db + "UPDATE users SET display_name = $1, email = $2, updated_at = $3 WHERE id = $4" + display-name (normalized-email email) (current-seconds) user-id)) + (when change-password? + (query-exec db + "DELETE FROM sessions WHERE user_id = $1 AND token_hash <> $2" + user-id + (token-hash session-token)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Create a short-lived one-time password-reset token for an account. +; pre : identity is a username or email string. +; post : Any prior unused tokens for the matching enabled user are invalidated. +; result : A pair containing raw token and email, or #f when no account matches. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (request-password-reset! config identity [lifetime 3600] [maximum-per-hour 2]) + (define token (random-token)) + (define now (current-seconds)) + (call-with-wiki-database + config + (λ (db) + (call-with-transaction + db + (λ () + (query-exec db "DELETE FROM password_reset_tokens WHERE created_at <= $1" (- now 3600)) + (define row + (query-maybe-row db + "SELECT id, email FROM users WHERE enabled = TRUE AND (lower(username) = lower($1) OR lower(email) = lower($1)) FOR UPDATE" + (string-trim identity))) + (if (and row (not (sql-null? (vector-ref row 1)))) + (let ((recent-count + (query-value db + "SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = $1 AND created_at > $2" + (vector-ref row 0) + (- now 3600)))) + (if (>= recent-count maximum-per-hour) + #f + (begin + (query-exec db + "INSERT INTO password_reset_tokens(token_hash, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)" + (token-hash token) (vector-ref row 0) now (+ now lifetime)) + (cons token (vector-ref row 1))))) + #f)))))) + +(define (cancel-password-reset! config token) + (call-with-wiki-database + config + (λ (db) + (query-exec db "DELETE FROM password_reset_tokens WHERE token_hash = $1" (token-hash token))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Consume a password-reset token and replace the account password. +; pre : token/password are strings and the password meets the caller's policy. +; post : A valid token is used once, all sessions are revoked and password updated. +; result : #t on success, #f for an invalid, expired or already-used token. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (reset-password! config token password) + (call-with-wiki-database + config + (λ (db) + (call-with-transaction + db + (λ () + (define now (current-seconds)) + (define user-id + (query-maybe-value db + "SELECT user_id FROM password_reset_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 FOR UPDATE" + (token-hash token) now)) + (if user-id + (begin + (query-exec db "UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3" (password-hash password) now user-id) + (query-exec db "UPDATE password_reset_tokens SET used_at = $1 WHERE user_id = $2 AND used_at IS NULL" now user-id) + (query-exec db "DELETE FROM sessions WHERE user_id = $1" user-id) + #t) + #f)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Delete a wiki user. ; pre : id identifies a possible user. diff --git a/private/cmap-storage.rkt b/private/cmap-storage.rkt index 417daf1..9892457 100644 --- a/private/cmap-storage.rkt +++ b/private/cmap-storage.rkt @@ -14,6 +14,8 @@ list-recent-concept-maps search-concept-maps read-concept-map + concept-map-history + read-concept-map-version create-concept-map! rename-concept-map! update-concept-map! @@ -70,6 +72,15 @@ (document->text document) (void)) +(define (insert-concept-map-version! db map-id version title document-text author action summary now) + (query-exec db + #<number (format "~a" base-version)))) (unless (and supplied-version (= supplied-version current-version)) (error 'rename-concept-map! "version-conflict")) + (define next-version (+ current-version 1)) + (define now (current-seconds)) (query-exec db #<text document)) @@ -295,6 +319,8 @@ SQL (string->number (format "~a" base-version)))) (unless (and supplied-version (= supplied-version current-version)) (error 'update-concept-map! "version-conflict")) + (define next-version (+ current-version 1)) + (define now (current-seconds)) (query-exec db #<number version))) + (and version-number + (call-with-wiki-database + config + (λ (db) + (define row + (query-maybe-row db + #<document (vector-ref row 2)) + 'author (vector-ref row 3) + 'action (vector-ref row 4) + 'summary (vector-ref row 5) + 'createdAt (vector-ref row 6))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Soft-delete one concept map. ; pre : slug identifies a current map. diff --git a/private/mail.rkt b/private/mail.rkt new file mode 100644 index 0000000..049714c --- /dev/null +++ b/private/mail.rkt @@ -0,0 +1,224 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Password-reset email delivery configured through the database or environment. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require db + net/smtp + openssl + racket/string + "config.rkt" + "database.rkt") + +(provide password-reset-mail-configured? + password-reset-mail-settings + save-password-reset-mail-settings! + send-test-mail! + send-password-reset-mail!) + +(define (environment-value name) + (define value (getenv name)) + (and value + (not (string=? (string-trim value) "")) + (string-trim value))) + +(define (safe-header-value value) + (regexp-replace* #px"[\r\n]+" value " ")) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Adapt modern TLS negotiation to net/smtp's STARTTLS callback. +; pre : host is the SMTP server name and accept-untrusted-certificates? is a boolean. +; post : A client context has been created but no connection is open. +; result : An encoder accepted by smtp-send-message. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (make-starttls-encoder host accept-untrusted-certificates?) + (define context + (if accept-untrusted-certificates? + (ssl-make-client-context 'auto) + (ssl-secure-client-context))) + (λ (input-port output-port + #:mode mode + #:encrypt _protocol + #:close-original? close-original?) + (if accept-untrusted-certificates? + (ports->ssl-ports input-port + output-port + #:mode mode + #:context context + #:close-original? close-original?) + (ports->ssl-ports input-port + output-port + #:mode mode + #:context context + #:hostname host + #:close-original? close-original?)))) + +(define setting-environment-names + (hash "public-url" "RACKET_WIKI_PUBLIC_URL" + "smtp-host" "RACKET_WIKI_SMTP_HOST" + "smtp-port" "RACKET_WIKI_SMTP_PORT" + "smtp-from" "RACKET_WIKI_SMTP_FROM" + "smtp-user" "RACKET_WIKI_SMTP_USER" + "smtp-password" "RACKET_WIKI_SMTP_PASSWORD" + "smtp-tls" "RACKET_WIKI_SMTP_TLS" + "smtp-accept-untrusted-certificates" "RACKET_WIKI_SMTP_ACCEPT_UNTRUSTED_CERTIFICATES" + "reset-limit" "RACKET_WIKI_RESET_LIMIT")) + +(define (database-mail-settings config) + (call-with-wiki-database + config + (λ (db) + (for/hash ((row (in-list (query-rows db "SELECT key, value FROM wiki_settings WHERE key LIKE 'mail.%'")))) + (values (substring (vector-ref row 0) 5) (vector-ref row 1)))))) + +(define (password-reset-mail-settings config) + (define stored (database-mail-settings config)) + (for/hash (((key environment-name) (in-hash setting-environment-names))) + (define default + (cond + ((string=? key "smtp-port") "587") + ((string=? key "reset-limit") "2") + ((string=? key "smtp-tls") "true") + ((string=? key "smtp-accept-untrusted-certificates") "false") + (else ""))) + (values key (or (hash-ref stored key #f) + (environment-value environment-name) + default)))) + +(define (save-password-reset-mail-settings! config settings) + (call-with-wiki-database + config + (λ (db) + (call-with-transaction + db + (λ () + (for (((key _environment-name) (in-hash setting-environment-names))) + (define supplied-value (hash-ref settings key "")) + (define value + (if (string=? key "smtp-password") + supplied-value + (string-trim supplied-value))) + (unless (and (string=? key "smtp-password") (string=? value "")) + (if (string=? value "") + (query-exec db "DELETE FROM wiki_settings WHERE key = $1" (string-append "mail." key)) + (query-exec db + "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + (string-append "mail." key) value (current-seconds)))))))))) + +(define (password-reset-mail-configured? config) + (define settings (password-reset-mail-settings config)) + (and (not (string=? (hash-ref settings "public-url") "")) + (not (string=? (hash-ref settings "smtp-host") "")) + (not (string=? (hash-ref settings "smtp-from") "")))) + +(define (settings-with-stored-password config supplied-settings) + (define stored-settings (password-reset-mail-settings config)) + (define supplied-password (hash-ref supplied-settings "smtp-password" "")) + (define effective-password + (if (string=? supplied-password "") + (hash-ref stored-settings "smtp-password" "") + supplied-password)) + (for/hash (((key _environment-name) (in-hash setting-environment-names))) + (define value + (if (string=? key "smtp-password") + effective-password + (hash-ref supplied-settings key (hash-ref stored-settings key "")))) + (values key value))) + +(define (send-mail-with-settings! settings recipient subject body-lines) + (define host (string-trim (hash-ref settings "smtp-host" ""))) + (define from (safe-header-value (string-trim (hash-ref settings "smtp-from" "")))) + (when (string=? host "") + (error 'send-mail-with-settings! "SMTP server is required")) + (when (string=? from "") + (error 'send-mail-with-settings! "Sender address is required")) + (define configured-port (string->number (hash-ref settings "smtp-port" "587"))) + (define port + (if (and (exact-integer? configured-port) (<= 1 configured-port 65535)) + configured-port + 587)) + (define configured-user (hash-ref settings "smtp-user" "")) + (define user + (if (string=? configured-user "") #f configured-user)) + (define configured-password (hash-ref settings "smtp-password" "")) + (define password + (if (string=? configured-password "") #f configured-password)) + (define starttls? + (string-ci=? (hash-ref settings "smtp-tls" "true") "true")) + (define accept-untrusted-certificates? + (string-ci=? (hash-ref settings "smtp-accept-untrusted-certificates" "false") "true")) + (define header + (string-append "From: " from "\r\n" + "To: " (safe-header-value recipient) "\r\n" + "Subject: " (safe-header-value subject) "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "\r\n")) + (define message + (for/list ((line (in-list body-lines))) + (string->bytes/utf-8 line))) + (with-handlers ((exn:fail? + (λ (exception) + (define message (exn-message exception)) + (if (regexp-match? #px"certificate verify failed" message) + (error 'send-mail-with-settings! + "TLS certificate verification failed; install a valid certificate or explicitly accept untrusted certificates for this trusted local SMTP server") + (raise exception))))) + (smtp-send-message host + from + (list recipient) + header + message + #:port-no port + #:auth-user user + #:auth-passwd password + #:tls-encode (if starttls? + (make-starttls-encoder host accept-untrusted-certificates?) + #f)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Test supplied SMTP settings without storing them. +; pre : supplied-settings contains the values from the admin form. +; post : One test message has been submitted; settings are unchanged. +; result : void. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (send-test-mail! config supplied-settings recipient) + (define settings + (settings-with-stored-password config supplied-settings)) + (send-mail-with-settings! + settings + recipient + (string-append (wiki-config-site-title config) " SMTP test") + (list (string-append "This is a test message from " + (wiki-config-site-title config) + ".") + "" + "The SMTP server accepted the message using the settings from the administration form."))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Send a one-time password-reset link through configured SMTP. +; pre : Required mail settings are configured in the database or environment. +; post : One email has been submitted to the SMTP server. +; result : void. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (send-password-reset-mail! config recipient token) + (unless (password-reset-mail-configured? config) + (error 'send-password-reset-mail! "Password-reset email is not configured")) + (define settings (password-reset-mail-settings config)) + (define public-url + (string-trim (hash-ref settings "public-url") "/" #:right? #t)) + (define reset-url + (string-append public-url "/reset-password?token=" token)) + (send-mail-with-settings! + settings + recipient + "Password reset" + (list (string-append "A password reset was requested for your account at " + (wiki-config-site-title config) + ".") + "" + "Open this link within one hour:" + reset-url + "" + "If you did not request this, you can ignore this email."))) diff --git a/private/migrations.rkt b/private/migrations.rkt index 8611bcd..a9d063a 100644 --- a/private/migrations.rkt +++ b/private/migrations.rkt @@ -20,7 +20,7 @@ ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define current-schema-version 9) +(define current-schema-version 11) (define schema-1-statements (list @@ -323,6 +323,67 @@ SQL "CREATE INDEX IF NOT EXISTS concept_maps_title_idx ON concept_maps(lower(title))") (record-schema-version! db 9)) +(define (migrate-9->10! db) + (query-exec db "ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT") + (query-exec db + "CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_idx ON users(lower(email)) WHERE email IS NOT NULL") + (query-exec db + #<11! db) + (query-exec db + #<9! db)) + (define after-concept-maps (database-schema-version db)) + (when (= after-concept-maps 9) + (migrate-9->10! db)) + (define after-user-profiles (database-schema-version db)) + (when (= after-user-profiles 10) + (migrate-10->11! db)) (define resulting-version (database-schema-version db)) (when (> resulting-version current-schema-version) (error 'migrate-database! diff --git a/scrbl/racket-wiki.scrbl b/scrbl/racket-wiki.scrbl index 49cadb6..0d192f6 100644 --- a/scrbl/racket-wiki.scrbl +++ b/scrbl/racket-wiki.scrbl @@ -4,6 +4,7 @@ racket/contract racket/path racket-wiki + racket-wiki/architecture/import racket-wiki/translate)) @title{racket-wiki} @@ -146,3 +147,53 @@ labels use one translation source. The browser calls @tt{/api/ping} every 15 seconds. Failed or timed-out requests show an Offline indicator. A recovered connection briefly shows Online. + +@section{Architecture Documentation Import} + +@defmodule[racket-wiki/architecture/import] + +The package includes twelve linked Dutch architecture pages below namespace +@tt{racket-wiki} and two native CMaps. The source files live below +@tt{architecture/}; importing them uses the normal page and CMap storage +procedures and therefore creates ordinary immutable versions. + +@defproc[(validate-racket-wiki-architecture!) (values list? list?)] { +Reads and validates the bundled manifest, Markdown and JSON documents without +accessing wiki content. Page references, CMap embeds, item identifiers and +connector endpoints must all resolve inside the set. +} + +@defproc[(import-racket-wiki-architecture! + [config any/c] + [#:author author string? "racket-wiki architecture import"] + [#:dry-run? dry-run? boolean? #f] + [#:overwrite-modified? overwrite-modified? boolean? #f]) + (listof architecture-import-result?)] { +Creates missing architecture pages and CMaps and updates imported content that +has not been edited locally. Every imported document contains a source hash. +Locally modified documents are returned with status +@racket['skipped-modified] unless @racket[overwrite-modified?] is true. Equal +content remains unchanged and does not receive a needless new version. +} + +@defproc[(import-racket-wiki-architecture-from-data-directory! + [data-directory path-string?] + [#:author author string? "racket-wiki architecture import"] + [#:dry-run? dry-run? boolean? #f] + [#:overwrite-modified? overwrite-modified? boolean? #f]) + (listof architecture-import-result?)] { +Convenience entry point for DrRacket and small scripts. It constructs the wiki +configuration from @racket[data-directory] and performs the same validated, +locally-change-aware import. +} + +@defstruct*[architecture-import-result + ([kind symbol?] + [reference string?] + [status symbol?] + [message string?])] + +From the package source directory, run a non-writing preview with +@tt{racket architecture/import.rkt --data ./wiki-data --dry-run}. Remove +@tt{--dry-run} to import and use @tt{--author} to select the author recorded in +page and CMap history. diff --git a/server.rkt b/server.rkt index 7bf910a..dc72430 100644 --- a/server.rkt +++ b/server.rkt @@ -18,6 +18,7 @@ "private/cmap-storage.rkt" "private/config.rkt" "private/http-util.rkt" + "private/mail.rkt" "private/setup.rkt" "private/storage.rkt" "private/version.rkt" @@ -33,6 +34,7 @@ (hash 'id (wiki-user-id user) 'username (wiki-user-username user) 'displayName (wiki-user-display-name user) + 'email (or (wiki-user-email user) "") 'role (symbol->string (wiki-user-role user)) 'enabled (wiki-user-enabled? user))) @@ -97,6 +99,9 @@ label { display: block; margin: 16px 0; font-weight: 600; } input { display: block; width: 100%; margin-top: 6px; padding: 10px 12px; font: inherit; border: 1px solid #aeb7c4; border-radius: 6px; } button { margin-top: 2px; padding: 9px 14px; font: inherit; cursor: pointer; } .error { margin: 0 0 18px; padding: 12px 14px; background: #fff1f1; border: 1px solid #e8b7b7; border-radius: 6px; color: #8b1f1f; } +.message { margin: 0 0 18px; padding: 12px 14px; background: #eef7ee; border: 1px solid #bad7ba; border-radius: 6px; } +.login-links { margin: 18px 0 0; } +.login-links a { color: #315f91; } CSS ) @@ -139,7 +144,9 @@ CSS (type "password") (autocomplete "current-password") (required "required")))) - (button ((type "submit")) ,(tr config 'sign-in))))))) + (button ((type "submit")) ,(tr config 'sign-in))) + (p ((class "login-links")) + (a ((href "/forgot-password")) ,(tr config 'forgot-password))))))) (define (login-page-response config [message #f] [username ""]) (html-response @@ -168,6 +175,116 @@ CSS (else (login-page-response config)))) +(define (password-page config title body-elements) + `(html + (head + (meta ((charset "utf-8"))) + (meta ((name "viewport") (content "width=device-width, initial-scale=1"))) + (title ,(string-append title " - " (wiki-config-site-title config))) + (style ,login-style)) + (body + (main ((class "login")) + (h1 ,title) + ,@body-elements)))) + +(define (password-page-response config title body-elements [code 200]) + (html-response (password-page config title body-elements) + #:code code + #:headers (list (make-header #"Cache-Control" #"no-store")))) + +(define (forgot-password-handler config req) + (define post? (string-ci=? (bytes->string/latin-1 (request-method req)) "POST")) + (if post? + (let* ((form (request-form req)) + (identity (string-trim (form-value form 'identity))) + (settings (password-reset-mail-settings config)) + (mail-configured? (password-reset-mail-configured? config)) + (configured-limit (string->number (hash-ref settings "reset-limit"))) + (limit (if (and (exact-integer? configured-limit) (<= 1 configured-limit 20)) configured-limit 2)) + (reset (and mail-configured? + (not (string=? identity "")) + (request-password-reset! config identity 3600 limit)))) + (unless mail-configured? + (eprintf "Password-reset email was not sent: SMTP is not configured.\n")) + (when reset + (thread + (λ () + (with-handlers ((exn:fail? + (λ (e) + (cancel-password-reset! config (car reset)) + (eprintf "Password-reset email could not be sent: ~a\n" (exn-message e))))) + (send-password-reset-mail! config (cdr reset) (car reset)))))) + (password-page-response + config + (tr config 'forgot-password) + `((div ((class "message")) ,(tr config 'reset-request-result)) + (p ,(tr config 'reset-request-next-step)) + (p (a ((href "/login")) ,(tr config 'back-to-login)))))) + (password-page-response + config + (tr config 'forgot-password) + `((p ,(tr config 'reset-request-help)) + (form ((method "post") (action "/forgot-password")) + (label + ,(tr config 'username-or-email) + (input ((name "identity") (autocomplete "username") (required "required") (autofocus "autofocus")))) + (button ((type "submit")) ,(tr config 'send-reset-link))) + (p ((class "login-links")) + (a ((href "/login")) ,(tr config 'back-to-login))))))) + +(define (query-parameter req name) + (for/or ((entry (in-list (url-query (request-uri req))))) + (and (string=? (format "~a" (car entry)) name) + (cdr entry)))) + +(define (reset-password-handler config req) + (define post? (string-ci=? (bytes->string/latin-1 (request-method req)) "POST")) + (define form (if post? (request-form req) '())) + (define token (if post? (form-value form 'token) (or (query-parameter req "token") ""))) + (cond + ((string=? token "") + (password-page-response config (tr config 'reset-password) + `((div ((class "error")) ,(tr config 'invalid-reset-link)) + (p (a ((href "/forgot-password")) ,(tr config 'request-new-reset-link)))) + 400)) + (post? + (define password (form-value form 'password)) + (define repeated (form-value form 'repeat-password)) + (cond + ((or (< (string-length password) 8) (> (string-length password) 1024)) + (password-page-response config (tr config 'reset-password) + `((div ((class "error")) ,(tr config 'password-minimum)) + ,(reset-password-form config token)) + 400)) + ((not (string=? password repeated)) + (password-page-response config (tr config 'reset-password) + `((div ((class "error")) ,(tr config 'passwords-do-not-match)) + ,(reset-password-form config token)) + 400)) + ((reset-password! config token password) + (password-page-response config (tr config 'reset-password) + `((div ((class "message")) ,(tr config 'password-reset-complete)) + (p (a ((href "/login")) ,(tr config 'sign-in)))))) + (else + (password-page-response config (tr config 'reset-password) + `((div ((class "error")) ,(tr config 'invalid-reset-link)) + (p (a ((href "/forgot-password")) ,(tr config 'request-new-reset-link)))) + 400)))) + (else + (password-page-response config (tr config 'reset-password) + (list (reset-password-form config token)))))) + +(define (reset-password-form config token) + `(form ((method "post") (action "/reset-password")) + (input ((type "hidden") (name "token") (value ,token))) + (label + ,(tr config 'new-password) + (input ((type "password") (name "password") (autocomplete "new-password") (minlength "8") (maxlength "1024") (required "required") (autofocus "autofocus")))) + (label + ,(tr config 'repeat-password) + (input ((type "password") (name "repeat-password") (autocomplete "new-password") (minlength "8") (maxlength "1024") (required "required")))) + (button ((type "submit")) ,(tr config 'save-new-password)))) + (define (logout-handler config req) (require-write-role config req 'reader @@ -179,6 +296,39 @@ CSS (list (make-header #"Set-Cookie" (cookie:clear-cookie-header "racket-wiki-session" #:path "/"))))))) +(define (valid-email? email) + (or (string=? email "") + (regexp-match? #px"^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$" email))) + +(define (profile-update-handler config req) + (require-write-role + config req 'reader + (λ (session) + (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) + (define body (request-json req)) + (define display-name (string-trim (hash-ref body 'displayName ""))) + (define email (string-downcase (string-trim (hash-ref body 'email "")))) + (define current-password (hash-ref body 'currentPassword "")) + (define new-password (hash-ref body 'newPassword "")) + (when (string=? display-name "") + (error 'profile-update-handler "Display name is required")) + (when (> (string-length display-name) 200) + (error 'profile-update-handler "Display name is too long")) + (unless (valid-email? email) + (error 'profile-update-handler "Invalid email address")) + (when (> (string-length email) 320) + (error 'profile-update-handler "Email address is too long")) + (when (and (not (string=? new-password "")) + (or (< (string-length new-password) 8) (> (string-length new-password) 1024))) + (error 'profile-update-handler "The new password must contain between 8 and 1024 characters")) + (update-own-profile! config + (wiki-user-id (wiki-session-user session)) + (wiki-session-token session) + display-name email current-password new-password) + (json-response + (hash 'ok #t + 'session (session->jsexpr (session-from-request config req)))))))) + (define (page-list-handler config req) (require-role config req 'reader @@ -234,6 +384,22 @@ CSS (json-response concept-map) (json-error 404 "Concept map not found"))))) +(define (concept-map-history-handler config req slug) + (require-role + config req 'reader + (λ (_session) + (with-handlers ((exn:fail? (λ (e) (json-error 404 (exn-message e))))) + (json-response (hash 'versions (concept-map-history config slug))))))) + +(define (concept-map-version-handler config req slug version) + (require-role + config req 'reader + (λ (_session) + (define result (read-concept-map-version config slug version)) + (if result + (json-response result) + (json-error 404 "Concept map version not found"))))) + (define (concept-map-update-handler config req slug) (require-write-role config req 'editor @@ -247,13 +413,22 @@ CSS (define title (string-trim (hash-ref body 'title ""))) (define document (request-concept-map-document body)) (define base-version (hash-ref body 'baseVersion "")) + (define snapshot? (eq? (hash-ref body 'snapshot #f) #t)) + (define supplied-summary (hash-ref body 'summary "Edited CMap")) + (define summary + (if (and (string? supplied-summary) + (not (string=? (string-trim supplied-summary) ""))) + (substring supplied-summary 0 (min 500 (string-length supplied-summary))) + "Edited CMap")) (json-response (update-concept-map! config slug title document (wiki-user-username (wiki-session-user session)) - base-version)))))) + base-version + summary + (if snapshot? "snapshot" "edit"))))))) (define (concept-map-rename-handler config req slug) (require-write-role @@ -580,6 +755,82 @@ CSS (λ (_session) (json-response (hash 'softwareVersion racket-wiki-version))))) +(define (mail-settings->jsexpr settings) + (hash 'publicUrl (hash-ref settings "public-url") + 'smtpHost (hash-ref settings "smtp-host") + 'smtpPort (hash-ref settings "smtp-port") + 'smtpFrom (hash-ref settings "smtp-from") + 'smtpUser (hash-ref settings "smtp-user") + 'smtpTls (string-ci=? (hash-ref settings "smtp-tls") "true") + 'smtpAcceptUntrustedCertificates + (string-ci=? (hash-ref settings "smtp-accept-untrusted-certificates") "true") + 'resetLimit (hash-ref settings "reset-limit") + 'hasPassword (not (string=? (hash-ref settings "smtp-password") "")))) + +(define (mail-settings-from-request body) + (define port (string-trim (hash-ref body 'smtpPort "587"))) + (define reset-limit (string-trim (hash-ref body 'resetLimit "2"))) + (define public-url (string-trim (hash-ref body 'publicUrl ""))) + (define sender (string-trim (hash-ref body 'smtpFrom ""))) + (define smtp-host (string-trim (hash-ref body 'smtpHost ""))) + (define smtp-user (string-trim (hash-ref body 'smtpUser ""))) + (define smtp-password (hash-ref body 'smtpPassword "")) + (unless (and (exact-integer? (string->number port)) + (<= 1 (string->number port) 65535)) + (error 'mail-settings-from-request "Invalid SMTP port")) + (unless (and (exact-integer? (string->number reset-limit)) + (<= 1 (string->number reset-limit) 20)) + (error 'mail-settings-from-request "The reset limit must be between 1 and 20")) + (unless (or (string=? public-url "") + (regexp-match? #px"^https?://[^[:space:]]+$" public-url)) + (error 'mail-settings-from-request "Invalid public wiki URL")) + (unless (valid-email? sender) + (error 'mail-settings-from-request "Invalid sender address")) + (hash "public-url" public-url + "smtp-host" smtp-host + "smtp-port" port + "smtp-from" sender + "smtp-user" smtp-user + "smtp-password" smtp-password + "smtp-tls" (if (hash-ref body 'smtpTls #t) "true" "false") + "smtp-accept-untrusted-certificates" + (if (hash-ref body 'smtpAcceptUntrustedCertificates #f) "true" "false") + "reset-limit" reset-limit)) + +(define (admin-mail-settings-handler config req) + (if (string-ci=? (bytes->string/latin-1 (request-method req)) "GET") + (require-role + config req 'admin + (λ (_session) + (json-response (mail-settings->jsexpr (password-reset-mail-settings config))))) + (require-write-role + config req 'admin + (λ (_session) + (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) + (define body (request-json req)) + (define settings (mail-settings-from-request body)) + (save-password-reset-mail-settings! config settings) + (json-response (hash 'ok #t))))))) + +(define (admin-test-mail-handler config req) + (require-write-role + config req 'admin + (λ (_session) + (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) + (define body (request-json req)) + (define recipient (string-downcase (string-trim (hash-ref body 'recipient "")))) + (when (string=? recipient "") + (error 'admin-test-mail-handler "Test recipient is required")) + (unless (valid-email? recipient) + (error 'admin-test-mail-handler "Invalid test recipient")) + (define settings (mail-settings-from-request body)) + (when (string=? (hash-ref settings "smtp-host") "") + (error 'admin-test-mail-handler "SMTP server is required")) + (when (string=? (hash-ref settings "smtp-from") "") + (error 'admin-test-mail-handler "Sender address is required")) + (send-test-mail! config settings recipient) + (json-response (hash 'ok #t)))))) + (define (admin-page-aliases-handler config req) (require-role config req 'admin @@ -617,6 +868,7 @@ CSS (define body (request-json req)) (define username (hash-ref body 'username "")) (define display-name (hash-ref body 'displayName username)) + (define email (string-trim (hash-ref body 'email ""))) (define password (hash-ref body 'password "")) (define role (string->symbol (hash-ref body 'role "reader"))) (define status (if (hash-ref body 'enabled #t) 'enabled 'disabled)) @@ -624,7 +876,9 @@ CSS (error 'admin-create-user-handler "Invalid role")) (when (or (string=? username "") (string=? password "")) (error 'admin-create-user-handler "Username and password are required")) - (create-user! config username display-name password role status) + (unless (valid-email? email) + (error 'admin-create-user-handler "Invalid email address")) + (create-user! config username display-name password role status email) (json-response (hash 'ok #t) #:code 201))))) (define (admin-update-user-handler config req id) @@ -634,12 +888,15 @@ CSS (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) (define body (request-json req)) (define display-name (hash-ref body 'displayName "")) + (define email (string-trim (hash-ref body 'email ""))) (define role (string->symbol (hash-ref body 'role "reader"))) (define status (if (hash-ref body 'enabled #t) 'enabled 'disabled)) (define password (hash-ref body 'password #f)) (unless (member role '(reader editor admin)) (error 'admin-update-user-handler "Invalid role")) - (update-user! config id display-name role status password) + (unless (valid-email? email) + (error 'admin-update-user-handler "Invalid email address")) + (update-user! config id display-name role status password email) (json-response (hash 'ok #t)))))) (define (admin-delete-user-handler config req id) @@ -717,6 +974,8 @@ CSS (λ (req) (login-handler config req))] [("api" "logout") #:method "post" (λ (req) (logout-handler config req))] + [("api" "profile") #:method "put" + (λ (req) (profile-update-handler config req))] [("api" "ping") #:method "get" (λ (_req) (json-response (hash 'ok #t 'time (current-seconds))))] [("api" "translations") #:method "get" @@ -741,6 +1000,10 @@ CSS (λ (req) (concept-map-create-handler config req))] [("api" "cmaps" (string-arg)) #:method "get" (λ (req slug) (concept-map-get-handler config req slug))] + [("api" "cmaps" (string-arg) "history") #:method "get" + (λ (req slug) (concept-map-history-handler config req slug))] + [("api" "cmaps" (string-arg) "versions" (string-arg)) #:method "get" + (λ (req slug version) (concept-map-version-handler config req slug version))] [("api" "cmaps" (string-arg)) #:method "put" (λ (req slug) (concept-map-update-handler config req slug))] [("api" "cmaps" (string-arg) "rename") #:method "post" @@ -767,6 +1030,12 @@ CSS (λ (req slug stored-name) (upload-get-handler config req slug stored-name))] [("api" "admin" "info") #:method "get" (λ (req) (admin-info-handler config req))] + [("api" "admin" "mail-settings") #:method "get" + (λ (req) (admin-mail-settings-handler config req))] + [("api" "admin" "mail-settings") #:method "put" + (λ (req) (admin-mail-settings-handler config req))] + [("api" "admin" "mail-settings" "test") #:method "post" + (λ (req) (admin-test-mail-handler config req))] [("api" "admin" "aliases") #:method "get" (λ (req) (admin-page-aliases-handler config req))] [("api" "admin" "aliases" (integer-arg) "cleanup") #:method "post" @@ -801,6 +1070,10 @@ CSS (redirect-response "/setup")) ((regexp-match? #px"^/login/?$" path) (browser-login-handler config req)) + ((regexp-match? #px"^/forgot-password/?$" path) + (forgot-password-handler config req)) + ((regexp-match? #px"^/reset-password/?$" path) + (reset-password-handler config req)) ((static-request-path? path) (next-dispatcher)) ((application-api-path? path) diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index f17286b..226764c 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -7,7 +7,7 @@ (() => { "use strict"; - const debugPrefix = "[racket-wiki:cmap 0.2.84]"; + const debugPrefix = "[racket-wiki:cmap 0.2.94]"; function debug(message, details) { if (details === undefined) { @@ -1207,7 +1207,7 @@ const expanded = []; for (const item of selected) { expanded.push(item); - if (item.kind === "submap" && !item.expanded && item !== this.activeMapRoot) { + if (item.kind === "submap" && item !== this.activeMapRoot) { expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item))); } } @@ -2081,7 +2081,7 @@ let lastEditor = null; window.RacketWikiCmap = { - version: "0.2.84", + version: "0.2.94", createEditor(canvas, options) { lastEditor = new CmapEditor(canvas, options); return lastEditor; diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css index 87b7f7f..1654b30 100644 --- a/static/cmap/cmap.css +++ b/static/cmap/cmap.css @@ -257,6 +257,54 @@ body.cmap-mode #main { transform-origin: 0 0; } +.rw-cmap-embed { + display: block; + margin: 1.25rem 0; + overflow: hidden; + border: 1px solid #cbd4dc; + border-radius: 10px; + background: #fff; + cursor: zoom-in; +} + +.rw-cmap-embed > header { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: baseline; + padding: 9px 13px; + border-bottom: 1px solid #e1e6eb; + background: #f7f9fa; +} + +.rw-cmap-embed > header span { + color: #65717c; + font-size: 0.85rem; +} + +.rw-cmap-embed-loading, +.rw-cmap-embed > .error { + margin: 0; + padding: 18px; +} + +.rw-cmap-embed-viewport { + position: relative; + width: 100%; + min-height: 180px; + max-height: 520px; + overflow: hidden; + background: #fff; +} + +.rw-cmap-embed-canvas { + min-width: 100%; + min-height: 100%; + pointer-events: none; + background: #fff !important; + box-shadow: none !important; +} + .cmap-zoom-controls { display: flex; flex: 0 0 auto; @@ -628,6 +676,26 @@ body.cmap-mode #main { background: rgb(22 30 44 / 38%); } +.cmap-history-dialog { + width: min(720px, calc(100vw - 40px)); + max-height: calc(100vh - 60px); + padding: 0; + border: 1px solid #aeb7c4; + border-radius: 10px; + box-shadow: 0 16px 48px rgba(22, 32, 51, 0.22); +} + +.cmap-history-dialog::backdrop { + background: rgba(25, 32, 45, 0.38); +} + +.cmap-history-panel { padding: 22px; } +.cmap-history-panel > header { display: flex; justify-content: space-between; gap: 16px; align-items: center; } +.cmap-history-panel h2 { margin: 0; } +#cmap-history-list { max-height: min(520px, 60vh); overflow: auto; } +.cmap-history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 0; border-bottom: 1px solid #dfe3e8; } +.cmap-history-row:last-child { border-bottom: 0; } + .cmap-unsaved-dialog form { display: grid; gap: 14px; diff --git a/static/cmap/cmap.js b/static/cmap/cmap.js index 2a529e4..ed6af17 100644 --- a/static/cmap/cmap.js +++ b/static/cmap/cmap.js @@ -1,5 +1,5 @@ /** - * racket-wiki cmap component 0.2.84 + * racket-wiki cmap component 0.2.94 * Based on cmap v0.1.3. * (c) 2015 iOnStage * Released under the MIT License. diff --git a/static/css/wiki.css b/static/css/wiki.css index 32987e3..1e3c420 100644 --- a/static/css/wiki.css +++ b/static/css/wiki.css @@ -168,9 +168,18 @@ button { cursor: pointer; } border-bottom: 1px solid #dfe3e8; } .history-row { grid-template-columns: minmax(0, 1fr) auto auto; } -.user-row { grid-template-columns: 1.1fr 1.2fr 110px 80px 1fr 140px; } +.user-row { grid-template-columns: 1fr 1.1fr 1.3fr 110px 70px 1fr 140px; } .user-row input, .user-row select { padding: 6px 7px; } -.user-form { display: grid; grid-template-columns: 1fr 1fr 1fr 120px auto; gap: 8px; margin-bottom: 20px; } +.user-form { display: grid; grid-template-columns: 1fr 1fr 1.2fr 1fr 120px auto; gap: 8px; margin-bottom: 20px; } +.settings-view { max-width: 760px; } +.settings-form { display: grid; gap: 14px; padding: 22px; } +.settings-form label:not(.checkbox-label) { display: grid; gap: 5px; font-weight: 600; } +.settings-form input { padding: 8px 10px; font: inherit; } +.settings-form fieldset { display: grid; gap: 12px; margin: 6px 0; padding: 16px; border: 1px solid #dfe3e8; } +.settings-form legend { font-weight: 700; padding: 0 6px; } +.settings-form .checkbox-label { display: flex; gap: 8px; align-items: center; } +.settings-form .checkbox-label input { width: auto; } +.settings-actions { display: flex; gap: 12px; align-items: center; } #diff-target { margin-top: 24px; background: white; } @media (max-width: 900px) { diff --git a/static/index.html b/static/index.html index 0ee4c47..e9a5f33 100644 --- a/static/index.html +++ b/static/index.html @@ -42,6 +42,8 @@ + Profile + Sign out @@ -212,6 +214,8 @@ + + @@ -258,6 +262,25 @@
+ + @@ -425,6 +474,7 @@

The selected CMap is inserted as a normal Markdown link.

+
@@ -442,6 +492,17 @@ + +
+
+

CMap history

+ +
+

Load a historical version into the editor to inspect it. Saving it creates a new current version.

+
+
+
+ diff --git a/static/js/wiki.js b/static/js/wiki.js index 8964f9f..650d811 100644 --- a/static/js/wiki.js +++ b/static/js/wiki.js @@ -43,6 +43,7 @@ let cmapStatusTimer = null; let cmapAutosaveTimer = null; let cmapSavePromise = null; + let cmapEmbedHydrationTimer = null; const CMAP_AUTOSAVE_DELAY = 1500; @@ -58,7 +59,7 @@ const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox")); function show(viewId) { - for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "admin-view", "alias-admin-view", "user-admin-view", "orphaned-uploads-view"]) { + for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view"]) { $(id).classList.toggle("hidden", id !== viewId); } $("page-action-links").classList.toggle("hidden", viewId !== "page-view"); @@ -492,13 +493,123 @@ * post : The returned HTML is sanitized with DOMPurify. * result : Safe HTML for preview, reader view or history view. */ + function extractCmapEmbeds(markdown) { + const embeds = []; + let fence = null; + const lines = String(markdown || "").split("\n").map((line) => { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fenceMatch) { + const marker = fenceMatch[1].charAt(0); + if (fence === null) fence = marker; + else if (fence === marker) fence = null; + return line; + } + if (fence !== null) return line; + const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i); + if (!match) return line; + const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`; + embeds.push({ token, reference: match[1].trim() }); + return token; + }); + return { markdown: lines.join("\n"), embeds }; + } + + function restoreCmapEmbeds(html, embeds) { + let result = html; + for (const embed of embeds) { + const placeholder = `
${escapeHtml(tr("loading", "Loading…"))}
`; + result = result.replace(`

${embed.token}

`, placeholder).replace(embed.token, placeholder); + } + return result; + } + + function queueCmapEmbedHydration() { + if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer); + cmapEmbedHydrationTimer = window.setTimeout(() => { + cmapEmbedHydrationTimer = null; + hydrateCmapEmbeds(document).catch((error) => console.error(error)); + }, 0); + } + function renderMarkdown(markdown, pageSlug = null) { - const withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || ""); + const extracted = extractCmapEmbeds(markdown || ""); + const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown); const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug); const withTodos = expandTodoMarkup(withWikiLinks, pageSlug); const html = easyMDE.markdown(withTodos); - const withImages = applyImageWidthMarkup(html); - return DOMPurify.sanitize(withImages); + const withEmbeds = restoreCmapEmbeds(html, extracted.embeds); + const withImages = applyImageWidthMarkup(withEmbeds); + const safeHtml = DOMPurify.sanitize(withImages); + if (extracted.embeds.length) queueCmapEmbedHydration(); + return safeHtml; + } + + async function hydrateCmapEmbeds(root) { + const embeds = Array.from(root.querySelectorAll(".rw-cmap-embed:not([data-cmap-hydrated])")); + for (const embed of embeds) { + embed.dataset.cmapHydrated = "loading"; + const conceptMap = cmapMentionTarget(embed.dataset.cmapReference || ""); + if (!conceptMap) { + embed.dataset.cmapHydrated = "error"; + embed.replaceChildren(); + const message = document.createElement("p"); + message.className = "error"; + message.textContent = tr("concept-map-not-found", "CMap not found"); + embed.append(message); + continue; + } + try { + const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`); + const documentValue = decodeStoredConceptMapDocument(stored); + embed.replaceChildren(); + embed.dataset.cmapSlug = conceptMap.slug; + embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap."); + const header = document.createElement("header"); + const title = document.createElement("strong"); + title.textContent = conceptMap.title; + const hint = document.createElement("span"); + hint.textContent = tr("embedded-concept-map-help", "Double-click to open this CMap."); + header.append(title, hint); + const viewport = document.createElement("div"); + viewport.className = "rw-cmap-embed-viewport"; + const canvas = document.createElement("div"); + canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas"; + viewport.append(canvas); + embed.append(header, viewport); + const editor = window.RacketWikiCmap.createEditor(canvas, { + Cmap: window.Cmap, + renderItem: (record) => cmapNodeHtml(record) + }); + editor.loadDocument(documentValue); + const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item)); + if (visible.length) { + const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24; + const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24; + const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24; + const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24; + const availableWidth = Math.max(320, embed.clientWidth - 2); + const scale = Math.min(1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top)); + editor.zoomFactor = scale; + editor.map.zoom(scale); + viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`; + window.requestAnimationFrame(() => { + viewport.scrollLeft = Math.max(0, left * scale); + viewport.scrollTop = Math.max(0, top * scale); + }); + } + embed.dataset.cmapHydrated = "ready"; + embed.addEventListener("dblclick", () => { + navigateToHash(cmapRoute(conceptMap.slug)).catch((error) => console.error(error)); + }); + } catch (error) { + embed.dataset.cmapHydrated = "error"; + embed.replaceChildren(); + const message = document.createElement("p"); + message.className = "error"; + message.textContent = error.message; + embed.append(message); + } + } } function slugTitle(slug) { @@ -1463,6 +1574,7 @@ wikiCmapLinkCombobox.setOptions( state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), ""); $("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0; + $("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0; $("wiki-cmap-link").placeholder = state.conceptMaps.length ? tr("filter-concept-maps", "Filter concept maps") : tr("no-concept-maps", "No saved CMaps"); @@ -1486,6 +1598,21 @@ return true; } + function insertSelectedWikiCmapEmbed() { + const slug = wikiCmapLinkCombobox.value(); + if (slug === null || !slug) { + const input = $("wiki-cmap-link"); + input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field.")); + input.reportValidity(); + return false; + } + const conceptMap = state.conceptMaps.find((item) => item.slug === slug); + if (!conceptMap) return false; + insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`); + $("wiki-cmap-link-dialog").close(); + return true; + } + function isInlineImage(file) { return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type); } @@ -2049,6 +2176,10 @@ username.textContent = user.username; const display = document.createElement("input"); display.value = user.displayName; + const email = document.createElement("input"); + email.type = "email"; + email.value = user.email || ""; + email.placeholder = tr("email-address", "Email address"); const role = document.createElement("select"); for (const roleName of ["reader", "editor", "admin"]) { const option = document.createElement("option"); @@ -2072,6 +2203,7 @@ method: "PUT", body: JSON.stringify({ displayName: display.value, + email: email.value, role: role.value, enabled: enabled.checked, password: password.value || undefined @@ -2088,11 +2220,75 @@ await loadUsersAdmin(); }); actions.append(save, remove); - row.append(username, display, role, enabled, password, actions); + row.append(username, display, email, role, enabled, password, actions); list.append(row); } } + function showProfile() { + renderBreadcrumbs([ + { label: state.siteTitle, href: "/" }, + { label: tr("profile", "Profile") } + ]); + show("profile-view"); + const user = state.session.user; + $("profile-username").value = user.username; + $("profile-display-name").value = user.displayName; + $("profile-email").value = user.email || ""; + $("profile-current-password").value = ""; + $("profile-new-password").value = ""; + $("profile-repeat-password").value = ""; + $("profile-status").textContent = ""; + } + + async function loadMailSettingsAdmin() { + renderBreadcrumbs([ + { label: state.siteTitle, href: "/" }, + { label: tr("admin", "Admin"), href: "#admin" }, + { label: tr("email-and-password-reset", "Email and password reset") } + ]); + show("mail-admin-view"); + const settings = await api("/api/admin/mail-settings"); + $("mail-public-url").value = settings.publicUrl || window.location.origin; + $("mail-smtp-host").value = settings.smtpHost || ""; + $("mail-smtp-port").value = settings.smtpPort || "587"; + $("mail-smtp-from").value = settings.smtpFrom || ""; + $("mail-smtp-user").value = settings.smtpUser || ""; + $("mail-smtp-password").value = ""; + $("mail-smtp-tls").checked = settings.smtpTls !== false; + $("mail-smtp-accept-untrusted-certificates").checked = settings.smtpAcceptUntrustedCertificates === true; + $("mail-smtp-accept-untrusted-certificates").disabled = !$("mail-smtp-tls").checked; + $("mail-reset-limit").value = settings.resetLimit || "2"; + $("mail-test-recipient").value = state.session.user.email || ""; + $("mail-password-help").textContent = settings.hasPassword ? tr("smtp-password-kept", "A password is stored; leave empty to keep it.") : ""; + $("mail-settings-status").textContent = ""; + } + + function mailSettingsFormData() { + return { + publicUrl: $("mail-public-url").value, + smtpHost: $("mail-smtp-host").value, + smtpPort: $("mail-smtp-port").value, + smtpFrom: $("mail-smtp-from").value, + smtpUser: $("mail-smtp-user").value, + smtpPassword: $("mail-smtp-password").value, + smtpTls: $("mail-smtp-tls").checked, + smtpAcceptUntrustedCertificates: $("mail-smtp-accept-untrusted-certificates").checked, + resetLimit: $("mail-reset-limit").value + }; + } + + function smtpErrorMessage(error) { + const message = error && error.message ? error.message : String(error); + if (message.includes("certificate verify failed") || message.includes("TLS certificate verification failed")) { + return tr("smtp-certificate-verification-failed", "The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server."); + } + if (message.includes("no protocols available")) { + return tr("smtp-no-modern-tls", "The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically."); + } + return message; + } + function showNotFound(slug) { state.currentPage = null; state.editingNew = false; @@ -2131,6 +2327,10 @@ * post : Exactly one application view is made active. */ async function route() { + if (location.hash === "#profile") { + showProfile(); + return; + } const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/); if (todoMatch) { await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2])); @@ -2186,6 +2386,11 @@ return; } + if (location.hash === "#admin/mail" && can("admin")) { + await loadMailSettingsAdmin(); + return; + } + if (location.hash === "#admin/aliases" && can("admin")) { await loadAliasesAdmin(); return; @@ -2899,7 +3104,7 @@ canvas.replaceChildren(); state.cmapPrototype = null; - console.info("[racket-wiki:cmap-host 0.2.84] resetCmapPrototype", { + console.info("[racket-wiki:cmap-host 0.2.94] resetCmapPrototype", { cmapAvailable: typeof window.Cmap === "function", interactionLayerAvailable: Boolean(window.RacketWikiCmap), interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null, @@ -2931,7 +3136,7 @@ } }, onOpenSubMap: (record) => { - console.info("[racket-wiki:cmap-host 0.2.84] submap state changed", { + console.info("[racket-wiki:cmap-host 0.2.94] submap state changed", { id: record.id, expanded: record.expanded, childMap: record.childMap @@ -2969,7 +3174,7 @@ relationLabel: tr("relation", "Relation") }); restoreCmapZoom(); - console.info("[racket-wiki:cmap-host 0.2.84] editor stored", { + console.info("[racket-wiki:cmap-host 0.2.94] editor stored", { editorAvailable: Boolean(prototype.editor), canvasChildCount: canvas.children.length }); @@ -3036,6 +3241,8 @@ const hasStoredMap = Boolean(state.currentConceptMap); $("cmap-rename-map").disabled = !hasStoredMap; $("cmap-delete-map").disabled = !hasStoredMap; + $("cmap-create-snapshot").disabled = !hasStoredMap; + $("cmap-history").disabled = !hasStoredMap; } async function loadConceptMaps() { @@ -3052,7 +3259,7 @@ documentValue = JSON.parse(documentValue); } if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) { - console.error("[racket-wiki:cmap-host 0.2.84] invalid stored CMap document", { + console.error("[racket-wiki:cmap-host 0.2.94] invalid stored CMap document", { slug: conceptMap.slug, valueType: Array.isArray(documentValue) ? "array" : typeof documentValue, value: documentValue @@ -3152,7 +3359,7 @@ const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`); if (loadSequence !== state.cmapLoadSequence) return; conceptMap.document = decodeStoredConceptMapDocument(conceptMap); - console.info("[racket-wiki:cmap-host 0.2.84] stored CMap received", { + console.info("[racket-wiki:cmap-host 0.2.94] stored CMap received", { slug: conceptMap.slug, version: conceptMap.currentVersion, itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0, @@ -3163,7 +3370,7 @@ resetCmapPrototype(conceptMap.document, false); markCurrentCmapSaved(); const loadedEditor = cmapPrototypeState().editor; - console.info("[racket-wiki:cmap-host 0.2.84] stored CMap loaded", { + console.info("[racket-wiki:cmap-host 0.2.94] stored CMap loaded", { slug: conceptMap.slug, editorAvailable: Boolean(loadedEditor), itemCount: loadedEditor ? loadedEditor.items.length : 0, @@ -3172,6 +3379,68 @@ showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true); } + async function loadHistoricalConceptMapVersion(version) { + const conceptMap = state.currentConceptMap; + if (!conceptMap) return; + const historical = await api( + `/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`); + historical.document = decodeStoredConceptMapDocument(historical); + const currentSnapshot = JSON.stringify(conceptMap.document); + resetCmapPrototype(historical.document, false); + state.cmapSavedSnapshot = currentSnapshot; + showCmapStatus( + tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.") + .replace("{version}", String(historical.version)), + true); + } + + async function showConceptMapHistory() { + const conceptMap = state.currentConceptMap; + if (!conceptMap) return; + const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`); + const list = $("cmap-history-list"); + list.replaceChildren(); + for (const version of result.versions || []) { + const row = document.createElement("div"); + row.className = "cmap-history-row"; + const label = document.createElement("div"); + const heading = document.createElement("strong"); + heading.textContent = `${tr("version", "Version")} ${version.version} — ${version.title}`; + const meta = document.createElement("div"); + meta.className = "muted"; + const knownSummaries = { + create: tr("concept-map-created-version", "CMap created"), + rename: tr("concept-map-renamed-version", "CMap renamed") + }; + let summary = knownSummaries[version.action] || version.summary; + if (version.action === "snapshot") { + const snapshotLabel = tr("snapshot", "Snapshot"); + if (version.summary === "Current state when CMap history was enabled") { + summary = tr("concept-map-initial-version", "Initial available version"); + } else { + summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel} — ${version.summary}`; + } + } + if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save"); + if (version.summary === "Manual save") summary = tr("manual-save", "Manual save"); + meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`; + label.append(heading, document.createElement("br"), meta); + const load = document.createElement("button"); + load.type = "button"; + load.textContent = version.version === conceptMap.currentVersion ? + tr("current-version", "Current") : tr("load-version", "Load version"); + load.disabled = version.version === conceptMap.currentVersion; + load.addEventListener("click", () => { + $("cmap-history-dialog").close(); + requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version)) + .catch((error) => showCmapStatus(error.message)); + }); + row.append(label, load); + list.append(row); + } + $("cmap-history-dialog").showModal(); + } + async function createStoredConceptMap() { const title = window.prompt(tr("concept-map-name", "Concept map name"), ""); if (!title || !title.trim()) return; @@ -3239,7 +3508,8 @@ } } - async function saveStoredConceptMap({ automatic = false } = {}) { + async function saveStoredConceptMap({ automatic = false, force = false, + summary = null, snapshotVersion = false } = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return false; cancelCmapAutosave(); @@ -3247,13 +3517,15 @@ if (cmapSavePromise) { const firstSaveSucceeded = await cmapSavePromise; if (!firstSaveSucceeded) return false; - if (cmapHasUnsavedChanges()) return saveStoredConceptMap({ automatic }); + if (force || cmapHasUnsavedChanges()) { + return saveStoredConceptMap({ automatic, force, summary, snapshotVersion }); + } return true; } const snapshot = currentCmapSnapshot(); if (snapshot === null) return false; - if (snapshot === state.cmapSavedSnapshot) { + if (!force && snapshot === state.cmapSavedSnapshot) { if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true); return true; } @@ -3261,7 +3533,8 @@ const document = JSON.parse(snapshot); const conceptMapAtStart = state.currentConceptMap; let saveSucceeded = false; - showCmapStatus(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")); + showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") : + (automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…"))); cmapSavePromise = (async () => { try { if (!conceptMapAtStart) { @@ -3286,6 +3559,8 @@ body: JSON.stringify({ title: conceptMapAtStart.title, baseVersion: conceptMapAtStart.currentVersion, + summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")), + snapshot: snapshotVersion, document }) }); @@ -3296,7 +3571,8 @@ } markCurrentCmapSaved(snapshot); showCmapStatus( - automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved"), + snapshotVersion ? tr("snapshot-created", "Snapshot created") : + (automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")), true); saveSucceeded = true; return true; @@ -3311,6 +3587,17 @@ return cmapSavePromise; } + async function createConceptMapSnapshot() { + if (!state.currentConceptMap) return false; + const description = window.prompt(tr("snapshot-description", "Snapshot description"), ""); + if (description === null) return false; + return saveStoredConceptMap({ + force: true, + summary: description.trim() || tr("snapshot", "Snapshot"), + snapshotVersion: true + }); + } + async function showCmapPrototype(requestedSlug = null) { state.previousView = state.currentPage ? "page-view" : "cmap-view"; renderBreadcrumbs([ @@ -3366,6 +3653,10 @@ const pageSlug = wikiSlugFromHref(href); if (pageSlug) pages.add(pageSlug); } + for (const embed of container.querySelectorAll(".rw-cmap-embed[data-cmap-reference]")) { + const target = cmapMentionTarget(embed.dataset.cmapReference || ""); + if (target) conceptMaps.add(target.slug); + } return { pages, conceptMaps }; } @@ -3902,6 +4193,12 @@ $("cmap-save-map").addEventListener("click", () => saveStoredConceptMap()); $("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap()); $("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap()); + $("cmap-create-snapshot").addEventListener("click", () => { + createConceptMapSnapshot().catch((error) => showCmapStatus(error.message)); + }); + $("cmap-history").addEventListener("click", () => { + showConceptMapHistory().catch((error) => showCmapStatus(error.message)); + }); $("cmap-add-concept").addEventListener("click", () => { openNewCmapConceptDialog(cmapContextCreateContext || {}); }); @@ -4115,6 +4412,7 @@ event.preventDefault(); insertSelectedWikiCmapLink(); }); + $("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed); $("wiki-cmap-link-dialog").addEventListener("close", () => { pendingWikiCmapLinkLabel = ""; wikiCmapLinkCombobox.clear(); @@ -4128,6 +4426,7 @@ event.preventDefault(); cancelCmapTransition(); }); + $("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close()); $("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch((error) => console.error(error)); }); $("context-overlay-close").addEventListener("click", (event) => { event.preventDefault(); closeContextOverlay(); }); $("context-overlay").addEventListener("click", (event) => { if (event.target === $("context-overlay")) closeContextOverlay(); }); @@ -4157,6 +4456,37 @@ await api("/api/logout", { method: "POST", body: "{}" }); window.location.replace("/login"); }); + $("profile-link").addEventListener("click", (event) => { + event.preventDefault(); + navigateToHash("#profile").catch(console.error); + }); + $("profile-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const newPassword = $("profile-new-password").value; + if (newPassword !== $("profile-repeat-password").value) { + $("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match."); + return; + } + try { + const result = await api("/api/profile", { + method: "PUT", + body: JSON.stringify({ + displayName: $("profile-display-name").value, + email: $("profile-email").value, + currentPassword: $("profile-current-password").value, + newPassword + }) + }); + state.session = result.session; + $("account-name").textContent = state.session.user.displayName; + $("profile-current-password").value = ""; + $("profile-new-password").value = ""; + $("profile-repeat-password").value = ""; + $("profile-status").textContent = tr("profile-saved", "Profile saved."); + } catch (error) { + $("profile-status").textContent = error.message; + } + }); $("admin-link").addEventListener("click", (event) => { event.preventDefault(); navigateToHash("#admin").catch(console.error); @@ -4165,6 +4495,10 @@ event.preventDefault(); navigateToHash("#admin/users").catch(console.error); }); + $("admin-mail-link").addEventListener("click", (event) => { + event.preventDefault(); + navigateToHash("#admin/mail").catch(console.error); + }); $("admin-aliases-link").addEventListener("click", (event) => { event.preventDefault(); navigateToHash("#admin/aliases").catch(console.error); @@ -4195,6 +4529,10 @@ event.preventDefault(); navigateToHash("#admin").catch(console.error); }); + $("close-mail-admin").addEventListener("click", (event) => { + event.preventDefault(); + navigateToHash("#admin").catch(console.error); + }); $("close-alias-admin").addEventListener("click", (event) => { event.preventDefault(); navigateToHash("#admin").catch(console.error); @@ -4247,6 +4585,7 @@ body: JSON.stringify({ username: $("new-username").value, displayName: $("new-display-name").value || $("new-username").value, + email: $("new-email").value, password: $("new-password").value, role: $("new-role").value, enabled: true @@ -4256,6 +4595,49 @@ await loadUsersAdmin(); }); + $("mail-settings-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await api("/api/admin/mail-settings", { + method: "PUT", + body: JSON.stringify(mailSettingsFormData()) + }); + $("mail-smtp-password").value = ""; + $("mail-password-help").textContent = tr("smtp-password-kept", "A password is stored; leave empty to keep it."); + $("mail-settings-status").textContent = tr("saved", "Saved"); + } catch (error) { + $("mail-settings-status").textContent = error.message; + } + }); + + $("mail-test-button").addEventListener("click", async () => { + const button = $("mail-test-button"); + const recipient = $("mail-test-recipient").value.trim(); + button.disabled = true; + $("mail-settings-status").textContent = tr("sending-test-mail", "Sending test email…"); + try { + await api("/api/admin/mail-settings/test", { + method: "POST", + body: JSON.stringify({ + ...mailSettingsFormData(), + recipient + }) + }); + $("mail-settings-status").textContent = tr("test-mail-sent", "Test email accepted by SMTP server"); + } catch (error) { + console.error("SMTP test failed", error); + $("mail-settings-status").textContent = smtpErrorMessage(error); + } finally { + button.disabled = false; + } + }); + + $("mail-smtp-tls").addEventListener("change", () => { + const acceptUntrustedCertificates = $("mail-smtp-accept-untrusted-certificates"); + acceptUntrustedCertificates.disabled = !$("mail-smtp-tls").checked; + if (acceptUntrustedCertificates.disabled) acceptUntrustedCertificates.checked = false; + }); + window.addEventListener("hashchange", () => { if (cmapHasUnsavedChanges()) { const requestedHash = location.hash; diff --git a/translate.rkt b/translate.rkt index b43a221..b4e822a 100644 --- a/translate.rkt +++ b/translate.rkt @@ -40,17 +40,37 @@ 'rename-concept-map "Rename CMap" 'delete-concept-map "Delete CMap" 'redo-action "Redo" 'save-now "Save now" 'autosave-pending "Changes waiting to be saved" 'autosaving "Saving automatically…" 'concept-map-autosaved "CMap saved automatically" 'concept-map-renamed "CMap renamed" 'concept-map-deleted "CMap deleted" + 'concept-map-history "CMap history" 'concept-map-history-help "Load a historical version into the editor to inspect it. Saving it creates a new current version." + 'create-concept-map-snapshot "Make snapshot…" 'snapshot-description "Snapshot description" 'snapshot "Snapshot" + 'creating-snapshot "Creating snapshot…" 'snapshot-created "Snapshot created" + 'concept-map-version-loaded "Version {version} loaded; save to make it current." 'load-version "Load version" 'current-version "Current" + 'automatic-save "Automatic save" 'manual-save "Manual save" 'concept-map-created-version "CMap created" + 'concept-map-renamed-version "CMap renamed" 'concept-map-initial-version "Initial available version" 'delete-concept-map-confirm "Delete concept map \"{title}\"? Links to it will remain but will no longer open the map." 'reload-concept-map "Reload CMap" 'edit-concept "Edit concept" 'linked-page "Linked wiki page" 'no-linked-page "No linked page" 'filter-pages "Filter pages" 'linked-concept-map "Linked concept map" 'no-linked-concept-map "No linked concept map" 'filter-concept-maps "Filter concept maps" 'parent-concept-map "Parent concept map" 'select-concept-map "Select a CMap" 'concept-map-loaded "CMap loaded" 'select-listed-page "Select a wiki page from the list or clear the field." 'select-listed-concept-map "Select a CMap from the list or clear the field." 'unsaved-concept-map "Unsaved concept map" 'save-concept-map-before-leaving "Save the changes to this concept map before leaving?" 'dont-save "Don't save" - 'concept-image "Concept image" 'remove-image "Remove image" 'text-color "Text color" 'main-concept-background-color "Main concept background color" 'submap-appearance "Sub-CMap appearance" 'submap-background-color "Sub-CMap background color" 'submap-border-color "Sub-CMap border color" 'link-concept-map "Link to CMap" 'insert-concept-map-link "Insert CMap link" 'concept-map-link-help "The selected CMap is inserted as a normal Markdown link." 'concept-map-not-found "CMap not found" 'insert "Insert" 'a4-page-boundaries "A4 page boundaries" + 'concept-image "Concept image" 'remove-image "Remove image" 'text-color "Text color" 'main-concept-background-color "Main concept background color" 'submap-appearance "Sub-CMap appearance" 'submap-background-color "Sub-CMap background color" 'submap-border-color "Sub-CMap border color" 'link-concept-map "Link to CMap" 'insert-concept-map-link "Insert CMap link" 'concept-map-link-help "Insert the selected CMap as a link or as a read-only view." 'concept-map-not-found "CMap not found" 'insert "Insert" 'insert-link "Insert link" 'embed-concept-map "Embed CMap" 'embedded-concept-map-help "Double-click to open this CMap." 'a4-page-boundaries "A4 page boundaries" 'search-wiki "Search pages and concept maps" 'contents "Contents" 'pages "Pages" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Root" 'wiki-page "Wiki page" 'concept-map "Concept map" 'no-matching-search-results "No matching pages or concept maps." 'edit "Edit" 'rename "Rename" 'rename-page "Rename page" 'slug "Slug" 'rename-summary "Renamed page" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found" 'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)" 'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history" 'back "Back" 'user-administration "User administration" 'username "Username" 'display-name "Display name" 'initial-password "Initial password" 'create-user "Create user" - 'sign-in "Sign in" 'sign-out "Sign out" 'password "Password" + 'sign-in "Sign in" 'sign-out "Sign out" 'password "Password" 'profile "Profile" + 'email-address "Email address" 'change-password "Change password" 'change-password-help "Leave these fields empty if you do not want to change your password." + 'current-password "Current password" 'save-profile "Save profile" 'profile-saved "Profile saved." 'passwords-do-not-match "Passwords do not match." + 'password-minimum "The password must contain at least 8 characters." 'forgot-password "Forgot your password?" 'reset-password "Reset password" + 'reset-request-help "Enter your username or email address. If an account with an email address exists, a reset link will be sent." + 'username-or-email "Username or email address" 'send-reset-link "Send reset link" 'reset-request-result "If a matching account with an email address exists and the request limit has not been reached, a reset link has been sent." + 'back-to-login "Back to sign in" 'invalid-reset-link "This reset link is invalid, expired or has already been used." 'request-new-reset-link "Request a new reset link" 'password-reset-complete "Your password has been reset. You can now sign in." + 'save-new-password "Save new password" 'email-and-password-reset "Email and password reset" 'public-wiki-url "Public wiki URL" + 'smtp-server "SMTP server" 'smtp-port "SMTP port" 'sender-address "Sender address" 'smtp-username "SMTP username" 'smtp-password "SMTP password" + 'smtp-password-kept "A password is stored; leave empty to keep it." 'use-starttls "Use STARTTLS" 'accept-untrusted-smtp-certificates "Accept untrusted certificates (trusted local server only)" 'reset-links-per-hour "Maximum reset links per user per hour" + 'test-smtp-settings "Test SMTP settings" 'test-recipient "Test recipient" 'send-test-mail "Send test email" + 'sending-test-mail "Sending test email…" 'test-mail-sent "Test email accepted by SMTP server; save these settings to use them for password reset" + 'smtp-certificate-verification-failed "The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server." + 'smtp-no-modern-tls "The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically." + 'reset-request-next-step "Check your inbox and spam folder. Delivery can take a few minutes." 'upload-image "Upload image" 'upload-file "Upload file" 'saved "Saved" 'offline "Offline" 'online "Online" 'todo-items "Todo items" 'no-todos "No todo items." 'no-matching-pages "No matching pages." @@ -94,17 +114,37 @@ 'rename-concept-map "CMap hernoemen" 'delete-concept-map "CMap verwijderen" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan" 'autosave-pending "Wijzigingen wachten op opslaan" 'autosaving "Automatisch opslaan…" 'concept-map-autosaved "CMap automatisch opgeslagen" 'concept-map-renamed "CMap hernoemd" 'concept-map-deleted "CMap verwijderd" + 'concept-map-history "CMap-geschiedenis" 'concept-map-history-help "Laad een historische versie in de editor om hem te bekijken. Opslaan maakt hiervan een nieuwe actuele versie." + 'create-concept-map-snapshot "Snapshot maken…" 'snapshot-description "Omschrijving van snapshot" 'snapshot "Snapshot" + 'creating-snapshot "Snapshot maken…" 'snapshot-created "Snapshot gemaakt" + 'concept-map-version-loaded "Versie {version} geladen; sla op om hem actueel te maken." 'load-version "Versie laden" 'current-version "Actueel" + 'automatic-save "Automatisch opgeslagen" 'manual-save "Handmatig opgeslagen" 'concept-map-created-version "CMap aangemaakt" + 'concept-map-renamed-version "CMap hernoemd" 'concept-map-initial-version "Eerste beschikbare versie" 'delete-concept-map-confirm "Conceptmap \"{title}\" verwijderen? Links ernaartoe blijven bestaan, maar kunnen de conceptmap niet meer openen." 'reload-concept-map "CMap opnieuw laden" 'edit-concept "Concept bewerken" 'linked-page "Gekoppelde wikipagina" 'no-linked-page "Geen gekoppelde pagina" 'filter-pages "Filter pagina's" 'linked-concept-map "Gekoppelde conceptmap" 'no-linked-concept-map "Geen gekoppelde conceptmap" 'filter-concept-maps "Filter conceptmaps" 'parent-concept-map "Bovenliggende conceptmap" 'select-concept-map "Selecteer een CMap" 'concept-map-loaded "CMap geladen" 'select-listed-page "Selecteer een wikipagina uit de lijst of maak het veld leeg." 'select-listed-concept-map "Selecteer een CMap uit de lijst of maak het veld leeg." 'unsaved-concept-map "Niet-opgeslagen conceptmap" 'save-concept-map-before-leaving "De wijzigingen aan deze conceptmap opslaan voordat u hem verlaat?" 'dont-save "Niet opslaan" - 'concept-image "Afbeelding bij concept" 'remove-image "Afbeelding verwijderen" 'text-color "Tekstkleur" 'main-concept-background-color "Achtergrondkleur hoofdconcept" 'submap-appearance "Vormgeving sub-CMap" 'submap-background-color "Achtergrondkleur sub-CMap" 'submap-border-color "Randkleur sub-CMap" 'link-concept-map "Link naar CMap" 'insert-concept-map-link "CMap-link invoegen" 'concept-map-link-help "De geselecteerde CMap wordt als gewone Markdown-link ingevoegd." 'concept-map-not-found "CMap niet gevonden" 'insert "Invoegen" 'a4-page-boundaries "A4-paginagrenzen" + 'concept-image "Afbeelding bij concept" 'remove-image "Afbeelding verwijderen" 'text-color "Tekstkleur" 'main-concept-background-color "Achtergrondkleur hoofdconcept" 'submap-appearance "Vormgeving sub-CMap" 'submap-background-color "Achtergrondkleur sub-CMap" 'submap-border-color "Randkleur sub-CMap" 'link-concept-map "Link naar CMap" 'insert-concept-map-link "CMap invoegen" 'concept-map-link-help "Voeg de geselecteerde CMap in als link of als alleen-lezen weergave." 'concept-map-not-found "CMap niet gevonden" 'insert "Invoegen" 'insert-link "Link invoegen" 'embed-concept-map "CMap insluiten" 'embedded-concept-map-help "Dubbelklik om deze CMap te openen." 'a4-page-boundaries "A4-paginagrenzen" 'search-wiki "Pagina's en conceptmaps doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Hoofdnamespace" 'wiki-page "Wikipagina" 'concept-map "Conceptmap" 'no-matching-search-results "Geen overeenkomende wikipagina's of conceptmaps." 'edit "Bewerken" 'rename "Hernoemen" 'rename-page "Pagina hernoemen" 'slug "Slug" 'rename-summary "Pagina hernoemd" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden" 'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)" 'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis" 'back "Terug" 'user-administration "Gebruikersbeheer" 'username "Gebruikersnaam" 'display-name "Weergavenaam" 'initial-password "Initieel wachtwoord" 'create-user "Gebruiker aanmaken" - 'sign-in "Inloggen" 'sign-out "Afmelden" 'password "Wachtwoord" + 'sign-in "Inloggen" 'sign-out "Afmelden" 'password "Wachtwoord" 'profile "Profiel" + 'email-address "E-mailadres" 'change-password "Wachtwoord wijzigen" 'change-password-help "Laat deze velden leeg als u uw wachtwoord niet wilt wijzigen." + 'current-password "Huidig wachtwoord" 'save-profile "Profiel opslaan" 'profile-saved "Profiel opgeslagen." 'passwords-do-not-match "De wachtwoorden komen niet overeen." + 'password-minimum "Het wachtwoord moet minstens 8 tekens bevatten." 'forgot-password "Wachtwoord vergeten?" 'reset-password "Wachtwoord herstellen" + 'reset-request-help "Vul uw gebruikersnaam of e-mailadres in. Als er een account met een e-mailadres bestaat, wordt een herstellink verstuurd." + 'username-or-email "Gebruikersnaam of e-mailadres" 'send-reset-link "Herstellink versturen" 'reset-request-result "Als er een passend account met e-mailadres bestaat en de aanvraaglimiet niet is bereikt, is een herstellink verstuurd." + 'back-to-login "Terug naar inloggen" 'invalid-reset-link "Deze herstellink is ongeldig, verlopen of al gebruikt." 'request-new-reset-link "Nieuwe herstellink aanvragen" 'password-reset-complete "Uw wachtwoord is hersteld. U kunt nu inloggen." + 'save-new-password "Nieuw wachtwoord opslaan" 'email-and-password-reset "E-mail en wachtwoordherstel" 'public-wiki-url "Publieke wiki-URL" + 'smtp-server "SMTP-server" 'smtp-port "SMTP-poort" 'sender-address "Afzenderadres" 'smtp-username "SMTP-gebruikersnaam" 'smtp-password "SMTP-wachtwoord" + 'smtp-password-kept "Er is een wachtwoord opgeslagen; laat dit leeg om het te behouden." 'use-starttls "STARTTLS gebruiken" 'accept-untrusted-smtp-certificates "Onvertrouwde certificaten accepteren (alleen voor een vertrouwde lokale server)" 'reset-links-per-hour "Maximaal aantal herstellinks per gebruiker per uur" + 'test-smtp-settings "SMTP-instellingen testen" 'test-recipient "Testontvanger" 'send-test-mail "Testmail versturen" + 'sending-test-mail "Testmail versturen…" 'test-mail-sent "Testmail door SMTP-server geaccepteerd; sla deze instellingen op voor wachtwoordherstel" + 'smtp-certificate-verification-failed "Het certificaat van de SMTP-server kon niet worden gecontroleerd. Installeer een geldig certificaat of kies de uitzondering voor een vertrouwde lokale SMTP-server." + 'smtp-no-modern-tls "De SMTP-verbinding probeerde een verouderd TLS-protocol. Installeer de huidige versie van Racket Wiki; die onderhandelt automatisch over moderne TLS." + 'reset-request-next-step "Controleer uw inbox en spammap. De bezorging kan enkele minuten duren." 'upload-image "Afbeelding uploaden" 'upload-file "Bestand uploaden" 'saved "Opgeslagen" 'offline "Offline" 'online "Online" 'todo-items "Todo-items" 'no-todos "Geen todo-items." 'no-matching-pages "Geen overeenkomende pagina's."