Initial import
This commit is contained in:
@@ -15,3 +15,6 @@ compiled/
|
|||||||
# Dependency tracking files
|
# Dependency tracking files
|
||||||
*.dep
|
*.dep
|
||||||
|
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
wiki-data/
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#lang racket-makefile
|
||||||
|
|
||||||
|
(target all
|
||||||
|
(displayln "Use (make clean), (make ver), (make zip), etc."))
|
||||||
|
|
||||||
|
(target ver
|
||||||
|
(displayln (git* next-version)))
|
||||||
|
|
||||||
|
(target zip
|
||||||
|
(zip-package))
|
||||||
|
|
||||||
|
(target clean
|
||||||
|
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "." #px"([.]bak|~)$" #:recursive #t))
|
||||||
|
(for-each (λ (d) (displayln d) (rm-rf d)) (list-dirs "." #px"(compiled|doc|docs)$" #:recursive #t))
|
||||||
|
(for-each (λ (f) (displayln f) (rm-f f)) (list-files "scrbl" #px"[.](css|js|html)$"))
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,3 +1,327 @@
|
|||||||
# racket-wiki
|
# racket-wiki
|
||||||
|
|
||||||
A simple wiki using markdown syntax
|
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
|
||||||
|
|
||||||
|
Version 0.2.9 uses PostgreSQL as the wiki's complete content store. Users, sessions, pages, immutable page versions, attachment metadata and attachment bytes live in PostgreSQL. PostgreSQL full-text search is built into the page table and exposed by the wiki search UI.
|
||||||
|
|
||||||
|
The first-run flow is web based. An unconfigured wiki redirects immediately to `/setup`. Setup asks for the PostgreSQL connection, creates the schema, downloads the pinned frontend libraries and creates the first administrator. After setup the browser is sent to `/login`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
The backend uses Racket `web-server-lib` for HTTP routing, `db-lib` for PostgreSQL and `crypto-lib` for password hashing. Racket's PostgreSQL support implements the wire protocol directly, so no PostgreSQL client library is required by racket-wiki.
|
||||||
|
|
||||||
|
Passwords use PBKDF2-HMAC-SHA256 through the libcrypto/OpenSSL provider. Session tokens are random client values whose SHA-256 hashes are stored in PostgreSQL; authenticated write requests also use a per-session CSRF token.
|
||||||
|
|
||||||
|
The editor uses EasyMDE 2.21.0. EasyMDE bundles CodeMirror for editing and Marked for Markdown rendering. The application uses `easyMDE.markdown(...)` for the editor preview, normal page display and historical page display. DOMPurify sanitizes rendered Markdown, highlight.js highlights fenced code blocks, and diff2html renders page-version differences.
|
||||||
|
|
||||||
|
Font Awesome is installed locally by the setup procedure and supplies the EasyMDE toolbar icons.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
`reader` can read pages, page history and uploaded files.
|
||||||
|
|
||||||
|
`editor` inherits `reader` and can create, edit and archive pages and upload files.
|
||||||
|
|
||||||
|
`admin` inherits `editor` and can administer users.
|
||||||
|
|
||||||
|
There is no anonymous page access. Setup and login are server-rendered pages. The normal JavaScript wiki application is only served when the request has an authenticated session.
|
||||||
|
|
||||||
|
## Data layout
|
||||||
|
|
||||||
|
PostgreSQL contains the relational/textual wiki state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
users
|
||||||
|
sessions
|
||||||
|
pages
|
||||||
|
page_versions
|
||||||
|
attachments
|
||||||
|
wiki_schema
|
||||||
|
```
|
||||||
|
|
||||||
|
`pages.markdown` contains the current Markdown source. Every save increments `current_version` and writes the matching immutable snapshot to `page_versions` in the same PostgreSQL transaction. Optimistic locking prevents an editor from silently overwriting a newer version.
|
||||||
|
|
||||||
|
The page search vector gives titles a higher weight than body Markdown and is indexed with a PostgreSQL GIN index. The `simple` text search configuration is used deliberately because a technical wiki commonly mixes Dutch, English, identifiers and package names.
|
||||||
|
|
||||||
|
The filesystem contains only installation/configuration assets during normal 0.2.9 operation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wiki-data/
|
||||||
|
database.rktd
|
||||||
|
static/
|
||||||
|
vendor/
|
||||||
|
... downloaded browser libraries ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`database.rktd` contains the PostgreSQL connection settings, including the password when one is supplied. Protect the data directory accordingly. Attachment metadata and bytes are stored in PostgreSQL. An `uploads/` directory may remain after migration from schema 1; it is retained only as a safety copy and is no longer read by 0.2.9.
|
||||||
|
|
||||||
|
Archiving a page marks it archived in PostgreSQL and keeps its history. Restore UI is a later addition.
|
||||||
|
|
||||||
|
## First-run web setup
|
||||||
|
|
||||||
|
Start the server normally:
|
||||||
|
|
||||||
|
```text
|
||||||
|
racket main.rkt --data ./wiki-data --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8080/`. An incomplete installation is immediately redirected to `/setup`.
|
||||||
|
|
||||||
|
On a fresh installation the setup page asks for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PostgreSQL server localhost
|
||||||
|
PostgreSQL port 5432
|
||||||
|
Database racket_wiki
|
||||||
|
User <database role>
|
||||||
|
Password <optional according to PostgreSQL authentication>
|
||||||
|
TLS/SSL no / optional / required
|
||||||
|
|
||||||
|
Administrator username
|
||||||
|
Display name
|
||||||
|
Administrator password
|
||||||
|
```
|
||||||
|
|
||||||
|
The PostgreSQL database itself must already exist and the supplied role must be allowed to connect and create the racket-wiki tables and indexes in the selected database. Setup tests the connection before storing the settings.
|
||||||
|
|
||||||
|
Setup creates the schema, including users, sessions, pages, page versions, attachments and their binary content, the schema migration history and the page full-text-search GIN index. It then downloads the pinned frontend libraries and creates the first administrator. After all setup conditions are satisfied, the browser is redirected to `/login`; setup deliberately does not log the administrator in automatically.
|
||||||
|
|
||||||
|
Setup is considered complete only when the PostgreSQL schema is reachable, at least one enabled administrator exists and all required browser vendor files are installed. `/setup` therefore also acts as a repair path when frontend assets are missing.
|
||||||
|
|
||||||
|
## Frontend libraries
|
||||||
|
|
||||||
|
The web setup downloads these pinned browser builds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
font-awesome/css/font-awesome.min.css
|
||||||
|
font-awesome/fonts/fontawesome-webfont.woff2
|
||||||
|
easymde.min.js
|
||||||
|
easymde.min.css
|
||||||
|
purify.min.js
|
||||||
|
highlight.min.js
|
||||||
|
highlight-scheme.min.js
|
||||||
|
highlight-github.min.css
|
||||||
|
diff2html-ui-base.min.js
|
||||||
|
diff2html.min.css
|
||||||
|
```
|
||||||
|
|
||||||
|
EasyMDE already bundles CodeMirror and Marked, so racket-wiki does not download a separate Markdown renderer or CodeMirror build.
|
||||||
|
|
||||||
|
The setup downloader validates HTTPS certificates and hostnames. The running wiki serves these files locally from the configured data directory, so no CDN is required after setup.
|
||||||
|
|
||||||
|
`setup-vendor.rkt` remains available as a manual/recovery command. It now writes to the same data directory as the web setup:
|
||||||
|
|
||||||
|
```text
|
||||||
|
racket setup-vendor.rkt --data ./wiki-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal first-run installation does not require this command.
|
||||||
|
|
||||||
|
## Development bootstrap
|
||||||
|
|
||||||
|
The bootstrap URL used for this development snapshot is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://rktsndbx.dijkewijk.nl/bootstrap-racket?next=opper-vrolijk-fresh-pocket
|
||||||
|
```
|
||||||
|
|
||||||
|
When development is performed in an isolated sandbox without a Racket installation, use that Racket bootstrap URL. The bootstrap page lists a numbered set of `text/plain` files. Each file contains the base64 representation of one binary part of `racket.zip`.
|
||||||
|
|
||||||
|
Use network access only through the environment web fetch/download facility. Do not use `curl`, `wget`, Python networking or shell networking for the bootstrap.
|
||||||
|
|
||||||
|
Use these fixed paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
work directory : /tmp/racket_bootstrap
|
||||||
|
zip file : /tmp/racket.zip
|
||||||
|
Racket runtime : /mnt/data/racket
|
||||||
|
Racket binary : /mnt/data/racket/bin/racket
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap procedure is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Open the supplied bootstrap URL with the web fetcher.
|
||||||
|
2. Read the number of parts and the exact part URLs from that page.
|
||||||
|
3. Download every part to /tmp/racket_bootstrap/part-NNNNNN.txt.
|
||||||
|
4. Decode every text file separately with base64 -d to part-NNNNNN.
|
||||||
|
5. Concatenate the binary parts in numeric order to /tmp/racket.zip.
|
||||||
|
6. Unpack the zip at filesystem root: unzip /tmp/racket.zip -d /
|
||||||
|
7. Use /mnt/data/racket/bin/racket and /mnt/data/racket/bin/raco.
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive intentionally contains the path `mnt/data/racket`, so unpacking at `/` places the runtime at `/mnt/data/racket`. Additional Racket packages should be obtained through the package/fetch facilities exposed by the bootstrap environment rather than by adding unrelated shell-network tooling.
|
||||||
|
|
||||||
|
This bootstrap is development tooling only. A normal installation can use an existing Racket installation and the dependencies declared in `info.rkt`.
|
||||||
|
|
||||||
|
## Starting from DrRacket
|
||||||
|
|
||||||
|
`main.rkt` can be run directly from DrRacket. Racket treats its `main` submodule in the same way as when the file is run with the `racket` executable, so pressing **Run** starts the wiki with the normal defaults when no command-line arguments are configured.
|
||||||
|
|
||||||
|
For interactive development, `main.rkt` also provides `start`. Require the module from an interactions file or another development module and supply keyword arguments directly:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(require "main.rkt")
|
||||||
|
|
||||||
|
(start #:data-dir "wiki-data"
|
||||||
|
#:port 8080
|
||||||
|
#:listen-ip "127.0.0.1"
|
||||||
|
#:site-title "Racket Wiki"
|
||||||
|
#:secure-cookie? #f)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `#:listen-ip "*"` or `#:listen-ip #f` to listen on all interfaces. `#:session-seconds` can be supplied when the default twelve-hour session lifetime is not desired.
|
||||||
|
|
||||||
|
The same web setup procedure applies when the server is started from DrRacket.
|
||||||
|
|
||||||
|
## Starting
|
||||||
|
|
||||||
|
Normally only the server start command is required:
|
||||||
|
|
||||||
|
```text
|
||||||
|
racket main.rkt --data ./wiki-data --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8080/`. A fresh data directory redirects to `/setup`; after setup the browser goes to `/login`.
|
||||||
|
|
||||||
|
To listen on all interfaces:
|
||||||
|
|
||||||
|
```text
|
||||||
|
racket main.rkt --data ./wiki-data --listen '*' --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
When the site is served through HTTPS, add `--secure-cookie` so the session cookie gets the Secure attribute.
|
||||||
|
|
||||||
|
The `--create-admin` command-line option remains available for recovery or scripted administration, but it is no longer part of the normal first-run procedure.
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
|
||||||
|
EasyMDE supplies the Markdown editor, Markdown-aware styling while editing, line numbers, keyboard shortcuts, preview, side-by-side mode and fullscreen mode. On a normal desktop-sized window racket-wiki opens the editor in side-by-side mode by default. EasyMDE is configured with `sideBySideFullscreen: false`, so split editing remains inside the normal wiki page rather than taking over the complete browser window.
|
||||||
|
|
||||||
|
The toolbar uses the normal EasyMDE-style Font Awesome icons, installed locally during setup. It includes formatting, headings, lists, checklists, code blocks, tables, links, image upload, general file upload, undo/redo, preview, split view and fullscreen.
|
||||||
|
|
||||||
|
The sidebar gives the current page table of contents priority. The global page list is secondary and collapsible. The search box performs real PostgreSQL full-text search rather than filtering page titles in the browser.
|
||||||
|
|
||||||
|
A fenced block such as:
|
||||||
|
|
||||||
|
````text
|
||||||
|
```racket
|
||||||
|
(define (hello name)
|
||||||
|
(displayln name))
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
is highlighted in the preview through highlight.js. highlight.js has a Scheme grammar rather than a dedicated Racket grammar; racket-wiki registers `racket` as an alias for that grammar.
|
||||||
|
|
||||||
|
|
||||||
|
## Page titles, slugs and missing links
|
||||||
|
|
||||||
|
For a normal new page the editor asks only for the page title. The Racket backend derives the slug when the page is first saved. Whitespace and punctuation become hyphens, the slug is lower-cased, and Unicode compatibility decomposition is used so common accented Latin titles produce readable addresses. A later title change does not rename the slug, which keeps existing links stable.
|
||||||
|
|
||||||
|
A link can explicitly name a page slug. In rendered Markdown, simple relative links such as `[Install](install)` and root links such as `[Install](/install)` are treated as wiki-page links; `#/install` also remains supported. External URLs and upload URLs are left alone.
|
||||||
|
|
||||||
|
When an editor or administrator follows a wiki link whose target does not exist, racket-wiki opens a new empty editor at that requested slug instead of exposing an API/Racket error. The title is still entered by the editor. A reader gets a normal `Page not found` view. An unauthenticated request never reaches the wiki page application: it is redirected to `/login` first.
|
||||||
|
|
||||||
|
## Full-text search
|
||||||
|
|
||||||
|
The sidebar search field calls the authenticated `/api/search` endpoint. PostgreSQL builds a `tsquery` with `websearch_to_tsquery('simple', ...)`, ranks matches with `ts_rank`, and returns a short `ts_headline` excerpt. Search results are relevance sorted, with title terms weighted above body terms. The search vector is updated atomically whenever a page is created or saved.
|
||||||
|
|
||||||
|
## Uploads
|
||||||
|
|
||||||
|
EasyMDE handles image upload through its normal image-upload hook. PNG, JPEG, GIF and WebP can therefore be inserted by its image button, drag/drop or clipboard paste. The upload itself still goes to the Racket API and the returned page-local URL is inserted into the Markdown.
|
||||||
|
|
||||||
|
The additional `File` toolbar button is for arbitrary attachments such as PDF, ZIP or source files. Dropping one or more non-image files on the editor is handled by racket-wiki and inserts Markdown links. A mixed drop containing images and other files uses this general file path so every dropped file is retained.
|
||||||
|
|
||||||
|
For a new page, save it once before uploading attachments. This is intentional: uploads always belong to an existing page directory.
|
||||||
|
|
||||||
|
Uploads are limited to 50 MiB per request in this first setup.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
There is no separate application Markdown renderer. EasyMDE's `markdown(...)` method is the single rendering path for the live editor preview, normal reader view and rendering a selected historical version.
|
||||||
|
|
||||||
|
That method uses EasyMDE's bundled Marked renderer and the same rendering configuration, including syntax highlighting and DOMPurify sanitization. This avoids subtle differences between what an editor previews and what a reader sees after saving.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Every create or save writes a new immutable row to `page_versions` in the same PostgreSQL transaction as the current page update. Updates use optimistic locking: the browser sends the version it originally opened. If somebody else saved the page in the meantime, the API returns HTTP 409 instead of silently overwriting their version.
|
||||||
|
|
||||||
|
The History view can render an old version or compare two consecutive versions. The browser creates a unified textual diff and diff2html renders it side by side.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
Markdown rendering is sanitized with DOMPurify before it is inserted into the page. This also means raw HTML embedded in Markdown is only retained to the extent that DOMPurify permits it.
|
||||||
|
|
||||||
|
Session cookies are HttpOnly and SameSite=Strict. Authenticated write operations also require a per-session CSRF token.
|
||||||
|
|
||||||
|
Untrusted uploads are not blindly rendered inline. Only common raster image formats are served inline. Other files use `Content-Disposition: attachment`; SVG is therefore not treated as an inline image.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Version 0.2.7
|
||||||
|
|
||||||
|
Version 0.2.7 stores all wiki content in PostgreSQL: users, sessions, pages, immutable page versions and binary attachments. Page writes and version snapshots are transactional. PostgreSQL full-text search with a weighted `tsvector` and GIN index is exposed through a real wiki search field. Database schema changes are handled by ordered migrations recorded in `wiki_schema`.
|
||||||
|
|
||||||
|
The same release also makes the page-local contents list the primary sidebar navigation, adds a DokuWiki-style breadcrumb, adopts restrained Racket/Scribble-inspired document typography, installs Font Awesome for the EasyMDE toolbar, and makes source-editor heading sizes match the rendered heading scale.
|
||||||
|
|
||||||
|
## Page contents and editor appearance
|
||||||
|
|
||||||
|
The sidebar shows a page-local table of contents before the global page list. The
|
||||||
|
table of contents is derived from Markdown headings. In the editor it updates
|
||||||
|
while the Markdown source changes; selecting an entry moves the editor cursor to
|
||||||
|
that heading. In page view, selecting an entry scrolls to the rendered heading.
|
||||||
|
|
||||||
|
The wiki stylesheet is intentionally document-oriented and takes visual cues
|
||||||
|
from Racket's Scribble/manual help pages without depending on Scribble's
|
||||||
|
internal CSS classes. EasyMDE preview and the normal rendered page share the
|
||||||
|
same heading and body sizes. CodeMirror heading tokens use the same heading
|
||||||
|
scale, so a Markdown heading is not larger in the source editor than in the
|
||||||
|
rendered preview.
|
||||||
|
|
||||||
|
Font Awesome 4.7 is installed locally by the web setup together with EasyMDE
|
||||||
|
and the other browser dependencies. No CDN access is needed after setup.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.6 setup and missing-page behaviour
|
||||||
|
|
||||||
|
When PostgreSQL explicitly requests cleartext `password` authentication, the setup page now explains that `scram-sha-256` is preferred and reminds the administrator that PostgreSQL uses the first matching `pg_hba.conf` rule. A failed setup POST preserves server, port, database, user, SSL mode, administrator username and display name. Password fields are deliberately cleared.
|
||||||
|
|
||||||
|
For editors and administrators, a missing wiki page is now shown as an empty page target with one `Edit` action. Editing that target creates the page on first save. The separate global `New page` action is hidden while such a missing target is open, avoiding simultaneous create/edit choices for the same page.
|
||||||
|
|
||||||
|
## Database schema migrations
|
||||||
|
|
||||||
|
The PostgreSQL schema has its own version history in table `wiki_schema`. Each applied schema version is recorded with `applied_at`. On startup racket-wiki detects the existing schema and applies every missing migration in order before serving the normal application. A database whose schema is newer than the running racket-wiki version is refused rather than silently downgraded.
|
||||||
|
|
||||||
|
Schema 1 is the PostgreSQL layout introduced by 0.2.5/0.2.6: pages and page versions are in PostgreSQL while attachment bytes are on disk. Schema 2, introduced by 0.2.7, adds `attachments.mime_type` and `attachments.content BYTEA`. During migration 1 -> 2, existing files below `wiki-data/uploads/<slug>/` are copied into the matching attachment rows. The migration runs transactionally and is only recorded as version 2 when every attachment can be migrated. Legacy upload files are deliberately left on disk after a successful migration as a safety copy; 0.2.7 no longer reads them at runtime. They may be removed after the migrated attachments have been verified.
|
||||||
|
|
||||||
|
The application libraries downloaded by setup and the local database connection settings remain in the data directory; they are installation/configuration data rather than wiki content.
|
||||||
|
|
||||||
|
## Version 0.2.9
|
||||||
|
|
||||||
|
Version 0.2.9 fixes the Racket regexp used by the server-side `todo(...)` extractor. The extractor processes one Markdown source line at a time, so the regexp now excludes only parentheses; it no longer contains the invalid alphabetic regexp escapes for carriage return and newline.
|
||||||
|
|
||||||
|
## Version 0.2.8
|
||||||
|
|
||||||
|
Version 0.2.8 adds wiki-wide todo markers, offline detection and translatable frontend labels.
|
||||||
|
|
||||||
|
A todo marker is written directly in Markdown as `todo(text)`. Markers inside fenced code blocks are ignored. On every page save the current markers are indexed in PostgreSQL. The **Todo** view gathers the unresolved markers from all active pages and links each item back to its source page. Standard Markdown task lists such as `- [ ] item` remain available for ordinary page-local checklists.
|
||||||
|
|
||||||
|
The browser sends a lightweight request to `/api/ping` every 15 seconds. A failed or timed-out request shows an **Offline** indicator. When the site becomes reachable again the indicator briefly reports **Online** and then disappears.
|
||||||
|
|
||||||
|
The old **New page** button has been removed. New pages are created by following or entering a wiki address that does not exist yet and choosing **Edit**.
|
||||||
|
|
||||||
|
UI text is routed through `private/translate.rkt`. The server can be started with a language, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
racket main.rkt --data ./wiki-data --language nl
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in English and Dutch translations are supplied. Administrators also get a **Translations** action. It opens the special page `wiki-translations-<language>`, for example `wiki-translations-nl`. Translation overrides use one `key = value` assignment per line. Saving that page is enough; the overrides are loaded from PostgreSQL on the next frontend load. Setup and login have built-in translations and therefore do not depend on that special page.
|
||||||
|
|
||||||
|
Database schema 3 adds `todo_items`. Migration 2 -> 3 creates the table and indexes todo markers from all existing, non-archived pages. The migration is recorded in `wiki_schema` just like earlier schema changes.
|
||||||
|
|
||||||
|
The web setup also contains a UI-language selector. The selected language is stored in `wiki-data/language.rktd`; the command-line `--language` value is the default when that file does not yet exist.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#lang info
|
||||||
|
|
||||||
|
(define pkg-authors '(hnmdijkema))
|
||||||
|
(define version "0.2.10")
|
||||||
|
(define license 'MIT)
|
||||||
|
(define collection "racket-wiki")
|
||||||
|
(define pkg-desc
|
||||||
|
"A small self-hosted Racket Wiki using Markdown and PostgreSQL.")
|
||||||
|
|
||||||
|
(define scribblings
|
||||||
|
'(("scrbl/racket-wiki.scrbl" () (library 0))))
|
||||||
|
|
||||||
|
(define deps
|
||||||
|
'("base"
|
||||||
|
"crypto-lib"
|
||||||
|
"db-lib"
|
||||||
|
"net-lib"
|
||||||
|
"net-cookies-lib"
|
||||||
|
"web-server-lib"))
|
||||||
|
|
||||||
|
(define build-deps
|
||||||
|
'("racket-doc"
|
||||||
|
"rackunit-lib"
|
||||||
|
"scribble-lib"))
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/cmdline
|
||||||
|
racket/path
|
||||||
|
"private/auth.rkt"
|
||||||
|
"private/config.rkt"
|
||||||
|
"private/database.rkt"
|
||||||
|
"private/storage.rkt"
|
||||||
|
"server.rkt")
|
||||||
|
|
||||||
|
(provide start
|
||||||
|
start-wiki)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Start the wiki using explicit server parameters.
|
||||||
|
; pre : data-dir, port, listen-ip and the remaining values are valid wiki
|
||||||
|
; configuration values.
|
||||||
|
; post : Wiki storage has been initialized and PostgreSQL is prepared when configured and the
|
||||||
|
; HTTP server has been started.
|
||||||
|
; result : The result returned by start-wiki-server.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (start #:data-dir [data-dir "wiki-data"]
|
||||||
|
#:port [port 8080]
|
||||||
|
#:listen-ip [listen-ip "127.0.0.1"]
|
||||||
|
#:secure-cookie? [secure-cookie? #f]
|
||||||
|
#:site-title [site-title "Racket Wiki"]
|
||||||
|
#:session-seconds [session-seconds (* 12 60 60)]
|
||||||
|
#:language [language "en"])
|
||||||
|
(define config
|
||||||
|
(make-wiki-config #:data-dir data-dir
|
||||||
|
#:port port
|
||||||
|
#:listen-ip listen-ip
|
||||||
|
#:secure-cookie? secure-cookie?
|
||||||
|
#:site-title site-title
|
||||||
|
#:session-seconds session-seconds
|
||||||
|
#:language language))
|
||||||
|
(start-wiki config))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Start the wiki using a configuration.
|
||||||
|
; pre : config is a wiki-config value.
|
||||||
|
; post : Wiki storage has been initialized and PostgreSQL is prepared when configured and the
|
||||||
|
; HTTP server has been started.
|
||||||
|
; result : The result returned by start-wiki-server.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (start-wiki [config (default-wiki-config)])
|
||||||
|
(ensure-wiki-data! config)
|
||||||
|
(when (database-settings-exist? config)
|
||||||
|
(initialize-database! config))
|
||||||
|
(start-wiki-server config))
|
||||||
|
|
||||||
|
(module+ main
|
||||||
|
(define config (default-wiki-config))
|
||||||
|
(define create-admin-user #f)
|
||||||
|
(define create-admin-password #f)
|
||||||
|
|
||||||
|
(command-line
|
||||||
|
#:program "racket-wiki"
|
||||||
|
#:once-each
|
||||||
|
(("--data") directory
|
||||||
|
"Wiki data directory"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(data-dir (path->complete-path directory)))))
|
||||||
|
(("--port") port
|
||||||
|
"HTTP port"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(port (string->number port)))))
|
||||||
|
(("--listen") listen-ip
|
||||||
|
"Listen IP; use * for all interfaces"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(listen-ip (if (string=? listen-ip "*") #f listen-ip)))))
|
||||||
|
(("--title") title
|
||||||
|
"Site title"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(site-title title))))
|
||||||
|
(("--language") language
|
||||||
|
"UI language (for example en or nl)"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(language language))))
|
||||||
|
(("--secure-cookie")
|
||||||
|
"Mark the session cookie Secure (enable behind HTTPS)"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(secure-cookie? #t))))
|
||||||
|
(("--create-admin") username password
|
||||||
|
"Create or reset an administrator account"
|
||||||
|
(set! create-admin-user username)
|
||||||
|
(set! create-admin-password password)))
|
||||||
|
|
||||||
|
(ensure-wiki-data! config)
|
||||||
|
(when (database-settings-exist? config)
|
||||||
|
(initialize-database! config))
|
||||||
|
|
||||||
|
(cond
|
||||||
|
(create-admin-user
|
||||||
|
(unless (database-ready? config)
|
||||||
|
(error 'racket-wiki "PostgreSQL must be configured through /setup before --create-admin can be used"))
|
||||||
|
(upsert-user! config
|
||||||
|
create-admin-user
|
||||||
|
create-admin-user
|
||||||
|
create-admin-password
|
||||||
|
'admin
|
||||||
|
'enabled)
|
||||||
|
(displayln (format "Administrator '~a' is ready." create-admin-user)))
|
||||||
|
(else
|
||||||
|
(start-wiki-server config))))
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require crypto
|
||||||
|
crypto/libcrypto
|
||||||
|
db
|
||||||
|
racket/list
|
||||||
|
racket/string
|
||||||
|
web-server/http/cookie-parse
|
||||||
|
"config.rkt"
|
||||||
|
"database.rkt")
|
||||||
|
|
||||||
|
(provide (struct-out wiki-user)
|
||||||
|
(struct-out wiki-session)
|
||||||
|
authenticate-user
|
||||||
|
create-session!
|
||||||
|
delete-session!
|
||||||
|
session-from-request
|
||||||
|
csrf-valid?
|
||||||
|
role-at-least?
|
||||||
|
list-users
|
||||||
|
administrator-exists?
|
||||||
|
create-user!
|
||||||
|
upsert-user!
|
||||||
|
update-user!
|
||||||
|
delete-user!)
|
||||||
|
|
||||||
|
(struct wiki-user (id username display-name role enabled?) #:transparent)
|
||||||
|
(struct wiki-session (user csrf-token expires-at token) #:transparent)
|
||||||
|
|
||||||
|
(crypto-factories (list libcrypto-factory))
|
||||||
|
|
||||||
|
(define role-order
|
||||||
|
(hash 'reader 10
|
||||||
|
'editor 20
|
||||||
|
'admin 30))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Check whether a user has a required role level.
|
||||||
|
; pre : required-role is reader, editor or admin; user is a wiki-user or #f.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : #t when the user has the required role level, otherwise #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (role-at-least? user required-role)
|
||||||
|
(and user
|
||||||
|
(>= (hash-ref role-order (wiki-user-role user) 0)
|
||||||
|
(hash-ref role-order required-role 100))))
|
||||||
|
|
||||||
|
(define (bytes->hex value)
|
||||||
|
(apply string-append
|
||||||
|
(for/list ([byte (in-bytes value)])
|
||||||
|
(define hex (number->string byte 16))
|
||||||
|
(if (= (string-length hex) 1)
|
||||||
|
(string-append "0" hex)
|
||||||
|
hex))))
|
||||||
|
|
||||||
|
(define (random-token [size 32])
|
||||||
|
(bytes->hex (crypto-random-bytes size)))
|
||||||
|
|
||||||
|
(define (token-hash token)
|
||||||
|
(bytes->hex (digest 'sha256 (string->bytes/utf-8 token))))
|
||||||
|
|
||||||
|
(define (password-hash password)
|
||||||
|
(pwhash '(pbkdf2 hmac sha256)
|
||||||
|
(string->bytes/utf-8 password)
|
||||||
|
'((iterations 600000))))
|
||||||
|
|
||||||
|
(define (password-valid? password stored-hash)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) #f)))
|
||||||
|
(pwhash-verify #f (string->bytes/utf-8 password) stored-hash)))
|
||||||
|
|
||||||
|
(define (row->user row)
|
||||||
|
(wiki-user (vector-ref row 0)
|
||||||
|
(vector-ref row 1)
|
||||||
|
(vector-ref row 2)
|
||||||
|
(string->symbol (vector-ref row 3))
|
||||||
|
(vector-ref row 4)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Authenticate an enabled wiki user.
|
||||||
|
; pre : username and password are strings.
|
||||||
|
; post : The user database has only been read.
|
||||||
|
; result : A wiki-user on success, otherwise #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (authenticate-user config username password)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define row
|
||||||
|
(query-maybe-row
|
||||||
|
db
|
||||||
|
"SELECT id, username, display_name, 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)
|
||||||
|
(else
|
||||||
|
(wiki-user (vector-ref row 0)
|
||||||
|
(vector-ref row 1)
|
||||||
|
(vector-ref row 2)
|
||||||
|
(string->symbol (vector-ref row 3))
|
||||||
|
#t))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a login session for a user.
|
||||||
|
; pre : user is an enabled wiki-user.
|
||||||
|
; post : A hashed session token and CSRF token have been stored in PostgreSQL.
|
||||||
|
; result : A wiki-session containing the client session token.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (create-session! config user)
|
||||||
|
(define token (random-token))
|
||||||
|
(define csrf-token (random-token 24))
|
||||||
|
(define now (current-seconds))
|
||||||
|
(define expires-at (+ now (wiki-config-session-seconds config)))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-exec db
|
||||||
|
"INSERT INTO sessions(token_hash, user_id, csrf_token, created_at, expires_at) VALUES ($1, $2, $3, $4, $5)"
|
||||||
|
(token-hash token)
|
||||||
|
(wiki-user-id user)
|
||||||
|
csrf-token
|
||||||
|
now
|
||||||
|
expires-at)))
|
||||||
|
(wiki-session user csrf-token expires-at token))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Delete a login session.
|
||||||
|
; pre : token is a session token string or #f.
|
||||||
|
; post : The matching stored session has been deleted when token was supplied.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (delete-session! config token)
|
||||||
|
(when token
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-exec db
|
||||||
|
"DELETE FROM sessions WHERE token_hash = $1"
|
||||||
|
(token-hash token))))))
|
||||||
|
|
||||||
|
(define (session-cookie-token req)
|
||||||
|
(define cookie
|
||||||
|
(findf (λ (candidate)
|
||||||
|
(string=? (client-cookie-name candidate) "racket-wiki-session"))
|
||||||
|
(request-cookies req)))
|
||||||
|
(if cookie
|
||||||
|
(client-cookie-value cookie)
|
||||||
|
#f))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Resolve an authenticated session from a request cookie.
|
||||||
|
; pre : req is a web-server request.
|
||||||
|
; post : The user and session tables have only been read.
|
||||||
|
; result : A non-expired wiki-session for an enabled user, or #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (session-from-request config req)
|
||||||
|
(define token (session-cookie-token req))
|
||||||
|
(if token
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define row
|
||||||
|
(query-maybe-row
|
||||||
|
db
|
||||||
|
#<<SQL
|
||||||
|
SELECT u.id, u.username, u.display_name, u.role, u.enabled,
|
||||||
|
s.csrf_token, s.expires_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = $1 AND s.expires_at > $2 AND u.enabled = TRUE
|
||||||
|
SQL
|
||||||
|
(token-hash token)
|
||||||
|
(current-seconds)))
|
||||||
|
(if row
|
||||||
|
(wiki-session
|
||||||
|
(wiki-user (vector-ref row 0)
|
||||||
|
(vector-ref row 1)
|
||||||
|
(vector-ref row 2)
|
||||||
|
(string->symbol (vector-ref row 3))
|
||||||
|
(vector-ref row 4))
|
||||||
|
(vector-ref row 5)
|
||||||
|
(vector-ref row 6)
|
||||||
|
token)
|
||||||
|
#f)))
|
||||||
|
#f))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Validate a CSRF token for a session.
|
||||||
|
; pre : session is a wiki-session or #f and csrf-token is a string or #f.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : #t when the supplied token belongs to the session, otherwise #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (csrf-valid? session csrf-token)
|
||||||
|
(and session
|
||||||
|
csrf-token
|
||||||
|
(string=? csrf-token (wiki-session-csrf-token session))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : List wiki users.
|
||||||
|
; pre : The user database is initialized.
|
||||||
|
; post : The user table has only been read.
|
||||||
|
; result : A username-sorted list of wiki-user values.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (list-users config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(for/list ([row (in-list (query-rows db
|
||||||
|
"SELECT id, username, display_name, role, enabled FROM users ORDER BY username"))])
|
||||||
|
(row->user row)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Check whether at least one enabled administrator exists.
|
||||||
|
; pre : The user database is initialized.
|
||||||
|
; post : The user table has only been read.
|
||||||
|
; result : #t when an enabled administrator exists, otherwise #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (administrator-exists? config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(> (query-value db
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = TRUE")
|
||||||
|
0))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a wiki user.
|
||||||
|
; pre : username is unused and role/status are supported symbols.
|
||||||
|
; post : The new user and password hash have been stored.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (create-user! config username display-name password role status)
|
||||||
|
(define now (current-seconds))
|
||||||
|
(define enabled (eq? status 'enabled))
|
||||||
|
(define hash (password-hash password))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (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)"
|
||||||
|
username
|
||||||
|
display-name
|
||||||
|
hash
|
||||||
|
(symbol->string role)
|
||||||
|
enabled
|
||||||
|
now
|
||||||
|
now))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create or reset a wiki user by username.
|
||||||
|
; pre : role/status are supported symbols.
|
||||||
|
; post : The named user exists with the supplied display name, password, role and status.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (upsert-user! config username display-name password role status)
|
||||||
|
(define now (current-seconds))
|
||||||
|
(define enabled (eq? status 'enabled))
|
||||||
|
(define hash (password-hash password))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-exec
|
||||||
|
db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO users(username, display_name, password_hash, role, enabled, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
role = excluded.role,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
SQL
|
||||||
|
username display-name hash (symbol->string role) enabled now now))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Update a wiki user.
|
||||||
|
; pre : id identifies a user and role/status are supported symbols.
|
||||||
|
; 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 enabled (eq? status 'enabled))
|
||||||
|
(define now (current-seconds))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (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"
|
||||||
|
display-name
|
||||||
|
(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"
|
||||||
|
display-name
|
||||||
|
(symbol->string role)
|
||||||
|
enabled
|
||||||
|
now
|
||||||
|
id)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Delete a wiki user.
|
||||||
|
; pre : id identifies a possible user.
|
||||||
|
; post : The user and cascading sessions have been deleted when present.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (delete-user! config id)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-exec db "DELETE FROM users WHERE id = $1" id))))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/path
|
||||||
|
racket/runtime-path)
|
||||||
|
|
||||||
|
(provide (struct-out wiki-config)
|
||||||
|
make-wiki-config
|
||||||
|
default-wiki-config
|
||||||
|
static-directory
|
||||||
|
uploads-directory
|
||||||
|
deleted-directory
|
||||||
|
data-static-directory
|
||||||
|
vendor-directory
|
||||||
|
database-config-path
|
||||||
|
language-config-path)
|
||||||
|
|
||||||
|
(struct wiki-config (data-dir
|
||||||
|
port
|
||||||
|
listen-ip
|
||||||
|
secure-cookie?
|
||||||
|
site-title
|
||||||
|
session-seconds
|
||||||
|
language)
|
||||||
|
#:transparent)
|
||||||
|
|
||||||
|
(define-runtime-path static-directory "../static")
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a wiki configuration.
|
||||||
|
; pre : data-dir is a path string, port is a port number and listen-ip is
|
||||||
|
; an IP address string, "*" or #f.
|
||||||
|
; post : No files or settings have been changed.
|
||||||
|
; result : A wiki-config value with a complete data directory path.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (make-wiki-config #:data-dir [data-dir "wiki-data"]
|
||||||
|
#:port [port 8080]
|
||||||
|
#:listen-ip [listen-ip "127.0.0.1"]
|
||||||
|
#:secure-cookie? [secure-cookie? #f]
|
||||||
|
#:site-title [site-title "Racket Wiki"]
|
||||||
|
#:session-seconds [session-seconds (* 12 60 60)]
|
||||||
|
#:language [language "en"])
|
||||||
|
(define normalized-listen-ip
|
||||||
|
(if (equal? listen-ip "*")
|
||||||
|
#f
|
||||||
|
listen-ip))
|
||||||
|
(wiki-config (path->complete-path data-dir)
|
||||||
|
port
|
||||||
|
normalized-listen-ip
|
||||||
|
secure-cookie?
|
||||||
|
site-title
|
||||||
|
session-seconds
|
||||||
|
language))
|
||||||
|
|
||||||
|
(define (default-wiki-config)
|
||||||
|
(make-wiki-config))
|
||||||
|
|
||||||
|
(define (uploads-directory config)
|
||||||
|
(build-path (wiki-config-data-dir config) "uploads"))
|
||||||
|
|
||||||
|
(define (deleted-directory config)
|
||||||
|
(build-path (wiki-config-data-dir config) "deleted"))
|
||||||
|
|
||||||
|
(define (data-static-directory config)
|
||||||
|
(build-path (wiki-config-data-dir config) "static"))
|
||||||
|
|
||||||
|
(define (vendor-directory config)
|
||||||
|
(build-path (data-static-directory config) "vendor"))
|
||||||
|
|
||||||
|
(define (database-config-path config)
|
||||||
|
(build-path (wiki-config-data-dir config) "database.rktd"))
|
||||||
|
|
||||||
|
(define (language-config-path config)
|
||||||
|
(build-path (wiki-config-data-dir config) "language.rktd"))
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require db
|
||||||
|
racket/file
|
||||||
|
racket/port
|
||||||
|
"config.rkt"
|
||||||
|
"migrations.rkt")
|
||||||
|
|
||||||
|
(provide (struct-out database-settings)
|
||||||
|
database-settings-exist?
|
||||||
|
read-database-settings
|
||||||
|
write-database-settings!
|
||||||
|
test-database-settings!
|
||||||
|
call-with-wiki-database
|
||||||
|
initialize-database!
|
||||||
|
initialize-database-with-settings!
|
||||||
|
database-ready?)
|
||||||
|
|
||||||
|
(struct database-settings (server port database user password ssl) #:transparent)
|
||||||
|
|
||||||
|
(define (database-settings-exist? config)
|
||||||
|
(file-exists? (database-config-path config)))
|
||||||
|
|
||||||
|
(define (settings->datum settings)
|
||||||
|
(hash 'server (database-settings-server settings)
|
||||||
|
'port (database-settings-port settings)
|
||||||
|
'database (database-settings-database settings)
|
||||||
|
'user (database-settings-user settings)
|
||||||
|
'password (database-settings-password settings)
|
||||||
|
'ssl (database-settings-ssl settings)))
|
||||||
|
|
||||||
|
(define (datum->settings value)
|
||||||
|
(database-settings (hash-ref value 'server "localhost")
|
||||||
|
(hash-ref value 'port 5432)
|
||||||
|
(hash-ref value 'database "racket_wiki")
|
||||||
|
(hash-ref value 'user "")
|
||||||
|
(hash-ref value 'password "")
|
||||||
|
(hash-ref value 'ssl 'no)))
|
||||||
|
|
||||||
|
(define (read-database-settings config)
|
||||||
|
(and (database-settings-exist? config)
|
||||||
|
(call-with-input-file (database-config-path config)
|
||||||
|
(λ (in)
|
||||||
|
(datum->settings (read in))))))
|
||||||
|
|
||||||
|
(define (write-database-settings! config settings)
|
||||||
|
(make-directory* (wiki-config-data-dir config))
|
||||||
|
(call-with-output-file (database-config-path config)
|
||||||
|
(λ (out)
|
||||||
|
(write (settings->datum settings) out)
|
||||||
|
(newline out))
|
||||||
|
#:exists 'truncate/replace)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) (void))))
|
||||||
|
(file-or-directory-permissions (database-config-path config) #o600))
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (connect settings)
|
||||||
|
(define password
|
||||||
|
(if (string=? (database-settings-password settings) "")
|
||||||
|
#f
|
||||||
|
(database-settings-password settings)))
|
||||||
|
(postgresql-connect #:server (database-settings-server settings)
|
||||||
|
#:port (database-settings-port settings)
|
||||||
|
#:database (database-settings-database settings)
|
||||||
|
#:user (database-settings-user settings)
|
||||||
|
#:password password
|
||||||
|
#:ssl (database-settings-ssl settings)))
|
||||||
|
|
||||||
|
(define (test-database-settings! settings)
|
||||||
|
(define db (connect settings))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ () (query-value db "SELECT 1"))
|
||||||
|
(λ () (disconnect db)))
|
||||||
|
(void))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Run a procedure with a fresh PostgreSQL connection.
|
||||||
|
; pre : PostgreSQL settings have been saved and proc accepts one connection.
|
||||||
|
; post : The connection is closed after proc returns or raises.
|
||||||
|
; result : The value returned by proc.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (call-with-wiki-database config proc)
|
||||||
|
(define settings (read-database-settings config))
|
||||||
|
(unless settings
|
||||||
|
(error 'call-with-wiki-database "PostgreSQL is not configured"))
|
||||||
|
(define db (connect settings))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ () (proc db))
|
||||||
|
(λ () (disconnect db))))
|
||||||
|
|
||||||
|
(define (initialize-on-connection! db config)
|
||||||
|
(migrate-database! db config)
|
||||||
|
(query-exec db "DELETE FROM sessions WHERE expires_at <= $1" (current-seconds))
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (initialize-database-with-settings! settings config)
|
||||||
|
(define db (connect settings))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ () (initialize-on-connection! db config))
|
||||||
|
(λ () (disconnect db))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Initialize the PostgreSQL schema used by racket-wiki.
|
||||||
|
; pre : Valid PostgreSQL settings have been saved.
|
||||||
|
; post : All wiki tables and indexes exist and expired sessions are removed.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (initialize-database! config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(initialize-on-connection! db config))))
|
||||||
|
|
||||||
|
(define (database-ready? config)
|
||||||
|
(and (database-settings-exist? config)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) #f)))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(and (query-value db "SELECT to_regclass('public.users') IS NOT NULL")
|
||||||
|
(query-value db "SELECT to_regclass('public.pages') IS NOT NULL")
|
||||||
|
(query-value db "SELECT to_regclass('public.page_versions') IS NOT NULL")
|
||||||
|
(query-value db "SELECT to_regclass('public.sessions') IS NOT NULL")))))))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require json
|
||||||
|
racket/string
|
||||||
|
web-server/http
|
||||||
|
web-server/http/json
|
||||||
|
web-server/http/xexpr)
|
||||||
|
|
||||||
|
(provide json-response
|
||||||
|
json-error
|
||||||
|
html-response
|
||||||
|
redirect-response
|
||||||
|
request-json
|
||||||
|
request-header/string
|
||||||
|
bytes-response
|
||||||
|
extension->mime)
|
||||||
|
|
||||||
|
(define security-headers
|
||||||
|
(list (make-header #"X-Content-Type-Options" #"nosniff")
|
||||||
|
(make-header #"Referrer-Policy" #"same-origin")
|
||||||
|
(make-header #"Content-Security-Policy"
|
||||||
|
#"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a JSON HTTP response with the standard security headers.
|
||||||
|
; pre : value is JSON encodable and headers contains HTTP headers.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : An HTTP response value.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (json-response value #:code [code 200] #:headers [headers '()])
|
||||||
|
(response/jsexpr value
|
||||||
|
#:code code
|
||||||
|
#:headers (append security-headers headers)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a JSON error response.
|
||||||
|
; pre : code is an HTTP status code and message is displayable JSON text.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : An HTTP JSON response containing ok = #f and the error message.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (json-error code message)
|
||||||
|
(json-response (hash 'ok #f 'error message) #:code code))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create an HTML response from an X-expression.
|
||||||
|
; pre : value is a valid X-expression and headers contains HTTP headers.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : An HTTP HTML response with the standard security headers.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (html-response value #:code [code 200] #:headers [headers '()])
|
||||||
|
(response/xexpr value
|
||||||
|
#:code code
|
||||||
|
#:headers (append security-headers headers)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a no-cache HTTP redirect response.
|
||||||
|
; pre : location is an absolute-path URL string for this server and headers
|
||||||
|
; contains optional response headers.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : An HTTP 303 response with a Location header and the supplied headers.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (redirect-response location #:headers [headers '()])
|
||||||
|
(response/full 303
|
||||||
|
#"See Other"
|
||||||
|
(current-seconds)
|
||||||
|
#"text/plain; charset=utf-8"
|
||||||
|
(append security-headers
|
||||||
|
headers
|
||||||
|
(list (make-header #"Location"
|
||||||
|
(string->bytes/utf-8 location))
|
||||||
|
(make-header #"Cache-Control" #"no-store")))
|
||||||
|
(list #"")))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Decode the JSON request body.
|
||||||
|
; pre : req is a web-server request whose body is empty or valid JSON.
|
||||||
|
; post : The request has only been inspected.
|
||||||
|
; result : The decoded JSON value, or an empty hash for an empty body.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (request-json req)
|
||||||
|
(define body (request-post-data/raw req))
|
||||||
|
(if body
|
||||||
|
(bytes->jsexpr body)
|
||||||
|
(hash)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Read a request header as UTF-8 text.
|
||||||
|
; pre : req is a web-server request and name is a header name string.
|
||||||
|
; post : The request has only been inspected.
|
||||||
|
; result : The header value as a string, or #f when absent.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (request-header/string req name)
|
||||||
|
(define found
|
||||||
|
(headers-assq* (string->bytes/utf-8 name)
|
||||||
|
(request-headers/raw req)))
|
||||||
|
(and found
|
||||||
|
(bytes->string/utf-8 (header-value found))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create an HTTP response containing bytes.
|
||||||
|
; pre : bytes is the response body, mime is a MIME byte string and headers contains HTTP headers.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : An HTTP response value.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (bytes-response bytes mime #:headers [headers '()])
|
||||||
|
(response/full 200
|
||||||
|
#f
|
||||||
|
(current-seconds)
|
||||||
|
mime
|
||||||
|
(append security-headers headers)
|
||||||
|
(list bytes)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Map a supported filename extension to a MIME type.
|
||||||
|
; pre : filename is a string.
|
||||||
|
; post : No external state has been changed.
|
||||||
|
; result : A MIME byte string; application/octet-stream when the extension is unknown.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (extension->mime filename)
|
||||||
|
(define lower (string-downcase filename))
|
||||||
|
(cond
|
||||||
|
((regexp-match? #px"[.]png$" lower) #"image/png")
|
||||||
|
((regexp-match? #px"[.](jpg|jpeg)$" lower) #"image/jpeg")
|
||||||
|
((regexp-match? #px"[.]gif$" lower) #"image/gif")
|
||||||
|
((regexp-match? #px"[.]webp$" lower) #"image/webp")
|
||||||
|
((regexp-match? #px"[.]pdf$" lower) #"application/pdf")
|
||||||
|
((regexp-match? #px"[.]txt$" lower) #"text/plain; charset=utf-8")
|
||||||
|
(else #"application/octet-stream")))
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require db
|
||||||
|
racket/file
|
||||||
|
racket/path
|
||||||
|
racket/string
|
||||||
|
"config.rkt"
|
||||||
|
"todo.rkt")
|
||||||
|
|
||||||
|
(provide current-schema-version
|
||||||
|
database-schema-version
|
||||||
|
migrate-database!)
|
||||||
|
|
||||||
|
(define current-schema-version 3)
|
||||||
|
|
||||||
|
(define schema-1-statements
|
||||||
|
(list
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('reader', 'editor', 'admin')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
"CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)"
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
markdown TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
current_version BIGINT NOT NULL DEFAULT 1,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
updated_by TEXT NOT NULL,
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
archived_at BIGINT,
|
||||||
|
archived_by TEXT,
|
||||||
|
search_document TSVECTOR NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
"CREATE INDEX IF NOT EXISTS pages_search_idx ON pages USING GIN(search_document)"
|
||||||
|
"CREATE INDEX IF NOT EXISTS pages_title_idx ON pages(lower(title))"
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS page_versions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
version BIGINT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
markdown TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
UNIQUE(page_id, version)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
"CREATE INDEX IF NOT EXISTS page_versions_page_idx ON page_versions(page_id, version DESC)"
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
size BIGINT NOT NULL,
|
||||||
|
uploaded_at BIGINT NOT NULL,
|
||||||
|
uploaded_by TEXT NOT NULL,
|
||||||
|
UNIQUE(page_id, stored_name)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
))
|
||||||
|
|
||||||
|
(define (table-exists? db name)
|
||||||
|
(if (query-maybe-value db "SELECT to_regclass($1) IS NOT NULL" (string-append "public." name))
|
||||||
|
#t
|
||||||
|
#f))
|
||||||
|
|
||||||
|
(define (ensure-schema-table! db)
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_schema (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
))
|
||||||
|
|
||||||
|
(define (database-schema-version db)
|
||||||
|
(if (table-exists? db "wiki_schema")
|
||||||
|
(query-value db "SELECT COALESCE(MAX(version), 0) FROM wiki_schema")
|
||||||
|
0))
|
||||||
|
|
||||||
|
(define (record-schema-version! db version)
|
||||||
|
(query-exec db
|
||||||
|
"INSERT INTO wiki_schema(version, applied_at) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING"
|
||||||
|
version
|
||||||
|
(current-seconds)))
|
||||||
|
|
||||||
|
(define (recognize-schema-1? db)
|
||||||
|
(and (table-exists? db "users")
|
||||||
|
(table-exists? db "sessions")
|
||||||
|
(table-exists? db "pages")
|
||||||
|
(table-exists? db "page_versions")
|
||||||
|
(table-exists? db "attachments")))
|
||||||
|
|
||||||
|
(define (install-schema-1! db)
|
||||||
|
(for ((statement (in-list schema-1-statements)))
|
||||||
|
(query-exec db statement))
|
||||||
|
(ensure-schema-table! db)
|
||||||
|
(record-schema-version! db 1))
|
||||||
|
|
||||||
|
(define (recognize-or-install-schema-1! db)
|
||||||
|
(cond
|
||||||
|
((table-exists? db "wiki_schema")
|
||||||
|
(void))
|
||||||
|
((recognize-schema-1? db)
|
||||||
|
(ensure-schema-table! db)
|
||||||
|
(record-schema-version! db 1))
|
||||||
|
(else
|
||||||
|
(install-schema-1! db))))
|
||||||
|
|
||||||
|
(define (legacy-mime-type stored-name)
|
||||||
|
(define lower (string-downcase stored-name))
|
||||||
|
(cond
|
||||||
|
((regexp-match? #px"[.]png$" lower) "image/png")
|
||||||
|
((regexp-match? #px"[.](jpg|jpeg)$" lower) "image/jpeg")
|
||||||
|
((regexp-match? #px"[.]gif$" lower) "image/gif")
|
||||||
|
((regexp-match? #px"[.]webp$" lower) "image/webp")
|
||||||
|
((regexp-match? #px"[.]pdf$" lower) "application/pdf")
|
||||||
|
((regexp-match? #px"[.]txt$" lower) "text/plain; charset=utf-8")
|
||||||
|
(else "application/octet-stream")))
|
||||||
|
|
||||||
|
(define (migrate-1->2! db config)
|
||||||
|
(query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS mime_type TEXT")
|
||||||
|
(query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS content BYTEA")
|
||||||
|
(define rows
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT a.id, p.slug, a.stored_name, a.content
|
||||||
|
FROM attachments a
|
||||||
|
JOIN pages p ON p.id = a.page_id
|
||||||
|
ORDER BY a.id
|
||||||
|
SQL
|
||||||
|
))
|
||||||
|
(for ((row (in-list rows)))
|
||||||
|
(define attachment-id (vector-ref row 0))
|
||||||
|
(define slug (vector-ref row 1))
|
||||||
|
(define stored-name (vector-ref row 2))
|
||||||
|
(define content (vector-ref row 3))
|
||||||
|
(unless (bytes? content)
|
||||||
|
(define path (build-path (uploads-directory config) slug stored-name))
|
||||||
|
(unless (file-exists? path)
|
||||||
|
(error 'migrate-database!
|
||||||
|
"schema 1 -> 2 cannot migrate attachment ~a: missing file ~a"
|
||||||
|
stored-name
|
||||||
|
(path->string path)))
|
||||||
|
(define bytes (file->bytes path))
|
||||||
|
(query-exec db
|
||||||
|
"UPDATE attachments SET content = $1, mime_type = $2, size = $3 WHERE id = $4"
|
||||||
|
bytes
|
||||||
|
(legacy-mime-type stored-name)
|
||||||
|
(bytes-length bytes)
|
||||||
|
attachment-id)))
|
||||||
|
(query-exec db "UPDATE attachments SET mime_type = 'application/octet-stream' WHERE mime_type IS NULL")
|
||||||
|
(query-exec db "ALTER TABLE attachments ALTER COLUMN mime_type SET NOT NULL")
|
||||||
|
(query-exec db "ALTER TABLE attachments ALTER COLUMN content SET NOT NULL")
|
||||||
|
(record-schema-version! db 2))
|
||||||
|
|
||||||
|
|
||||||
|
(define (replace-page-todos! db page-id markdown)
|
||||||
|
(query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id)
|
||||||
|
(for ((item (in-list (extract-todos markdown))))
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO todo_items(page_id, item_number, line_number, text)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
SQL
|
||||||
|
page-id
|
||||||
|
(hash-ref item 'number)
|
||||||
|
(hash-ref item 'line)
|
||||||
|
(hash-ref item 'text))))
|
||||||
|
|
||||||
|
(define (migrate-2->3! db)
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS todo_items (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
item_number INTEGER NOT NULL,
|
||||||
|
line_number INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
UNIQUE(page_id, item_number)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
)
|
||||||
|
(query-exec db "CREATE INDEX IF NOT EXISTS todo_items_page_idx ON todo_items(page_id, item_number)")
|
||||||
|
(for ((row (in-list (query-rows db "SELECT id, markdown FROM pages WHERE archived = FALSE"))))
|
||||||
|
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
|
||||||
|
(record-schema-version! db 3))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
||||||
|
; pre : db is a writable PostgreSQL connection and config identifies the
|
||||||
|
; data directory used by older racket-wiki versions.
|
||||||
|
; post : Every required migration has been applied in order and recorded.
|
||||||
|
; result : The resulting schema version.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (migrate-database! db config)
|
||||||
|
(call-with-transaction
|
||||||
|
db
|
||||||
|
(λ ()
|
||||||
|
(recognize-or-install-schema-1! db)
|
||||||
|
(define version (database-schema-version db))
|
||||||
|
(when (< version 1)
|
||||||
|
(error 'migrate-database! "unable to determine the existing wiki database schema"))
|
||||||
|
(when (= version 1)
|
||||||
|
(migrate-1->2! db config))
|
||||||
|
(define after-attachments (database-schema-version db))
|
||||||
|
(when (= after-attachments 2)
|
||||||
|
(migrate-2->3! db))
|
||||||
|
(define resulting-version (database-schema-version db))
|
||||||
|
(when (> resulting-version current-schema-version)
|
||||||
|
(error 'migrate-database!
|
||||||
|
"database schema ~a is newer than this racket-wiki supports (~a)"
|
||||||
|
resulting-version
|
||||||
|
current-schema-version))
|
||||||
|
resulting-version)))
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require crypto
|
||||||
|
net/uri-codec
|
||||||
|
racket/path
|
||||||
|
racket/string
|
||||||
|
web-server/http
|
||||||
|
"auth.rkt"
|
||||||
|
"config.rkt"
|
||||||
|
"database.rkt"
|
||||||
|
"http-util.rkt"
|
||||||
|
"vendor.rkt"
|
||||||
|
"../translate.rkt")
|
||||||
|
|
||||||
|
(provide setup-complete?
|
||||||
|
setup-handler)
|
||||||
|
|
||||||
|
(define setup-lock (make-semaphore 1))
|
||||||
|
|
||||||
|
(define (bytes->hex value)
|
||||||
|
(apply string-append
|
||||||
|
(for/list ((byte (in-bytes value)))
|
||||||
|
(define hex (number->string byte 16))
|
||||||
|
(if (= (string-length hex) 1)
|
||||||
|
(string-append "0" hex)
|
||||||
|
hex))))
|
||||||
|
|
||||||
|
(define setup-form-token
|
||||||
|
(bytes->hex (crypto-random-bytes 32)))
|
||||||
|
|
||||||
|
(define (admin-ready? config)
|
||||||
|
(and (database-ready? config)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) #f)))
|
||||||
|
(administrator-exists? config))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Check whether the wiki has everything required for normal use.
|
||||||
|
; pre : The data directory is accessible.
|
||||||
|
; post : PostgreSQL configuration/schema and vendor files have only been inspected.
|
||||||
|
; result : #t when PostgreSQL, an administrator and browser libraries are ready.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (setup-complete? config)
|
||||||
|
(and (database-ready? config)
|
||||||
|
(admin-ready? config)
|
||||||
|
(vendor-files-ready? config)))
|
||||||
|
|
||||||
|
(define (request-form req)
|
||||||
|
(define body (request-post-data/raw req))
|
||||||
|
(if body
|
||||||
|
(form-urlencoded->alist (bytes->string/utf-8 body))
|
||||||
|
'()))
|
||||||
|
|
||||||
|
(define (form-value form key [default ""])
|
||||||
|
(define found (assoc key form))
|
||||||
|
(if (and found (cdr found))
|
||||||
|
(cdr found)
|
||||||
|
default))
|
||||||
|
|
||||||
|
(define setup-style
|
||||||
|
#<<CSS
|
||||||
|
html { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #f4f6f8; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 32px; }
|
||||||
|
.setup { width: min(700px, 100%); background: white; border: 1px solid #d7dde5; border-radius: 14px; padding: 34px; box-shadow: 0 12px 40px rgba(22, 32, 51, 0.08); }
|
||||||
|
h1 { margin: 0 0 10px; font-size: 2rem; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 1.15rem; }
|
||||||
|
p { line-height: 1.55; }
|
||||||
|
.status { margin: 24px 0; padding: 0; list-style: none; border-top: 1px solid #e3e7ed; }
|
||||||
|
.status li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid #e3e7ed; }
|
||||||
|
.ready { color: #286b3d; font-weight: 600; }
|
||||||
|
.pending { color: #8b5d00; font-weight: 600; }
|
||||||
|
.fields { display: grid; grid-template-columns: 1fr 140px; gap: 0 14px; }
|
||||||
|
.fields .wide { grid-column: 1 / -1; }
|
||||||
|
label { display: block; margin: 12px 0; font-weight: 600; }
|
||||||
|
input, select { display: block; width: 100%; margin-top: 6px; padding: 10px 12px; font: inherit; border: 1px solid #aeb7c4; border-radius: 6px; background: white; }
|
||||||
|
button { margin-top: 18px; padding: 10px 18px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
.error { margin: 18px 0; padding: 12px 14px; background: #fff1f1; border: 1px solid #e8b7b7; border-radius: 6px; color: #8b1f1f; white-space: pre-wrap; }
|
||||||
|
.note { color: #5b6472; font-size: 0.94rem; }
|
||||||
|
code { background: #f2f4f7; padding: 2px 5px; border-radius: 4px; }
|
||||||
|
@media (max-width: 620px) { .fields { grid-template-columns: 1fr; } .fields .wide { grid-column: auto; } }
|
||||||
|
CSS
|
||||||
|
)
|
||||||
|
|
||||||
|
(define (status-row label ready? ready-text pending-text)
|
||||||
|
`(li
|
||||||
|
(span ,label)
|
||||||
|
(span ((class ,(if ready? "ready" "pending")))
|
||||||
|
,(if ready? ready-text pending-text))))
|
||||||
|
|
||||||
|
(define (setting-value settings getter default)
|
||||||
|
(if settings (getter settings) default))
|
||||||
|
|
||||||
|
|
||||||
|
(define (language-field config [form '()])
|
||||||
|
(define language (form-value form 'language (current-language config)))
|
||||||
|
`((label
|
||||||
|
,(tr config 'language)
|
||||||
|
(select ((name "language"))
|
||||||
|
(option ((value "en") ,@(if (string-ci=? language "en") '((selected "selected")) '())) ,(tr config 'language-en))
|
||||||
|
(option ((value "nl") ,@(if (string-ci=? language "nl") '((selected "selected")) '())) ,(tr config 'language-nl))))))
|
||||||
|
|
||||||
|
(define (database-fields config [form '()])
|
||||||
|
(define settings (read-database-settings config))
|
||||||
|
(define ssl-text
|
||||||
|
(form-value form
|
||||||
|
'db-ssl
|
||||||
|
(symbol->string (setting-value settings database-settings-ssl 'no))))
|
||||||
|
(define ssl (string->symbol ssl-text))
|
||||||
|
`((h2 ,(tr config 'postgresql))
|
||||||
|
(div ((class "fields"))
|
||||||
|
(label
|
||||||
|
,(tr config 'server)
|
||||||
|
(input ((name "db-server")
|
||||||
|
(value ,(form-value form 'db-server (setting-value settings database-settings-server "localhost")))
|
||||||
|
(required "required"))))
|
||||||
|
(label
|
||||||
|
,(tr config 'port)
|
||||||
|
(input ((name "db-port")
|
||||||
|
(type "number")
|
||||||
|
(min "1")
|
||||||
|
(max "65535")
|
||||||
|
(value ,(form-value form 'db-port (number->string (setting-value settings database-settings-port 5432))))
|
||||||
|
(required "required"))))
|
||||||
|
(label ((class "wide"))
|
||||||
|
,(tr config 'database)
|
||||||
|
(input ((name "db-database")
|
||||||
|
(value ,(form-value form 'db-database (setting-value settings database-settings-database "racket_wiki")))
|
||||||
|
(required "required"))))
|
||||||
|
(label ((class "wide"))
|
||||||
|
,(tr config 'user)
|
||||||
|
(input ((name "db-user")
|
||||||
|
(autocomplete "username")
|
||||||
|
(value ,(form-value form 'db-user (setting-value settings database-settings-user "")))
|
||||||
|
(required "required"))))
|
||||||
|
(label ((class "wide"))
|
||||||
|
,(tr config 'password)
|
||||||
|
(input ((name "db-password")
|
||||||
|
(type "password")
|
||||||
|
(autocomplete "current-password")
|
||||||
|
(value ""))))
|
||||||
|
(label ((class "wide"))
|
||||||
|
,(tr config 'tls-ssl)
|
||||||
|
(select ((name "db-ssl"))
|
||||||
|
(option ((value "no") ,@(if (eq? ssl 'no) '((selected "selected")) '())) ,(tr config 'ssl-no))
|
||||||
|
(option ((value "optional") ,@(if (eq? ssl 'optional) '((selected "selected")) '())) ,(tr config 'ssl-optional))
|
||||||
|
(option ((value "yes") ,@(if (eq? ssl 'yes) '((selected "selected")) '())) ,(tr config 'ssl-required)))))))
|
||||||
|
|
||||||
|
(define (admin-fields config [form '()])
|
||||||
|
`((h2 ,(tr config 'administrator))
|
||||||
|
(label
|
||||||
|
,(tr config 'administrator-username)
|
||||||
|
(input ((name "username")
|
||||||
|
(autocomplete "username")
|
||||||
|
(value ,(form-value form 'username ""))
|
||||||
|
(required "required"))))
|
||||||
|
(label
|
||||||
|
,(tr config 'display-name)
|
||||||
|
(input ((name "display-name")
|
||||||
|
(autocomplete "name")
|
||||||
|
(value ,(form-value form 'display-name ""))
|
||||||
|
(placeholder "Optional; defaults to username"))))
|
||||||
|
(label
|
||||||
|
,(tr config 'administrator-password)
|
||||||
|
(input ((name "password")
|
||||||
|
(type "password")
|
||||||
|
(autocomplete "new-password")
|
||||||
|
(required "required"))))
|
||||||
|
(label
|
||||||
|
,(tr config 'repeat-password)
|
||||||
|
(input ((name "password-confirm")
|
||||||
|
(type "password")
|
||||||
|
(autocomplete "new-password")
|
||||||
|
(required "required"))))))
|
||||||
|
|
||||||
|
(define (setup-page config [message #f] [form '()])
|
||||||
|
(define db-ready? (database-ready? config))
|
||||||
|
(define administrator-ready? (admin-ready? config))
|
||||||
|
(define vendor-ready? (vendor-files-ready? config))
|
||||||
|
`(html
|
||||||
|
(head
|
||||||
|
(meta ((charset "utf-8")))
|
||||||
|
(meta ((name "viewport") (content "width=device-width, initial-scale=1")))
|
||||||
|
(title ,(string-append "Setup - " (wiki-config-site-title config)))
|
||||||
|
(style ,setup-style))
|
||||||
|
(body
|
||||||
|
(main ((class "setup"))
|
||||||
|
(h1 ,(tr config 'setup-title))
|
||||||
|
(p ,(tr config 'setup-description))
|
||||||
|
(ul ((class "status"))
|
||||||
|
,(status-row (tr config 'postgresql) db-ready? (tr config 'ready) (tr config 'required))
|
||||||
|
,(status-row (tr config 'administrator-account) administrator-ready? (tr config 'ready) (tr config 'required))
|
||||||
|
,(status-row (tr config 'frontend-libraries) vendor-ready? (tr config 'ready) (tr config 'will-download)))
|
||||||
|
,@(if message
|
||||||
|
`((div ((class "error")) ,message))
|
||||||
|
'())
|
||||||
|
(form ((method "post") (action "/setup"))
|
||||||
|
(input ((type "hidden") (name "setup-token") (value ,setup-form-token)))
|
||||||
|
,@(language-field config form)
|
||||||
|
,@(if db-ready? '() (database-fields config form))
|
||||||
|
,@(if administrator-ready? '() (admin-fields config form))
|
||||||
|
(button ((type "submit")) ,(tr config 'complete-setup)))
|
||||||
|
(p ((class "note"))
|
||||||
|
"PostgreSQL connection settings are stored in "
|
||||||
|
(code ,(path->string (database-config-path config)))
|
||||||
|
". Protect the wiki data directory as you would any other file containing database credentials.")
|
||||||
|
(p ((class "note"))
|
||||||
|
"Frontend libraries are stored below "
|
||||||
|
(code ,(path->string (vendor-directory config)))
|
||||||
|
" and are served locally after setup.")))))
|
||||||
|
|
||||||
|
(define (setup-page-response config [message #f] [form '()])
|
||||||
|
(html-response
|
||||||
|
(setup-page config message form)
|
||||||
|
#:headers (list (make-header #"Cache-Control" #"no-store"))))
|
||||||
|
|
||||||
|
(define (validate-admin-form form)
|
||||||
|
(define username (string-trim (form-value form 'username)))
|
||||||
|
(define display-name (string-trim (form-value form 'display-name)))
|
||||||
|
(define password (form-value form 'password))
|
||||||
|
(define password-confirm (form-value form 'password-confirm))
|
||||||
|
(cond
|
||||||
|
((string=? username "") "Administrator username is required.")
|
||||||
|
((string=? password "") "Administrator password is required.")
|
||||||
|
((< (string-length password) 8) "Administrator password must contain at least 8 characters.")
|
||||||
|
((not (string=? password password-confirm)) "The two passwords do not match.")
|
||||||
|
(else
|
||||||
|
(list username
|
||||||
|
(if (string=? display-name "") username display-name)
|
||||||
|
password))))
|
||||||
|
|
||||||
|
(define (form->database-settings form)
|
||||||
|
(define port (string->number (form-value form 'db-port "5432")))
|
||||||
|
(define ssl-text (form-value form 'db-ssl "no"))
|
||||||
|
(define ssl
|
||||||
|
(cond
|
||||||
|
((string=? ssl-text "yes") 'yes)
|
||||||
|
((string=? ssl-text "optional") 'optional)
|
||||||
|
(else 'no)))
|
||||||
|
(cond
|
||||||
|
((string=? (string-trim (form-value form 'db-server)) "") "PostgreSQL server is required.")
|
||||||
|
((or (not port) (not (exact-integer? port)) (< port 1) (> port 65535)) "PostgreSQL port is invalid.")
|
||||||
|
((string=? (string-trim (form-value form 'db-database)) "") "PostgreSQL database is required.")
|
||||||
|
((string=? (string-trim (form-value form 'db-user)) "") "PostgreSQL user is required.")
|
||||||
|
(else
|
||||||
|
(database-settings (string-trim (form-value form 'db-server))
|
||||||
|
port
|
||||||
|
(string-trim (form-value form 'db-database))
|
||||||
|
(string-trim (form-value form 'db-user))
|
||||||
|
(form-value form 'db-password)
|
||||||
|
ssl))))
|
||||||
|
|
||||||
|
|
||||||
|
(define (configure-language! config form)
|
||||||
|
(define language (string-downcase (form-value form 'language (current-language config))))
|
||||||
|
(unless (member language '("en" "nl"))
|
||||||
|
(error 'setup "Unsupported UI language: ~a" language))
|
||||||
|
(write-language! config language))
|
||||||
|
|
||||||
|
(define (configure-database! config form)
|
||||||
|
(unless (database-ready? config)
|
||||||
|
(define settings (form->database-settings form))
|
||||||
|
(when (string? settings)
|
||||||
|
(error 'setup settings))
|
||||||
|
(test-database-settings! settings)
|
||||||
|
(initialize-database-with-settings! settings config)
|
||||||
|
(write-database-settings! config settings)))
|
||||||
|
|
||||||
|
(define (configure-administrator! config form)
|
||||||
|
(unless (admin-ready? config)
|
||||||
|
(define admin-values (validate-admin-form form))
|
||||||
|
(when (string? admin-values)
|
||||||
|
(error 'setup admin-values))
|
||||||
|
(create-user! config
|
||||||
|
(list-ref admin-values 0)
|
||||||
|
(list-ref admin-values 1)
|
||||||
|
(list-ref admin-values 2)
|
||||||
|
'admin
|
||||||
|
'enabled)))
|
||||||
|
|
||||||
|
(define (cleartext-password-error? message)
|
||||||
|
(and (string? message)
|
||||||
|
(regexp-match? #rx"refusing to send cleartext password" message)))
|
||||||
|
|
||||||
|
(define (setup-error-message e)
|
||||||
|
(define message (exn-message e))
|
||||||
|
(if (cleartext-password-error? message)
|
||||||
|
(string-append
|
||||||
|
"PostgreSQL requests cleartext password authentication. "
|
||||||
|
"Configure the matching pg_hba.conf rule for this database/user to use "
|
||||||
|
"scram-sha-256 (recommended), then reload PostgreSQL and try again. "
|
||||||
|
"If more than one pg_hba.conf rule could match, remember that PostgreSQL "
|
||||||
|
"uses the first matching rule. "
|
||||||
|
"The entered non-password fields have been preserved below.\n\n"
|
||||||
|
"Technical detail: " message)
|
||||||
|
message))
|
||||||
|
|
||||||
|
(define (complete-setup! config req)
|
||||||
|
(call-with-semaphore
|
||||||
|
setup-lock
|
||||||
|
(λ ()
|
||||||
|
(if (setup-complete? config)
|
||||||
|
(redirect-response "/login")
|
||||||
|
(let ((form (request-form req)))
|
||||||
|
(cond
|
||||||
|
((not (string=? (form-value form 'setup-token) setup-form-token))
|
||||||
|
(setup-page-response
|
||||||
|
config
|
||||||
|
"Invalid setup form token. Reload the setup page and try again."
|
||||||
|
form))
|
||||||
|
(else
|
||||||
|
(with-handlers ((exn:fail?
|
||||||
|
(λ (e)
|
||||||
|
(setup-page-response
|
||||||
|
config
|
||||||
|
(setup-error-message e)
|
||||||
|
form))))
|
||||||
|
(configure-language! config form)
|
||||||
|
(configure-database! config form)
|
||||||
|
(configure-administrator! config form)
|
||||||
|
(download-vendor-files! config)
|
||||||
|
(redirect-response "/login")))))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Handle the web-based initial setup page.
|
||||||
|
; pre : req is a GET or POST request for /setup.
|
||||||
|
; post : POST can configure PostgreSQL, create the first administrator and
|
||||||
|
; download frontend libraries.
|
||||||
|
; result : An HTML setup response or a redirect to /login.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (setup-handler config req)
|
||||||
|
(cond
|
||||||
|
((setup-complete? config)
|
||||||
|
(redirect-response "/login"))
|
||||||
|
((string-ci=? (bytes->string/latin-1 (request-method req)) "POST")
|
||||||
|
(complete-setup! config req))
|
||||||
|
(else
|
||||||
|
(setup-page-response config))))
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require db
|
||||||
|
json
|
||||||
|
racket/file
|
||||||
|
racket/list
|
||||||
|
racket/path
|
||||||
|
racket/string
|
||||||
|
"config.rkt"
|
||||||
|
"database.rkt"
|
||||||
|
"todo.rkt")
|
||||||
|
|
||||||
|
(provide ensure-wiki-data!
|
||||||
|
valid-slug?
|
||||||
|
title->slug
|
||||||
|
list-pages
|
||||||
|
read-page
|
||||||
|
create-page!
|
||||||
|
update-page!
|
||||||
|
archive-page!
|
||||||
|
page-history
|
||||||
|
read-version
|
||||||
|
search-pages
|
||||||
|
list-todos
|
||||||
|
save-upload!
|
||||||
|
uploaded-file)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Ensure writable installation directories exist.
|
||||||
|
; pre : config is a wiki-config value and its data directory is writable.
|
||||||
|
; post : The data directory and writable static directory exist.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (ensure-wiki-data! config)
|
||||||
|
(for ((directory (in-list (list (wiki-config-data-dir config)
|
||||||
|
(data-static-directory config)))))
|
||||||
|
(make-directory* directory)))
|
||||||
|
|
||||||
|
(define (slug-alphanumeric? char)
|
||||||
|
(or (char-alphabetic? char)
|
||||||
|
(char-numeric? char)))
|
||||||
|
|
||||||
|
(define (combining-mark? char)
|
||||||
|
(if (member (char-general-category char) '(mn mc me))
|
||||||
|
#t
|
||||||
|
#f))
|
||||||
|
|
||||||
|
(define (valid-slug-character? char)
|
||||||
|
(or (slug-alphanumeric? char)
|
||||||
|
(char=? char #\.)
|
||||||
|
(char=? char #\_)
|
||||||
|
(char=? char #\-)))
|
||||||
|
|
||||||
|
(define (valid-slug? slug)
|
||||||
|
(and (> (string-length slug) 0)
|
||||||
|
(<= (string-length slug) 120)
|
||||||
|
(slug-alphanumeric? (string-ref slug 0))
|
||||||
|
(for/and ((char (in-string slug)))
|
||||||
|
(valid-slug-character? char))
|
||||||
|
(not (member slug '("." "..")))))
|
||||||
|
|
||||||
|
(define (title->slug title)
|
||||||
|
(define normalized
|
||||||
|
(string-downcase
|
||||||
|
(string-normalize-nfkd (string-trim title))))
|
||||||
|
(define out (open-output-string))
|
||||||
|
(define separator-needed? #f)
|
||||||
|
(define wrote-character? #f)
|
||||||
|
(for ((char (in-string normalized)))
|
||||||
|
(cond
|
||||||
|
((slug-alphanumeric? char)
|
||||||
|
(when (and separator-needed? wrote-character?)
|
||||||
|
(write-char #\- out))
|
||||||
|
(write-char char out)
|
||||||
|
(set! separator-needed? #f)
|
||||||
|
(set! wrote-character? #t))
|
||||||
|
((combining-mark? char)
|
||||||
|
(void))
|
||||||
|
(else
|
||||||
|
(set! separator-needed? #t))))
|
||||||
|
(define slug (get-output-string out))
|
||||||
|
(define limited
|
||||||
|
(if (> (string-length slug) 120)
|
||||||
|
(substring slug 0 120)
|
||||||
|
slug))
|
||||||
|
(regexp-replace #px"-+$" limited ""))
|
||||||
|
|
||||||
|
(define (tags->text tags)
|
||||||
|
(jsexpr->string tags))
|
||||||
|
|
||||||
|
(define (text->tags text)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) '())))
|
||||||
|
(define value (string->jsexpr text))
|
||||||
|
(if (list? value) value '())))
|
||||||
|
|
||||||
|
(define (row->page row [include-markdown? #t])
|
||||||
|
(define result
|
||||||
|
(hash 'slug (vector-ref row 0)
|
||||||
|
'title (vector-ref row 1)
|
||||||
|
'createdAt (vector-ref row 3)
|
||||||
|
'updatedAt (vector-ref row 4)
|
||||||
|
'createdBy (vector-ref row 5)
|
||||||
|
'updatedBy (vector-ref row 6)
|
||||||
|
'tags (text->tags (vector-ref row 7))
|
||||||
|
'currentVersion (vector-ref row 8)))
|
||||||
|
(if include-markdown?
|
||||||
|
(hash-set result 'markdown (vector-ref row 2))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(define page-columns
|
||||||
|
"slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version")
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : List current wiki page metadata.
|
||||||
|
; pre : The PostgreSQL schema is initialized.
|
||||||
|
; post : The pages table has only been read.
|
||||||
|
; result : A title-sorted list of page metadata hashes.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (list-pages config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(for/list ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
(string-append "SELECT " page-columns
|
||||||
|
" FROM pages WHERE archived = FALSE ORDER BY lower(title), title")))))
|
||||||
|
(row->page row #f)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Read the current form of one wiki page.
|
||||||
|
; pre : slug is a valid page slug.
|
||||||
|
; post : The pages table has only been read.
|
||||||
|
; result : Page metadata with Markdown, or #f when the page does not exist.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (read-page config slug)
|
||||||
|
(if (not (valid-slug? slug))
|
||||||
|
#f
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define row
|
||||||
|
(query-maybe-row db
|
||||||
|
(string-append "SELECT " page-columns
|
||||||
|
" FROM pages WHERE slug = $1 AND archived = FALSE")
|
||||||
|
slug))
|
||||||
|
(if row (row->page row) #f)))))
|
||||||
|
|
||||||
|
|
||||||
|
(define (replace-todos! db page-id markdown)
|
||||||
|
(query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id)
|
||||||
|
(for ((item (in-list (extract-todos markdown))))
|
||||||
|
(query-exec db
|
||||||
|
"INSERT INTO todo_items(page_id, item_number, line_number, text) VALUES ($1, $2, $3, $4)"
|
||||||
|
page-id
|
||||||
|
(hash-ref item 'number)
|
||||||
|
(hash-ref item 'line)
|
||||||
|
(hash-ref item 'text))))
|
||||||
|
|
||||||
|
(define (insert-version! db page-id version title markdown author action summary now tags)
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO page_versions(page_id, version, title, markdown, tags, author, action, summary, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
SQL
|
||||||
|
page-id version title markdown (tags->text tags) author action summary now))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Create a wiki page and its first version.
|
||||||
|
; pre : slug is valid and unused.
|
||||||
|
; post : Current page state and version 1 are committed atomically.
|
||||||
|
; result : The new page metadata with Markdown.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (create-page! config slug title markdown author [summary "Created page"] [tags '()])
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(call-with-transaction
|
||||||
|
db
|
||||||
|
(λ ()
|
||||||
|
(define now (current-seconds))
|
||||||
|
(define page-id
|
||||||
|
(query-value db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO pages(slug, title, markdown, tags, current_version,
|
||||||
|
created_at, updated_at, created_by, updated_by, search_document)
|
||||||
|
VALUES ($1, $2, $3, $4, 1, $5, $5, $6, $6,
|
||||||
|
setweight(to_tsvector('simple', coalesce($2, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce($3, '')), 'B'))
|
||||||
|
RETURNING id
|
||||||
|
SQL
|
||||||
|
slug title markdown (tags->text tags) now author))
|
||||||
|
(insert-version! db page-id 1 title markdown author "create" summary now tags)
|
||||||
|
(replace-todos! db page-id markdown)))))
|
||||||
|
(read-page config slug))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Save a new version of an existing wiki page.
|
||||||
|
; pre : The page exists and base-version equals its current version.
|
||||||
|
; post : Current page and version history are committed atomically.
|
||||||
|
; result : The updated page metadata with Markdown.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (update-page! config slug title markdown author base-version [summary "Edited page"] [tags #f])
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(call-with-transaction
|
||||||
|
db
|
||||||
|
(λ ()
|
||||||
|
(define row
|
||||||
|
(query-maybe-row db
|
||||||
|
"SELECT id, current_version, tags FROM pages WHERE slug = $1 AND archived = FALSE FOR UPDATE"
|
||||||
|
slug))
|
||||||
|
(unless row
|
||||||
|
(error 'update-page! "unknown page: ~a" slug))
|
||||||
|
(define current-version (vector-ref row 1))
|
||||||
|
(define supplied-version
|
||||||
|
(if (number? base-version)
|
||||||
|
base-version
|
||||||
|
(string->number (format "~a" base-version))))
|
||||||
|
(unless (and supplied-version (= current-version supplied-version))
|
||||||
|
(error 'update-page! "version-conflict"))
|
||||||
|
(define page-tags
|
||||||
|
(if tags tags (text->tags (vector-ref row 2))))
|
||||||
|
(define next-version (+ current-version 1))
|
||||||
|
(define now (current-seconds))
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
UPDATE pages
|
||||||
|
SET title = $1, markdown = $2, tags = $3, current_version = $4,
|
||||||
|
updated_at = $5, updated_by = $6,
|
||||||
|
search_document = setweight(to_tsvector('simple', coalesce($1, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce($2, '')), 'B')
|
||||||
|
WHERE id = $7
|
||||||
|
SQL
|
||||||
|
title markdown (tags->text page-tags) next-version now author (vector-ref row 0))
|
||||||
|
(insert-version! db (vector-ref row 0) next-version title markdown author "edit" summary now page-tags)
|
||||||
|
(replace-todos! db (vector-ref row 0) markdown)))))
|
||||||
|
(read-page config slug))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Archive an existing wiki page.
|
||||||
|
; pre : The page exists.
|
||||||
|
; post : The page is marked archived while its versions and attachments remain stored.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (archive-page! config slug author)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define id
|
||||||
|
(query-maybe-value db
|
||||||
|
#<<SQL
|
||||||
|
UPDATE pages
|
||||||
|
SET archived = TRUE, archived_at = $1, archived_by = $2
|
||||||
|
WHERE slug = $3 AND archived = FALSE
|
||||||
|
RETURNING id
|
||||||
|
SQL
|
||||||
|
(current-seconds) author slug))
|
||||||
|
(unless id
|
||||||
|
(error 'archive-page! "unknown page: ~a" slug))))
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (page-id config slug)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-maybe-value db
|
||||||
|
"SELECT id FROM pages WHERE slug = $1 AND archived = FALSE"
|
||||||
|
slug))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Read the version history for a wiki page.
|
||||||
|
; pre : The page exists.
|
||||||
|
; post : Page version rows have only been read.
|
||||||
|
; result : A newest-first list of version metadata hashes.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (page-history config slug)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define id
|
||||||
|
(query-maybe-value db "SELECT id FROM pages WHERE slug = $1 AND archived = FALSE" slug))
|
||||||
|
(unless id
|
||||||
|
(error 'page-history "unknown page: ~a" slug))
|
||||||
|
(for/list ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT version, title, author, action, summary, tags, created_at
|
||||||
|
FROM page_versions
|
||||||
|
WHERE page_id = $1
|
||||||
|
ORDER BY version DESC
|
||||||
|
SQL
|
||||||
|
id))))
|
||||||
|
(hash 'version (vector-ref row 0)
|
||||||
|
'title (vector-ref row 1)
|
||||||
|
'author (vector-ref row 2)
|
||||||
|
'action (vector-ref row 3)
|
||||||
|
'summary (vector-ref row 4)
|
||||||
|
'tags (text->tags (vector-ref row 5))
|
||||||
|
'createdAt (vector-ref row 6))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Read one stored page version.
|
||||||
|
; pre : slug and version identify a possible stored version.
|
||||||
|
; post : Version rows have only been read.
|
||||||
|
; result : Version metadata with Markdown, or #f when the version is absent.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (read-version config slug version)
|
||||||
|
(define version-number
|
||||||
|
(if (number? version) version (string->number version)))
|
||||||
|
(and version-number
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define row
|
||||||
|
(query-maybe-row db
|
||||||
|
#<<SQL
|
||||||
|
SELECT v.version, v.title, v.markdown, v.author, v.action, v.summary, v.tags, v.created_at
|
||||||
|
FROM page_versions v
|
||||||
|
JOIN pages p ON p.id = v.page_id
|
||||||
|
WHERE p.slug = $1 AND p.archived = FALSE AND v.version = $2
|
||||||
|
SQL
|
||||||
|
slug version-number))
|
||||||
|
(and row
|
||||||
|
(hash 'version (vector-ref row 0)
|
||||||
|
'title (vector-ref row 1)
|
||||||
|
'markdown (vector-ref row 2)
|
||||||
|
'author (vector-ref row 3)
|
||||||
|
'action (vector-ref row 4)
|
||||||
|
'summary (vector-ref row 5)
|
||||||
|
'tags (text->tags (vector-ref row 6))
|
||||||
|
'createdAt (vector-ref row 7)))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Search current wiki pages using PostgreSQL full-text search.
|
||||||
|
; pre : query-text is a string and the database schema is initialized.
|
||||||
|
; post : Page content has only been read.
|
||||||
|
; result : Up to 50 relevance-sorted search result hashes.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (search-pages config query-text)
|
||||||
|
(if (string=? (string-trim query-text) "")
|
||||||
|
'()
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(for/list ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
WITH q AS (SELECT websearch_to_tsquery('simple', $1) AS query)
|
||||||
|
SELECT p.slug,
|
||||||
|
p.title,
|
||||||
|
ts_rank(p.search_document, q.query) AS rank,
|
||||||
|
ts_headline('simple', p.markdown, q.query,
|
||||||
|
'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet
|
||||||
|
FROM pages p, q
|
||||||
|
WHERE p.archived = FALSE
|
||||||
|
AND p.search_document @@ q.query
|
||||||
|
ORDER BY rank DESC, lower(p.title), p.title
|
||||||
|
LIMIT 50
|
||||||
|
SQL
|
||||||
|
query-text))))
|
||||||
|
(hash 'slug (vector-ref row 0)
|
||||||
|
'title (vector-ref row 1)
|
||||||
|
'rank (vector-ref row 2)
|
||||||
|
'snippet (vector-ref row 3)))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : List unresolved todo(...) markers from all current wiki pages.
|
||||||
|
; pre : PostgreSQL schema 3 or newer is initialized.
|
||||||
|
; post : Todo and page rows have only been read.
|
||||||
|
; result : A page/title sorted list of todo item hashes.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (list-todos config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(for/list ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT p.slug, p.title, t.item_number, t.line_number, t.text
|
||||||
|
FROM todo_items t
|
||||||
|
JOIN pages p ON p.id = t.page_id
|
||||||
|
WHERE p.archived = FALSE
|
||||||
|
ORDER BY lower(p.title), p.title, t.item_number
|
||||||
|
SQL
|
||||||
|
))))
|
||||||
|
(hash 'slug (vector-ref row 0)
|
||||||
|
'title (vector-ref row 1)
|
||||||
|
'number (vector-ref row 2)
|
||||||
|
'line (vector-ref row 3)
|
||||||
|
'text (vector-ref row 4))))))
|
||||||
|
|
||||||
|
(define (safe-file-name name)
|
||||||
|
(define clean
|
||||||
|
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
||||||
|
(if (or (string=? clean "")
|
||||||
|
(string=? clean ".")
|
||||||
|
(string=? clean ".."))
|
||||||
|
"upload.bin"
|
||||||
|
clean))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Store an uploaded file in PostgreSQL.
|
||||||
|
; pre : The page exists and content is a byte string.
|
||||||
|
; post : Attachment metadata and bytes are stored in one PostgreSQL row.
|
||||||
|
; result : A hash containing original name, stored name and page-local URL.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (save-upload! config slug original-name content author)
|
||||||
|
(define id (page-id config slug))
|
||||||
|
(unless id
|
||||||
|
(error 'save-upload! "unknown page: ~a" slug))
|
||||||
|
(define stored-name
|
||||||
|
(format "~a-~a-~a" (current-seconds) (random 1000000) (safe-file-name original-name)))
|
||||||
|
(define mime-type
|
||||||
|
(cond
|
||||||
|
((regexp-match? #px"(?i:[.]png)$" stored-name) "image/png")
|
||||||
|
((regexp-match? #px"(?i:[.](jpg|jpeg))$" stored-name) "image/jpeg")
|
||||||
|
((regexp-match? #px"(?i:[.]gif)$" stored-name) "image/gif")
|
||||||
|
((regexp-match? #px"(?i:[.]webp)$" stored-name) "image/webp")
|
||||||
|
((regexp-match? #px"(?i:[.]pdf)$" stored-name) "application/pdf")
|
||||||
|
((regexp-match? #px"(?i:[.]txt)$" stored-name) "text/plain; charset=utf-8")
|
||||||
|
(else "application/octet-stream")))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO attachments(page_id, original_name, stored_name, mime_type, content,
|
||||||
|
size, uploaded_at, uploaded_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
SQL
|
||||||
|
id
|
||||||
|
original-name
|
||||||
|
stored-name
|
||||||
|
mime-type
|
||||||
|
content
|
||||||
|
(bytes-length content)
|
||||||
|
(current-seconds)
|
||||||
|
author)))
|
||||||
|
(hash 'name original-name
|
||||||
|
'storedName stored-name
|
||||||
|
'url (format "/uploads/~a/~a" slug stored-name)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Read one stored attachment from PostgreSQL.
|
||||||
|
; pre : slug and stored-name come from an upload request path.
|
||||||
|
; post : PostgreSQL has only been read.
|
||||||
|
; result : A hash containing bytes, MIME type and names, or #f when absent.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (uploaded-file config slug stored-name)
|
||||||
|
(and (valid-slug? slug)
|
||||||
|
(not (regexp-match? #px"[/\\\\]" stored-name))
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define row
|
||||||
|
(query-maybe-row db
|
||||||
|
#<<SQL
|
||||||
|
SELECT a.original_name, a.stored_name, a.mime_type, a.content, a.size
|
||||||
|
FROM attachments a
|
||||||
|
JOIN pages p ON p.id = a.page_id
|
||||||
|
WHERE p.slug = $1 AND p.archived = FALSE AND a.stored_name = $2
|
||||||
|
SQL
|
||||||
|
slug stored-name))
|
||||||
|
(if row
|
||||||
|
(hash 'originalName (vector-ref row 0)
|
||||||
|
'storedName (vector-ref row 1)
|
||||||
|
'mimeType (vector-ref row 2)
|
||||||
|
'content (vector-ref row 3)
|
||||||
|
'size (vector-ref row 4))
|
||||||
|
#f)))))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/list
|
||||||
|
racket/string)
|
||||||
|
|
||||||
|
(provide extract-todos)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Extract wiki todo(...) markers from Markdown source.
|
||||||
|
; pre : markdown is a string.
|
||||||
|
; post : Fenced code blocks are ignored and source is unchanged.
|
||||||
|
; result : A list of hashes containing item number, line number and text.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (extract-todos markdown)
|
||||||
|
(define lines (string-split markdown "\n" #:trim? #f))
|
||||||
|
(define in-fence? #f)
|
||||||
|
(define item-number 0)
|
||||||
|
(define result '())
|
||||||
|
(for ((line (in-list lines))
|
||||||
|
(line-number (in-naturals 1)))
|
||||||
|
(define trimmed (string-trim line))
|
||||||
|
(cond
|
||||||
|
((regexp-match? #px"^(```|~~~)" trimmed)
|
||||||
|
(set! in-fence? (not in-fence?)))
|
||||||
|
((not in-fence?)
|
||||||
|
(for ((match (in-list (regexp-match* #px"todo\\([^()]+\\)" line))))
|
||||||
|
(define text (string-trim (substring match 5 (- (string-length match) 1))))
|
||||||
|
(when (not (string=? text ""))
|
||||||
|
(set! item-number (+ item-number 1))
|
||||||
|
(set! result
|
||||||
|
(cons (hash 'number item-number
|
||||||
|
'line line-number
|
||||||
|
'text text)
|
||||||
|
result)))))))
|
||||||
|
(reverse result))
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require net/url
|
||||||
|
net/url-connect
|
||||||
|
racket/file
|
||||||
|
racket/path
|
||||||
|
racket/port
|
||||||
|
"config.rkt")
|
||||||
|
|
||||||
|
(provide vendor-files
|
||||||
|
vendor-files-ready?
|
||||||
|
download-vendor-files!)
|
||||||
|
|
||||||
|
(define vendor-files
|
||||||
|
(list
|
||||||
|
(cons "font-awesome/css/font-awesome.min.css"
|
||||||
|
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css")
|
||||||
|
(cons "font-awesome/fonts/fontawesome-webfont.woff2"
|
||||||
|
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/fonts/fontawesome-webfont.woff2")
|
||||||
|
(cons "easymde.min.js"
|
||||||
|
"https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js")
|
||||||
|
(cons "easymde.min.css"
|
||||||
|
"https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css")
|
||||||
|
(cons "purify.min.js"
|
||||||
|
"https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js")
|
||||||
|
(cons "highlight.min.js"
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js")
|
||||||
|
(cons "highlight-scheme.min.js"
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/languages/scheme.min.js")
|
||||||
|
(cons "highlight-github.min.css"
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css")
|
||||||
|
(cons "diff2html-ui-base.min.js"
|
||||||
|
"https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/js/diff2html-ui-base.min.js")
|
||||||
|
(cons "diff2html.min.css"
|
||||||
|
"https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css")))
|
||||||
|
|
||||||
|
(define (vendor-file-ready? config name)
|
||||||
|
(define path (build-path (vendor-directory config) name))
|
||||||
|
(and (file-exists? path)
|
||||||
|
(> (file-size path) 0)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Check whether all required browser libraries are installed.
|
||||||
|
; pre : config is a wiki-config value.
|
||||||
|
; post : Vendor files have only been inspected.
|
||||||
|
; result : #t when every required vendor file exists and is non-empty,
|
||||||
|
; otherwise #f.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (vendor-files-ready? config)
|
||||||
|
(for/and ([entry (in-list vendor-files)])
|
||||||
|
(vendor-file-ready? config (car entry))))
|
||||||
|
|
||||||
|
(define (download-content source)
|
||||||
|
(parameterize ((current-https-protocol 'secure))
|
||||||
|
(define-values (in headers)
|
||||||
|
(get-pure-port/headers (string->url source)
|
||||||
|
'()
|
||||||
|
#:redirections 5
|
||||||
|
#:status? #t))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(define status-match
|
||||||
|
(regexp-match #px"^HTTP/[^ ]+ ([0-9][0-9][0-9])" headers))
|
||||||
|
(unless (and status-match
|
||||||
|
(= (string->number (list-ref status-match 1)) 200))
|
||||||
|
(error 'download-vendor-files!
|
||||||
|
"download failed for ~a: ~a"
|
||||||
|
source
|
||||||
|
(or (and status-match (list-ref status-match 1))
|
||||||
|
"invalid HTTP status")))
|
||||||
|
(port->bytes in))
|
||||||
|
(λ ()
|
||||||
|
(close-input-port in)))))
|
||||||
|
|
||||||
|
(define (download-file! config name source)
|
||||||
|
(define directory (vendor-directory config))
|
||||||
|
(define target (build-path directory name))
|
||||||
|
(define temporary-target
|
||||||
|
(build-path directory (string-append name ".download")))
|
||||||
|
(define content (download-content source))
|
||||||
|
(make-directory* (path-only target))
|
||||||
|
(call-with-output-file temporary-target
|
||||||
|
(λ (out)
|
||||||
|
(write-bytes content out))
|
||||||
|
#:exists 'truncate/replace)
|
||||||
|
(rename-file-or-directory temporary-target target #t)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Download all browser libraries required by the wiki frontend.
|
||||||
|
; pre : config identifies a writable data directory and outbound HTTPS is
|
||||||
|
; available for the configured vendor URLs.
|
||||||
|
; post : Every required vendor file is stored below data/static/vendor.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (download-vendor-files! config)
|
||||||
|
(make-directory* (vendor-directory config))
|
||||||
|
(for ([entry (in-list vendor-files)])
|
||||||
|
(define name (car entry))
|
||||||
|
(define source (cdr entry))
|
||||||
|
(unless (vendor-file-ready? config name)
|
||||||
|
(download-file! config name source)))
|
||||||
|
(unless (vendor-files-ready? config)
|
||||||
|
(error 'download-vendor-files! "frontend vendor setup is incomplete"))
|
||||||
|
(void))
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#lang scribble/manual
|
||||||
|
|
||||||
|
@(require (for-label racket/base
|
||||||
|
racket/contract
|
||||||
|
racket/path
|
||||||
|
racket-wiki
|
||||||
|
racket-wiki/translate))
|
||||||
|
|
||||||
|
@title{racket-wiki}
|
||||||
|
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
|
||||||
|
|
||||||
|
@defmodule[racket-wiki]
|
||||||
|
|
||||||
|
The @racketmodname[racket-wiki] module starts a small self-hosted Markdown wiki.
|
||||||
|
Racket provides the HTTP API, authentication, PostgreSQL-backed user and page
|
||||||
|
storage, versioning, full-text search, and the initial web setup. Markdown
|
||||||
|
rendering and the editing interface run in the browser.
|
||||||
|
|
||||||
|
@section{Starting the Wiki}
|
||||||
|
|
||||||
|
@defproc[(start
|
||||||
|
[#:data-dir data-dir path-string? "wiki-data"]
|
||||||
|
[#:port port exact-positive-integer? 8080]
|
||||||
|
[#:listen-ip listen-ip (or/c string? #f) "127.0.0.1"]
|
||||||
|
[#:secure-cookie? secure-cookie? boolean? #f]
|
||||||
|
[#:site-title site-title string? "Racket Wiki"]
|
||||||
|
[#:session-seconds session-seconds exact-positive-integer? (* 12 60 60)]
|
||||||
|
[#:language language string? "en"])
|
||||||
|
any] {
|
||||||
|
Starts the wiki using explicit server parameters. The data directory is made
|
||||||
|
absolute before the server starts.
|
||||||
|
|
||||||
|
Use @racket["*"] or @racket[#f] for @racket[listen-ip] to listen on all
|
||||||
|
interfaces. The default address only listens on the local machine.
|
||||||
|
|
||||||
|
This procedure is the convenient entry point when starting the wiki from
|
||||||
|
DrRacket or another interactive Racket session.
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc*[([(start-wiki) any]
|
||||||
|
[(start-wiki [config any/c]) any])] {
|
||||||
|
Starts the wiki using either the default configuration or the supplied wiki
|
||||||
|
configuration.
|
||||||
|
}
|
||||||
|
|
||||||
|
@section{Initial Setup}
|
||||||
|
|
||||||
|
When PostgreSQL has not been configured, the schema is unavailable, no enabled
|
||||||
|
administrator exists, or one of the required browser libraries is missing,
|
||||||
|
normal application requests are redirected to @tt{/setup}.
|
||||||
|
|
||||||
|
The setup page itself requires no JavaScript. On a fresh installation it asks for
|
||||||
|
PostgreSQL server, port, database, user, password and SSL mode. The database must
|
||||||
|
already exist. After testing the connection, setup creates the racket-wiki schema,
|
||||||
|
asks for the first administrator, and downloads the pinned EasyMDE, Font Awesome,
|
||||||
|
DOMPurify, highlight.js, and diff2html browser files. PostgreSQL connection
|
||||||
|
settings are stored in @tt{database.rktd} below the configured data directory.
|
||||||
|
After setup the browser is redirected to @tt{/login}; setup does not create an
|
||||||
|
authenticated browser session.
|
||||||
|
|
||||||
|
|
||||||
|
@section{Page Slugs}
|
||||||
|
|
||||||
|
For a normal new page, the backend derives the slug from the title when the page
|
||||||
|
is first saved. A later title change leaves the slug unchanged so existing links
|
||||||
|
remain stable.
|
||||||
|
|
||||||
|
When an editor or administrator follows a wiki-page link to a slug that does not
|
||||||
|
yet exist, the browser opens an empty create view for that requested slug. A
|
||||||
|
reader receives a normal not-found view. Unauthenticated browser requests are
|
||||||
|
redirected to @tt{/login} before the wiki application is served.
|
||||||
|
|
||||||
|
|
||||||
|
@section{Page contents}
|
||||||
|
|
||||||
|
The application sidebar gives the current page's headings priority over the
|
||||||
|
global page list. The contents list follows Markdown headings and is updated
|
||||||
|
while a page is edited.
|
||||||
|
|
||||||
|
@section{Editor appearance}
|
||||||
|
|
||||||
|
EasyMDE and the rendered wiki page use matching body and heading sizes. The
|
||||||
|
source editor keeps Markdown syntax visible, but heading text uses the same
|
||||||
|
scale as the rendered page. Toolbar icons are provided by a locally installed
|
||||||
|
Font Awesome copy.
|
||||||
|
|
||||||
|
|
||||||
|
@section{Page navigation and metadata}
|
||||||
|
|
||||||
|
The sidebar gives priority to a table of contents derived from the headings in
|
||||||
|
the current Markdown document. The page list is secondary. The editor updates
|
||||||
|
the table of contents while the document is being edited.
|
||||||
|
|
||||||
|
A breadcrumb is displayed above the document. The footer displays creation and
|
||||||
|
modification timestamps, the current version, and page tags. Tags are stored in
|
||||||
|
the page metadata and version metadata.
|
||||||
|
|
||||||
|
@section{Full-text search}
|
||||||
|
|
||||||
|
Current pages are indexed by PostgreSQL using a weighted @tt{tsvector}; title
|
||||||
|
terms have a higher weight than Markdown body terms. A GIN index accelerates
|
||||||
|
searches. The wiki uses PostgreSQL's @tt{simple} text-search configuration so
|
||||||
|
technical Dutch, English and identifier-like terms are not subjected to a
|
||||||
|
language-specific stemmer.
|
||||||
|
|
||||||
|
@section{Database schema migrations}
|
||||||
|
|
||||||
|
Racket Wiki records PostgreSQL schema migrations in @tt{wiki_schema}. The current page is stored in @tt{pages}; every saved historical revision is stored in @tt{page_versions}. Attachments, including their binary content, are stored in @tt{attachments}. Schema migrations run in order when the application starts.
|
||||||
|
|
||||||
|
@section{Todo items}
|
||||||
|
|
||||||
|
Wiki-wide todo items are written as @tt{todo(text)} in Markdown. Markers inside
|
||||||
|
fenced code blocks are ignored. Current todo markers are indexed in PostgreSQL
|
||||||
|
and collected in the Todo view. Ordinary Markdown task lists remain available
|
||||||
|
for page-local checklists.
|
||||||
|
|
||||||
|
@section{Translations}
|
||||||
|
|
||||||
|
@defmodule[racket-wiki/translate]
|
||||||
|
|
||||||
|
@defproc[(tr [config any/c] [key symbol?]) string?] {
|
||||||
|
Returns the effective UI translation for @racket[key]. Built-in English and
|
||||||
|
Dutch translations are available before the database is configured. Once the
|
||||||
|
database is available, assignments in the special
|
||||||
|
@tt{wiki-translations-<language>} page override the built-in strings.
|
||||||
|
}
|
||||||
|
|
||||||
|
The UI language is selected with @tt{--language} or @racket[#:language]. The
|
||||||
|
frontend obtains the same effective table from the server, so server and browser
|
||||||
|
labels use one translation source.
|
||||||
|
|
||||||
|
@section{Connectivity}
|
||||||
|
|
||||||
|
The browser calls @tt{/api/ping} every 15 seconds. Failed or timed-out requests
|
||||||
|
show an Offline indicator. A recovered connection briefly shows Online.
|
||||||
+561
@@ -0,0 +1,561 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require net/url
|
||||||
|
net/uri-codec
|
||||||
|
racket/file
|
||||||
|
racket/list
|
||||||
|
racket/string
|
||||||
|
(prefix-in cookie: net/cookies/server)
|
||||||
|
web-server/dispatch
|
||||||
|
web-server/dispatchers/dispatch
|
||||||
|
web-server/http
|
||||||
|
web-server/servlet-env
|
||||||
|
"private/auth.rkt"
|
||||||
|
"private/config.rkt"
|
||||||
|
"private/http-util.rkt"
|
||||||
|
"private/setup.rkt"
|
||||||
|
"private/storage.rkt"
|
||||||
|
"translate.rkt")
|
||||||
|
|
||||||
|
(provide start-wiki-server)
|
||||||
|
|
||||||
|
(define (user->jsexpr user)
|
||||||
|
(hash 'id (wiki-user-id user)
|
||||||
|
'username (wiki-user-username user)
|
||||||
|
'displayName (wiki-user-display-name user)
|
||||||
|
'role (symbol->string (wiki-user-role user))
|
||||||
|
'enabled (wiki-user-enabled? user)))
|
||||||
|
|
||||||
|
(define (session->jsexpr session)
|
||||||
|
(if session
|
||||||
|
(hash 'authenticated #t
|
||||||
|
'user (user->jsexpr (wiki-session-user session))
|
||||||
|
'csrfToken (wiki-session-csrf-token session))
|
||||||
|
(hash 'authenticated #f)))
|
||||||
|
|
||||||
|
(define (require-role config req role proc)
|
||||||
|
(define session (session-from-request config req))
|
||||||
|
(cond
|
||||||
|
((not session) (json-error 401 "Authentication required"))
|
||||||
|
((not (role-at-least? (wiki-session-user session) role))
|
||||||
|
(json-error 403 "Insufficient permissions"))
|
||||||
|
(else (proc session))))
|
||||||
|
|
||||||
|
(define (require-write-role config req role proc)
|
||||||
|
(require-role
|
||||||
|
config req role
|
||||||
|
(λ (session)
|
||||||
|
(define csrf (request-header/string req "X-CSRF-Token"))
|
||||||
|
(if (csrf-valid? session csrf)
|
||||||
|
(proc session)
|
||||||
|
(json-error 403 "Invalid CSRF token")))))
|
||||||
|
|
||||||
|
(define (session-cookie-header config session)
|
||||||
|
(define cookie
|
||||||
|
(cookie:make-cookie "racket-wiki-session"
|
||||||
|
(wiki-session-token session)
|
||||||
|
#:path "/"
|
||||||
|
#:max-age (wiki-config-session-seconds config)
|
||||||
|
#:http-only? #t
|
||||||
|
#:secure? (wiki-config-secure-cookie? config)
|
||||||
|
#:extension "SameSite=Strict"))
|
||||||
|
(make-header #"Set-Cookie"
|
||||||
|
(cookie:cookie->set-cookie-header cookie)))
|
||||||
|
|
||||||
|
(define (login-handler config req)
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(define body (request-json req))
|
||||||
|
(define username (hash-ref body 'username ""))
|
||||||
|
(define password (hash-ref body 'password ""))
|
||||||
|
(define user (authenticate-user config username password))
|
||||||
|
(cond
|
||||||
|
((not user) (json-error 401 "Invalid username or password"))
|
||||||
|
(else
|
||||||
|
(define session (create-session! config user))
|
||||||
|
(json-response
|
||||||
|
(session->jsexpr session)
|
||||||
|
#:headers (list (session-cookie-header config session)))))))
|
||||||
|
|
||||||
|
(define login-style
|
||||||
|
#<<CSS
|
||||||
|
html { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #f4f6f8; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 32px; }
|
||||||
|
.login { width: min(440px, 100%); background: white; border: 1px solid #d7dde5; border-radius: 14px; padding: 34px; box-shadow: 0 12px 40px rgba(22, 32, 51, 0.08); }
|
||||||
|
h1 { margin: 0 0 24px; font-size: 2rem; }
|
||||||
|
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; }
|
||||||
|
CSS
|
||||||
|
)
|
||||||
|
|
||||||
|
(define (request-form req)
|
||||||
|
(define body (request-post-data/raw req))
|
||||||
|
(if body
|
||||||
|
(form-urlencoded->alist (bytes->string/utf-8 body))
|
||||||
|
'()))
|
||||||
|
|
||||||
|
(define (form-value form key [default ""])
|
||||||
|
(define found (assoc key form))
|
||||||
|
(if (and found (cdr found))
|
||||||
|
(cdr found)
|
||||||
|
default))
|
||||||
|
|
||||||
|
(define (login-page config [message #f] [username ""])
|
||||||
|
`(html
|
||||||
|
(head
|
||||||
|
(meta ((charset "utf-8")))
|
||||||
|
(meta ((name "viewport") (content "width=device-width, initial-scale=1")))
|
||||||
|
(title ,(string-append (tr config 'sign-in) " - " (wiki-config-site-title config)))
|
||||||
|
(style ,login-style))
|
||||||
|
(body
|
||||||
|
(main ((class "login"))
|
||||||
|
(h1 ,(tr config 'sign-in))
|
||||||
|
,@(if message
|
||||||
|
`((div ((class "error")) ,message))
|
||||||
|
'())
|
||||||
|
(form ((method "post") (action "/login"))
|
||||||
|
(label
|
||||||
|
,(tr config 'username)
|
||||||
|
(input ((name "username")
|
||||||
|
(value ,username)
|
||||||
|
(autocomplete "username")
|
||||||
|
(required "required")
|
||||||
|
(autofocus "autofocus"))))
|
||||||
|
(label
|
||||||
|
,(tr config 'password)
|
||||||
|
(input ((name "password")
|
||||||
|
(type "password")
|
||||||
|
(autocomplete "current-password")
|
||||||
|
(required "required"))))
|
||||||
|
(button ((type "submit")) ,(tr config 'sign-in)))))))
|
||||||
|
|
||||||
|
(define (login-page-response config [message #f] [username ""])
|
||||||
|
(html-response
|
||||||
|
(login-page config message username)
|
||||||
|
#:code (if message 401 200)
|
||||||
|
#:headers (list (make-header #"Cache-Control" #"no-store"))))
|
||||||
|
|
||||||
|
(define (browser-login-handler config req)
|
||||||
|
(define session (session-from-request config req))
|
||||||
|
(cond
|
||||||
|
(session
|
||||||
|
(redirect-response "/"))
|
||||||
|
((string-ci=? (bytes->string/latin-1 (request-method req)) "POST")
|
||||||
|
(define form (request-form req))
|
||||||
|
(define username (string-trim (form-value form 'username)))
|
||||||
|
(define password (form-value form 'password))
|
||||||
|
(define user (authenticate-user config username password))
|
||||||
|
(cond
|
||||||
|
((not user)
|
||||||
|
(login-page-response config "Invalid username or password" username))
|
||||||
|
(else
|
||||||
|
(define new-session (create-session! config user))
|
||||||
|
(redirect-response
|
||||||
|
"/"
|
||||||
|
#:headers (list (session-cookie-header config new-session))))))
|
||||||
|
(else
|
||||||
|
(login-page-response config))))
|
||||||
|
|
||||||
|
(define (logout-handler config req)
|
||||||
|
(require-write-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (session)
|
||||||
|
(delete-session! config (wiki-session-token session))
|
||||||
|
(json-response
|
||||||
|
(hash 'ok #t)
|
||||||
|
#:headers
|
||||||
|
(list (make-header #"Set-Cookie"
|
||||||
|
(cookie:clear-cookie-header "racket-wiki-session" #:path "/")))))))
|
||||||
|
|
||||||
|
(define (page-list-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(json-response (hash 'pages (list-pages config))))))
|
||||||
|
|
||||||
|
(define (request-query-value req key [default ""])
|
||||||
|
(define found (assoc key (url-query (request-uri req))))
|
||||||
|
(if found (cdr found) default))
|
||||||
|
|
||||||
|
(define (search-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(define query-text (request-query-value req 'q))
|
||||||
|
(json-response (hash 'results (search-pages config query-text))))))
|
||||||
|
|
||||||
|
(define (todo-list-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(json-response (hash 'items (list-todos config))))))
|
||||||
|
|
||||||
|
(define (translations-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(json-response
|
||||||
|
(hash 'language (current-language config)
|
||||||
|
'page (translation-page-slug (current-language config))
|
||||||
|
'translations (translations-for config))))))
|
||||||
|
|
||||||
|
(define (page-get-handler config req slug)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(define page (and (valid-slug? slug) (read-page config slug)))
|
||||||
|
(if page
|
||||||
|
(json-response page)
|
||||||
|
(json-error 404 "Page not found")))))
|
||||||
|
|
||||||
|
(define (request-tags body)
|
||||||
|
(define value (hash-ref body 'tags '()))
|
||||||
|
(if (and (list? value)
|
||||||
|
(for/and ((tag (in-list value)))
|
||||||
|
(string? tag)))
|
||||||
|
value
|
||||||
|
(raise-argument-error 'request-tags "list of strings" value)))
|
||||||
|
|
||||||
|
(define (page-create-handler config req)
|
||||||
|
(require-write-role
|
||||||
|
config req 'editor
|
||||||
|
(λ (session)
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(define body (request-json req))
|
||||||
|
(define requested-slug (hash-ref body 'slug #f))
|
||||||
|
(define title (hash-ref body 'title ""))
|
||||||
|
(define markdown (hash-ref body 'markdown ""))
|
||||||
|
(define tags (request-tags body))
|
||||||
|
(define summary (hash-ref body 'summary "Created page"))
|
||||||
|
(define slug
|
||||||
|
(if requested-slug
|
||||||
|
requested-slug
|
||||||
|
(title->slug title)))
|
||||||
|
(cond
|
||||||
|
((string=? (string-trim title) "")
|
||||||
|
(json-error 400 "Title is required"))
|
||||||
|
((string=? slug "")
|
||||||
|
(json-error 400 "The title cannot be converted to a page slug"))
|
||||||
|
((not (valid-slug? slug))
|
||||||
|
(json-error 400 "Invalid page address"))
|
||||||
|
((read-page config slug)
|
||||||
|
(json-error 409 "A page with this address already exists"))
|
||||||
|
(else
|
||||||
|
(json-response
|
||||||
|
(create-page! config
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
markdown
|
||||||
|
(wiki-user-username (wiki-session-user session))
|
||||||
|
summary
|
||||||
|
tags)
|
||||||
|
#:code 201)))))))
|
||||||
|
|
||||||
|
(define (page-update-handler config req slug)
|
||||||
|
(require-write-role
|
||||||
|
config req 'editor
|
||||||
|
(λ (session)
|
||||||
|
(with-handlers ((exn:fail?
|
||||||
|
(λ (e)
|
||||||
|
(if (string=? (exn-message e) "update-page!: version-conflict")
|
||||||
|
(json-error 409 "Page changed since it was opened")
|
||||||
|
(json-error 400 (exn-message e))))))
|
||||||
|
(define body (request-json req))
|
||||||
|
(define title (hash-ref body 'title ""))
|
||||||
|
(define markdown (hash-ref body 'markdown ""))
|
||||||
|
(define tags (request-tags body))
|
||||||
|
(define base-version (hash-ref body 'baseVersion ""))
|
||||||
|
(define summary (hash-ref body 'summary "Edited page"))
|
||||||
|
(json-response
|
||||||
|
(update-page! config
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
markdown
|
||||||
|
(wiki-user-username (wiki-session-user session))
|
||||||
|
base-version
|
||||||
|
summary
|
||||||
|
tags))))))
|
||||||
|
|
||||||
|
(define (page-delete-handler config req slug)
|
||||||
|
(require-write-role
|
||||||
|
config req 'editor
|
||||||
|
(λ (session)
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(archive-page! config
|
||||||
|
slug
|
||||||
|
(wiki-user-username (wiki-session-user session)))
|
||||||
|
(json-response (hash 'ok #t))))))
|
||||||
|
|
||||||
|
(define (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 (page-history config slug)))))))
|
||||||
|
|
||||||
|
(define (version-handler config req slug version)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(define result (read-version config slug version))
|
||||||
|
(if result
|
||||||
|
(json-response result)
|
||||||
|
(json-error 404 "Version not found")))))
|
||||||
|
|
||||||
|
(define (upload-handler config req slug)
|
||||||
|
(require-write-role
|
||||||
|
config req 'editor
|
||||||
|
(λ (session)
|
||||||
|
(define name (or (request-header/string req "X-File-Name") "upload.bin"))
|
||||||
|
(define content (or (request-post-data/raw req) #""))
|
||||||
|
(cond
|
||||||
|
((> (bytes-length content) (* 50 1024 1024))
|
||||||
|
(json-error 413 "Upload exceeds 50 MiB"))
|
||||||
|
(else
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(json-response (save-upload! config
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
content
|
||||||
|
(wiki-user-username (wiki-session-user session)))
|
||||||
|
#:code 201)))))))
|
||||||
|
|
||||||
|
(define (inline-image-mime? mime)
|
||||||
|
(if (member mime
|
||||||
|
'(#"image/png"
|
||||||
|
#"image/jpeg"
|
||||||
|
#"image/gif"
|
||||||
|
#"image/webp"))
|
||||||
|
#t
|
||||||
|
#f))
|
||||||
|
|
||||||
|
(define (upload-file-response attachment)
|
||||||
|
(define mime (string->bytes/utf-8 (hash-ref attachment 'mimeType)))
|
||||||
|
(define stored-name (hash-ref attachment 'storedName))
|
||||||
|
(define original-name (hash-ref attachment 'originalName))
|
||||||
|
(define disposition
|
||||||
|
(if (inline-image-mime? mime)
|
||||||
|
#"inline"
|
||||||
|
(string->bytes/utf-8
|
||||||
|
(format "attachment; filename=\"~a\"" original-name))))
|
||||||
|
(bytes-response
|
||||||
|
(hash-ref attachment 'content)
|
||||||
|
mime
|
||||||
|
#:headers
|
||||||
|
(list (make-header #"Content-Disposition" disposition))))
|
||||||
|
|
||||||
|
(define (upload-get-handler config req slug stored-name)
|
||||||
|
(require-role
|
||||||
|
config req 'reader
|
||||||
|
(λ (_session)
|
||||||
|
(define attachment (uploaded-file config slug stored-name))
|
||||||
|
(if attachment
|
||||||
|
(upload-file-response attachment)
|
||||||
|
(json-error 404 "File not found")))))
|
||||||
|
|
||||||
|
(define (admin-users-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'admin
|
||||||
|
(λ (_session)
|
||||||
|
(json-response (hash 'users (map user->jsexpr (list-users config)))))))
|
||||||
|
|
||||||
|
(define (admin-create-user-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 username (hash-ref body 'username ""))
|
||||||
|
(define display-name (hash-ref body 'displayName username))
|
||||||
|
(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))
|
||||||
|
(unless (member role '(reader editor admin))
|
||||||
|
(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)
|
||||||
|
(json-response (hash 'ok #t) #:code 201)))))
|
||||||
|
|
||||||
|
(define (admin-update-user-handler config req id)
|
||||||
|
(require-write-role
|
||||||
|
config req 'admin
|
||||||
|
(λ (_session)
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(define body (request-json req))
|
||||||
|
(define display-name (hash-ref body 'displayName ""))
|
||||||
|
(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)
|
||||||
|
(json-response (hash 'ok #t))))))
|
||||||
|
|
||||||
|
(define (admin-delete-user-handler config req id)
|
||||||
|
(require-write-role
|
||||||
|
config req 'admin
|
||||||
|
(λ (session)
|
||||||
|
(if (= id (wiki-user-id (wiki-session-user session)))
|
||||||
|
(json-error 400 "You cannot delete your own account")
|
||||||
|
(begin
|
||||||
|
(delete-user! config id)
|
||||||
|
(json-response (hash 'ok #t)))))))
|
||||||
|
|
||||||
|
(define (request-path-string req)
|
||||||
|
(define segments
|
||||||
|
(for/list ([segment (in-list (url-path (request-uri req)))])
|
||||||
|
(path/param-path segment)))
|
||||||
|
(string-append "/" (string-join segments "/")))
|
||||||
|
|
||||||
|
(define (index-response)
|
||||||
|
(bytes-response
|
||||||
|
(file->bytes (build-path static-directory "index.html"))
|
||||||
|
#"text/html; charset=utf-8"
|
||||||
|
#:headers (list (make-header #"Cache-Control" #"no-cache"))))
|
||||||
|
|
||||||
|
(define (static-request-path? path)
|
||||||
|
(or (regexp-match? #px"^/(vendor|css|js)/" path)
|
||||||
|
(string=? path "/favicon.ico")))
|
||||||
|
|
||||||
|
(define (application-api-path? path)
|
||||||
|
(or (regexp-match? #px"^/api(/|$)" path)
|
||||||
|
(regexp-match? #px"^/uploads(/|$)" path)))
|
||||||
|
|
||||||
|
(define (request-page-slug path)
|
||||||
|
(define match (regexp-match #px"^/([^/]+)/?$" path))
|
||||||
|
(if match
|
||||||
|
(list-ref match 1)
|
||||||
|
#f))
|
||||||
|
|
||||||
|
(define (page-location slug)
|
||||||
|
(string-append "/#/" (uri-path-segment-unreserved-encode slug)))
|
||||||
|
|
||||||
|
(define (not-found-response config)
|
||||||
|
(html-response
|
||||||
|
`(html
|
||||||
|
(head
|
||||||
|
(meta ((charset "utf-8")))
|
||||||
|
(meta ((name "viewport") (content "width=device-width, initial-scale=1")))
|
||||||
|
(title ,(string-append "Not found - " (wiki-config-site-title config))))
|
||||||
|
(body
|
||||||
|
(h1 "Page not found")))
|
||||||
|
#:code 404
|
||||||
|
#:headers (list (make-header #"Cache-Control" #"no-store"))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Start the HTTP server for the wiki.
|
||||||
|
; pre : config is initialized and its static and data paths are available.
|
||||||
|
; post : The server is listening until the servlet environment stops. When
|
||||||
|
; initial setup is incomplete, normal application requests redirect
|
||||||
|
; to /setup.
|
||||||
|
; result : The result returned by serve/servlet.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (start-wiki-server config)
|
||||||
|
(define-values (application-dispatch _url)
|
||||||
|
(dispatch-rules
|
||||||
|
[("api" "session") #:method "get"
|
||||||
|
(λ (req)
|
||||||
|
(json-response
|
||||||
|
(session->jsexpr (session-from-request config req))))]
|
||||||
|
[("api" "login") #:method "post"
|
||||||
|
(λ (req) (login-handler config req))]
|
||||||
|
[("api" "logout") #:method "post"
|
||||||
|
(λ (req) (logout-handler config req))]
|
||||||
|
[("api" "ping") #:method "get"
|
||||||
|
(λ (_req) (json-response (hash 'ok #t 'time (current-seconds))))]
|
||||||
|
[("api" "translations") #:method "get"
|
||||||
|
(λ (req) (translations-handler config req))]
|
||||||
|
[("api" "todos") #:method "get"
|
||||||
|
(λ (req) (todo-list-handler config req))]
|
||||||
|
[("api" "search") #:method "get"
|
||||||
|
(λ (req) (search-handler config req))]
|
||||||
|
[("api" "pages") #:method "get"
|
||||||
|
(λ (req) (page-list-handler config req))]
|
||||||
|
[("api" "pages") #:method "post"
|
||||||
|
(λ (req) (page-create-handler config req))]
|
||||||
|
[("api" "pages" (string-arg)) #:method "get"
|
||||||
|
(λ (req slug) (page-get-handler config req slug))]
|
||||||
|
[("api" "pages" (string-arg)) #:method "put"
|
||||||
|
(λ (req slug) (page-update-handler config req slug))]
|
||||||
|
[("api" "pages" (string-arg)) #:method "delete"
|
||||||
|
(λ (req slug) (page-delete-handler config req slug))]
|
||||||
|
[("api" "pages" (string-arg) "history") #:method "get"
|
||||||
|
(λ (req slug) (history-handler config req slug))]
|
||||||
|
[("api" "pages" (string-arg) "versions" (string-arg)) #:method "get"
|
||||||
|
(λ (req slug version) (version-handler config req slug version))]
|
||||||
|
[("api" "pages" (string-arg) "upload") #:method "post"
|
||||||
|
(λ (req slug) (upload-handler config req slug))]
|
||||||
|
[("uploads" (string-arg) (string-arg)) #:method "get"
|
||||||
|
(λ (req slug stored-name) (upload-get-handler config req slug stored-name))]
|
||||||
|
[("api" "admin" "users") #:method "get"
|
||||||
|
(λ (req) (admin-users-handler config req))]
|
||||||
|
[("api" "admin" "users") #:method "post"
|
||||||
|
(λ (req) (admin-create-user-handler config req))]
|
||||||
|
[("api" "admin" "users" (integer-arg)) #:method "put"
|
||||||
|
(λ (req id) (admin-update-user-handler config req id))]
|
||||||
|
[("api" "admin" "users" (integer-arg)) #:method "delete"
|
||||||
|
(λ (req id) (admin-delete-user-handler config req id))]
|
||||||
|
[else (λ (_req) (json-error 404 "API endpoint not found"))]))
|
||||||
|
|
||||||
|
(define setup-ready? (box (setup-complete? config)))
|
||||||
|
|
||||||
|
(define (dispatch req)
|
||||||
|
(define path (request-path-string req))
|
||||||
|
(cond
|
||||||
|
((regexp-match? #px"^/setup/?$" path)
|
||||||
|
(define response (setup-handler config req))
|
||||||
|
(when (setup-complete? config)
|
||||||
|
(set-box! setup-ready? #t))
|
||||||
|
response)
|
||||||
|
((not (unbox setup-ready?))
|
||||||
|
(redirect-response "/setup"))
|
||||||
|
((regexp-match? #px"^/login/?$" path)
|
||||||
|
(browser-login-handler config req))
|
||||||
|
((static-request-path? path)
|
||||||
|
(next-dispatcher))
|
||||||
|
((application-api-path? path)
|
||||||
|
(application-dispatch req))
|
||||||
|
((or (string=? path "/")
|
||||||
|
(string=? path "/index.html"))
|
||||||
|
(if (session-from-request config req)
|
||||||
|
(index-response)
|
||||||
|
(redirect-response "/login")))
|
||||||
|
(else
|
||||||
|
(define session (session-from-request config req))
|
||||||
|
(define slug (request-page-slug path))
|
||||||
|
(cond
|
||||||
|
((not session)
|
||||||
|
(redirect-response "/login"))
|
||||||
|
((and slug (valid-slug? slug))
|
||||||
|
(let ((page (read-page config slug)))
|
||||||
|
(cond
|
||||||
|
(page
|
||||||
|
(redirect-response (page-location slug)))
|
||||||
|
((role-at-least? (wiki-session-user session) 'editor)
|
||||||
|
(redirect-response (page-location slug)))
|
||||||
|
(else
|
||||||
|
(not-found-response config)))))
|
||||||
|
(else
|
||||||
|
(not-found-response config))))))
|
||||||
|
|
||||||
|
(displayln
|
||||||
|
(format "~a listening on http://~a:~a/"
|
||||||
|
(wiki-config-site-title config)
|
||||||
|
(or (wiki-config-listen-ip config) "0.0.0.0")
|
||||||
|
(wiki-config-port config)))
|
||||||
|
|
||||||
|
(when (not (unbox setup-ready?))
|
||||||
|
(displayln "Initial setup is required. Open /setup in a browser."))
|
||||||
|
|
||||||
|
(serve/servlet dispatch
|
||||||
|
#:launch-browser? #f
|
||||||
|
#:quit? #f
|
||||||
|
#:banner? #f
|
||||||
|
#:listen-ip (wiki-config-listen-ip config)
|
||||||
|
#:port (wiki-config-port config)
|
||||||
|
#:servlet-regexp #rx""
|
||||||
|
#:extra-files-paths (list (data-static-directory config)
|
||||||
|
static-directory)))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/cmdline
|
||||||
|
racket/path
|
||||||
|
"private/config.rkt"
|
||||||
|
"private/vendor.rkt")
|
||||||
|
|
||||||
|
(define config (default-wiki-config))
|
||||||
|
|
||||||
|
(command-line
|
||||||
|
#:program "racket-wiki setup-vendor"
|
||||||
|
#:once-each
|
||||||
|
(("--data") directory
|
||||||
|
"Wiki data directory"
|
||||||
|
(set! config
|
||||||
|
(struct-copy wiki-config config
|
||||||
|
(data-dir (path->complete-path directory))))))
|
||||||
|
|
||||||
|
(displayln
|
||||||
|
(format "Downloading frontend libraries to ~a"
|
||||||
|
(vendor-directory config)))
|
||||||
|
(download-vendor-files! config)
|
||||||
|
(displayln "Frontend vendor files are ready.")
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #202124;
|
||||||
|
background: #f6f7f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; }
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.muted { color: #6b7280; font-size: 0.9rem; }
|
||||||
|
.error { color: #b42318; margin-top: .75rem; }
|
||||||
|
.danger { color: #b42318; }
|
||||||
|
.primary { font-weight: 650; }
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { font-size: 1.2rem; font-weight: 700; margin-bottom: 14px; }
|
||||||
|
#user-box { font-size: .9rem; margin-bottom: 12px; }
|
||||||
|
.sidebar-actions { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||||
|
#page-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.page-link { color: white; text-decoration: none; padding: 7px 8px; border-radius: 5px; }
|
||||||
|
.page-link:hover, .page-link.active { background: #374151; }
|
||||||
|
|
||||||
|
#main { min-width: 0; padding: 28px; }
|
||||||
|
.card { max-width: 440px; margin: 12vh auto 0; padding: 28px; background: white; border: 1px solid #dfe3e8; border-radius: 10px; }
|
||||||
|
.card label { display: grid; gap: 6px; margin: 14px 0; }
|
||||||
|
.card input { padding: 9px 10px; }
|
||||||
|
|
||||||
|
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.page-header h1 { margin: 0; }
|
||||||
|
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.toolbar button, .sidebar-actions button, .user-form button { padding: 7px 10px; }
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dfe3e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.markdown-body img { max-width: 100%; height: auto; }
|
||||||
|
.markdown-body pre { overflow: auto; padding: 12px; background: #f3f4f6; border-radius: 6px; }
|
||||||
|
.markdown-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.markdown-body table { border-collapse: collapse; }
|
||||||
|
.markdown-body th, .markdown-body td { border: 1px solid #d1d5db; padding: 6px 9px; }
|
||||||
|
|
||||||
|
.editor-heading { display: grid; gap: 5px; flex: 1; }
|
||||||
|
.title-input, #edit-summary { padding: 9px 10px; }
|
||||||
|
#editor-slug-info { min-height: 1.3em; padding-left: 2px; }
|
||||||
|
.title-input { font-size: 1.25rem; font-weight: 650; }
|
||||||
|
|
||||||
|
.editor-shell .EasyMDEContainer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar {
|
||||||
|
border-color: #cfd5dc;
|
||||||
|
background: white;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .82rem;
|
||||||
|
line-height: 30px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-button:hover,
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-button.active {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-bold {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.rw-mde-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror,
|
||||||
|
.EasyMDEContainer .CodeMirror-scroll,
|
||||||
|
.EasyMDEContainer .editor-preview-side {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror {
|
||||||
|
border-color: #cfd5dc;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-preview,
|
||||||
|
.EasyMDEContainer .editor-preview-side {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-preview-side.markdown-body {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-statusbar {
|
||||||
|
padding-right: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer.dragging {
|
||||||
|
outline: 3px dashed #64748b;
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-row { display: flex; gap: 12px; align-items: center; margin-top: 10px; }
|
||||||
|
#edit-summary { flex: 1; }
|
||||||
|
|
||||||
|
.history-row, .user-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
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 input, .user-row select { padding: 6px 7px; }
|
||||||
|
.user-form { display: grid; grid-template-columns: 1fr 1fr 1fr 120px auto; gap: 8px; margin-bottom: 20px; }
|
||||||
|
#diff-target { margin-top: 24px; background: white; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#app { grid-template-columns: 1fr; }
|
||||||
|
#sidebar { min-height: auto; }
|
||||||
|
.user-form, .user-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Document styling follows the restrained layout of Racket's Scribble manuals:
|
||||||
|
light navigation, readable measure, compact headings, and code-oriented text. */
|
||||||
|
:root {
|
||||||
|
--wiki-text: #202020;
|
||||||
|
--wiki-muted: #666;
|
||||||
|
--wiki-link: #3b4f91;
|
||||||
|
--wiki-border: #d8d8d8;
|
||||||
|
--wiki-sidebar: #f1f1f1;
|
||||||
|
--wiki-code-bg: #f4f4f4;
|
||||||
|
--wiki-font-size: 16px;
|
||||||
|
--wiki-line-height: 1.55;
|
||||||
|
--wiki-h1-size: 2rem;
|
||||||
|
--wiki-h2-size: 1.5rem;
|
||||||
|
--wiki-h3-size: 1.25rem;
|
||||||
|
--wiki-h4-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--wiki-text);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
background: var(--wiki-sidebar);
|
||||||
|
color: var(--wiki-text);
|
||||||
|
border-right: 1px solid var(--wiki-border);
|
||||||
|
padding: 18px 16px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: #111;
|
||||||
|
font-size: 1.18rem;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-box,
|
||||||
|
#sidebar .muted {
|
||||||
|
color: var(--wiki-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions button {
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
border: 1px solid #c7c7c7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-heading {
|
||||||
|
margin: 0 6px 7px;
|
||||||
|
color: #555;
|
||||||
|
font-size: .76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-list,
|
||||||
|
.toc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link,
|
||||||
|
.toc-link {
|
||||||
|
color: var(--wiki-link);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover,
|
||||||
|
.page-link.active,
|
||||||
|
.toc-link:hover {
|
||||||
|
background: #e2e2e2;
|
||||||
|
color: #1e2f6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link {
|
||||||
|
padding: 3px 7px;
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-level-2 { padding-left: 18px; }
|
||||||
|
.toc-level-3 { padding-left: 30px; font-size: .86rem; }
|
||||||
|
.toc-level-4 { padding-left: 42px; font-size: .83rem; }
|
||||||
|
.toc-empty {
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: #888;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-section {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #d7d7d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
padding: 30px 42px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-view,
|
||||||
|
#not-found-view,
|
||||||
|
#history-view,
|
||||||
|
#admin-view {
|
||||||
|
max-width: 1040px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body,
|
||||||
|
.EasyMDEContainer .editor-preview,
|
||||||
|
.EasyMDEContainer .editor-preview-side {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: var(--wiki-font-size);
|
||||||
|
line-height: var(--wiki-line-height);
|
||||||
|
color: var(--wiki-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
background: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h1,
|
||||||
|
.EasyMDEContainer .editor-preview h1,
|
||||||
|
.EasyMDEContainer .editor-preview-side h1 {
|
||||||
|
font-size: var(--wiki-h1-size);
|
||||||
|
line-height: 1.18;
|
||||||
|
margin: 1.15em 0 .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h2,
|
||||||
|
.EasyMDEContainer .editor-preview h2,
|
||||||
|
.EasyMDEContainer .editor-preview-side h2 {
|
||||||
|
font-size: var(--wiki-h2-size);
|
||||||
|
line-height: 1.22;
|
||||||
|
margin: 1.25em 0 .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h3,
|
||||||
|
.EasyMDEContainer .editor-preview h3,
|
||||||
|
.EasyMDEContainer .editor-preview-side h3 {
|
||||||
|
font-size: var(--wiki-h3-size);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.2em 0 .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h4,
|
||||||
|
.EasyMDEContainer .editor-preview h4,
|
||||||
|
.EasyMDEContainer .editor-preview-side h4 {
|
||||||
|
font-size: var(--wiki-h4-size);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.1em 0 .35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body h5,
|
||||||
|
.markdown-body h6,
|
||||||
|
.EasyMDEContainer .editor-preview h5,
|
||||||
|
.EasyMDEContainer .editor-preview h6,
|
||||||
|
.EasyMDEContainer .editor-preview-side h5,
|
||||||
|
.EasyMDEContainer .editor-preview-side h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body a,
|
||||||
|
.EasyMDEContainer .editor-preview a,
|
||||||
|
.EasyMDEContainer .editor-preview-side a {
|
||||||
|
color: var(--wiki-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre,
|
||||||
|
.EasyMDEContainer .editor-preview pre,
|
||||||
|
.EasyMDEContainer .editor-preview-side pre {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--wiki-code-bg);
|
||||||
|
border: 1px solid #e1e1e1;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body code,
|
||||||
|
.EasyMDEContainer .editor-preview code,
|
||||||
|
.EasyMDEContainer .editor-preview-side code {
|
||||||
|
font-family: "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: .94em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote,
|
||||||
|
.EasyMDEContainer .editor-preview blockquote,
|
||||||
|
.EasyMDEContainer .editor-preview-side blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #bbb;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The source editor deliberately uses a slightly smaller, tighter monospace
|
||||||
|
* body than the rendered document. Monospace text is wider than proportional
|
||||||
|
* document text; keeping it at the same 16px size made the source wrap much
|
||||||
|
* earlier and therefore consume far more vertical space. Headings keep the
|
||||||
|
* same scale as their rendered counterparts.
|
||||||
|
*/
|
||||||
|
.EasyMDEContainer .CodeMirror {
|
||||||
|
font-family: "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror pre.CodeMirror-line,
|
||||||
|
.EasyMDEContainer .CodeMirror pre.CodeMirror-line-like {
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-1 { font-size: var(--wiki-h1-size); line-height: 1.18; }
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-2 { font-size: var(--wiki-h2-size); line-height: 1.22; }
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-3 { font-size: var(--wiki-h3-size); line-height: 1.25; }
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-4 { font-size: var(--wiki-h4-size); line-height: 1.3; }
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-5,
|
||||||
|
.EasyMDEContainer .CodeMirror .cm-header-6 { font-size: 1rem; line-height: var(--wiki-line-height); }
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button {
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .editor-toolbar button.fa::before {
|
||||||
|
font-family: FontAwesome;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#app { grid-template-columns: 1fr; }
|
||||||
|
#sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--wiki-border);
|
||||||
|
}
|
||||||
|
#main { padding: 22px 18px 50px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .4rem;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 0 22px;
|
||||||
|
color: var(--wiki-muted);
|
||||||
|
font-size: .86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a {
|
||||||
|
color: var(--wiki-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-label {
|
||||||
|
color: #777;
|
||||||
|
font-size: .82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-current {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .35rem 1rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-top: 38px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e2e2e2;
|
||||||
|
color: #777;
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tag {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-metadata-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, .8fr) minmax(260px, 1.4fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-tags,
|
||||||
|
#edit-summary {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.editor-metadata-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.wiki-search {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid #c6c6c6;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: white;
|
||||||
|
color: var(--wiki-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-view {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
padding: 0 0 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #e2e2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result h2 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result h2 a {
|
||||||
|
color: var(--wiki-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result h2 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result mark {
|
||||||
|
background: #fff1a8;
|
||||||
|
padding: 0 .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Long-document editing ---------------------------------------------------
|
||||||
|
In editor mode the chrome stays in place while the source and preview
|
||||||
|
become the scrollable regions. This keeps breadcrumbs, title/actions,
|
||||||
|
EasyMDE toolbar and the page TOC available throughout a long document. */
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-section {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages-section {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 34vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #app {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #main {
|
||||||
|
display: grid;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #breadcrumbs {
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 3px 0 7px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #editor-view:not(.hidden) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #editor-view .page-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 29;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 9px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--wiki-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .editor-shell .EasyMDEContainer {
|
||||||
|
--editor-toolbar-height: 43px;
|
||||||
|
--editor-status-height: 25px;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .editor-toolbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 25;
|
||||||
|
grid-row: 1;
|
||||||
|
margin: 0;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .CodeMirror {
|
||||||
|
grid-row: 2;
|
||||||
|
min-height: 0 !important;
|
||||||
|
height: 100% !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .CodeMirror-scroll {
|
||||||
|
min-height: 0 !important;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .editor-statusbar {
|
||||||
|
grid-row: 3;
|
||||||
|
min-height: var(--editor-status-height);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EasyMDE normally positions the side preview against the viewport. In the
|
||||||
|
wiki it belongs to the editor work area, so it gets its own independent
|
||||||
|
scroll region beside CodeMirror instead. */
|
||||||
|
body.editor-mode .EasyMDEContainer .editor-preview-side {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: var(--editor-toolbar-height);
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--editor-status-height);
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .CodeMirror-sided {
|
||||||
|
width: 50% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .editor-metadata-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 29;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid var(--wiki-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#sidebar {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-section,
|
||||||
|
#toc-list,
|
||||||
|
.pages-section {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #app,
|
||||||
|
body.editor-mode #main {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode #editor-view:not(.hidden),
|
||||||
|
body.editor-mode .editor-shell .EasyMDEContainer {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.editor-mode .EasyMDEContainer .CodeMirror,
|
||||||
|
body.editor-mode .EasyMDEContainer .CodeMirror-scroll {
|
||||||
|
min-height: 520px !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Todo and connection status -------------------------------------------- */
|
||||||
|
.connection-status {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
top: 12px;
|
||||||
|
right: 14px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.offline {
|
||||||
|
color: #8b1f1f;
|
||||||
|
border-color: #d9a0a0;
|
||||||
|
background: #fff1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.online {
|
||||||
|
color: #286b3d;
|
||||||
|
border-color: #9dcaaa;
|
||||||
|
background: #f1fbf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-todo {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .08em .38em;
|
||||||
|
border: 1px solid #c9aa58;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff8dc;
|
||||||
|
color: #5d4700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-items {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 260px) 1fr auto;
|
||||||
|
gap: 10px 18px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #e2e2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > a {
|
||||||
|
color: var(--wiki-link);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.todo-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
|
||||||
|
<title>Racket Wiki</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/font-awesome/css/font-awesome.min.css">
|
||||||
|
<link rel="stylesheet" href="/vendor/easymde.min.css">
|
||||||
|
<link rel="stylesheet" href="/vendor/highlight-github.min.css">
|
||||||
|
<link rel="stylesheet" href="/vendor/diff2html.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/wiki.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="connection-status" class="connection-status hidden" role="status"></div>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<div class="brand">Racket Wiki</div>
|
||||||
|
<div id="user-box"></div>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<button id="todo-button" data-tr="todo">Todo</button>
|
||||||
|
<button id="translations-button" class="admin-only hidden" data-tr="translations">Translations</button>
|
||||||
|
<button id="admin-button" class="admin-only hidden" data-tr="users">Users</button>
|
||||||
|
</div>
|
||||||
|
<form id="search-form" class="wiki-search" role="search">
|
||||||
|
<input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki">
|
||||||
|
</form>
|
||||||
|
<section class="sidebar-section toc-section">
|
||||||
|
<div class="sidebar-heading" data-tr="contents">Contents</div>
|
||||||
|
<nav id="toc-list" class="toc-list"></nav>
|
||||||
|
</section>
|
||||||
|
<details class="sidebar-section pages-section">
|
||||||
|
<summary class="sidebar-heading" data-tr="pages">Pages</summary>
|
||||||
|
<nav id="page-list"></nav>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<nav id="breadcrumbs" class="breadcrumbs hidden" aria-label="Breadcrumb"></nav>
|
||||||
|
|
||||||
|
<section id="page-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 id="page-title"></h1>
|
||||||
|
<div id="page-meta" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="edit-page" class="editor-only hidden" data-tr="edit">Edit</button>
|
||||||
|
<button id="history-page" data-tr="history">History</button>
|
||||||
|
<button id="delete-page" class="editor-only danger hidden" data-tr="delete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<article id="markdown-preview" class="markdown-body"></article>
|
||||||
|
<footer id="page-details" class="page-details"></footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="not-found-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 data-tr="page-not-found">Page not found</h1>
|
||||||
|
</header>
|
||||||
|
<p id="not-found-message"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="editor-view" class="hidden editor-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="editor-heading">
|
||||||
|
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
|
||||||
|
<div id="editor-slug-info" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="cancel-edit" data-tr="cancel">Cancel</button>
|
||||||
|
<button id="save-page" class="primary" data-tr="save">Save</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<textarea id="markdown-editor" aria-label="Markdown source"></textarea>
|
||||||
|
<input id="file-input" type="file" multiple hidden>
|
||||||
|
<div class="editor-metadata-row">
|
||||||
|
<input id="editor-tags" placeholder="Tags (comma separated)" data-tr-placeholder="tags">
|
||||||
|
<input id="edit-summary" placeholder="Version summary (optional)" data-tr-placeholder="version-summary">
|
||||||
|
<span id="save-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="search-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 data-tr="search">Search</h1>
|
||||||
|
<div id="search-summary" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="todo-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 data-tr="todo-items">Todo items</h1>
|
||||||
|
</header>
|
||||||
|
<div id="todo-list" class="todo-items"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="history-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 data-tr="page-history">Page history</h1>
|
||||||
|
<button id="close-history" data-tr="back">Back</button>
|
||||||
|
</header>
|
||||||
|
<div id="history-list"></div>
|
||||||
|
<div id="diff-target"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="admin-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 data-tr="user-administration">User administration</h1>
|
||||||
|
<button id="close-admin" data-tr="back">Back</button>
|
||||||
|
</header>
|
||||||
|
<form id="new-user-form" class="user-form">
|
||||||
|
<input id="new-username" placeholder="Username" data-tr-placeholder="username" required>
|
||||||
|
<input id="new-display-name" placeholder="Display name" data-tr-placeholder="display-name">
|
||||||
|
<input id="new-password" type="password" placeholder="Initial password" data-tr-placeholder="initial-password" required>
|
||||||
|
<select id="new-role">
|
||||||
|
<option value="reader">reader</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" data-tr="create-user">Create user</button>
|
||||||
|
</form>
|
||||||
|
<div id="user-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/vendor/purify.min.js"></script>
|
||||||
|
<script src="/vendor/highlight.min.js"></script>
|
||||||
|
<script src="/vendor/highlight-scheme.min.js"></script>
|
||||||
|
<script src="/vendor/easymde.min.js"></script>
|
||||||
|
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
||||||
|
<script src="/js/wiki.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1184
File diff suppressed because it is too large
Load Diff
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Frontend vendor sources
|
||||||
|
|
||||||
|
racket-wiki 0.2.3 no longer writes downloaded browser libraries into this package directory. The web setup and `setup-vendor.rkt` store them below the configured wiki data directory at `static/vendor/`.
|
||||||
|
|
||||||
|
Pinned sources:
|
||||||
|
|
||||||
|
- EasyMDE 2.21.0: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js`
|
||||||
|
- EasyMDE 2.21.0 CSS: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css`
|
||||||
|
- DOMPurify 3.4.13: `https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js`
|
||||||
|
- highlight.js 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js`
|
||||||
|
- highlight.js Scheme grammar 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/languages/scheme.min.js`
|
||||||
|
- highlight.js GitHub style 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css`
|
||||||
|
- diff2html 3.4.56: `https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/js/diff2html-ui-base.min.js`
|
||||||
|
- diff2html 3.4.56 CSS: `https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css`
|
||||||
|
|
||||||
|
EasyMDE bundles CodeMirror and Marked, so no separate CodeMirror or Markdown renderer is downloaded.
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require db
|
||||||
|
racket/file
|
||||||
|
racket/string
|
||||||
|
"private/config.rkt"
|
||||||
|
"private/database.rkt")
|
||||||
|
|
||||||
|
(provide tr
|
||||||
|
translations-for
|
||||||
|
current-language
|
||||||
|
write-language!
|
||||||
|
translation-page-slug)
|
||||||
|
|
||||||
|
(define english
|
||||||
|
(hash
|
||||||
|
'users "Users" 'translations "Translations" 'todo "Todo"
|
||||||
|
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages"
|
||||||
|
'edit "Edit" '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"
|
||||||
|
'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."
|
||||||
|
'this-page-missing "This page does not exist yet." 'no-pages "No pages yet"
|
||||||
|
'view "View" 'compare-previous "Compare previous" 'new-password "New password"
|
||||||
|
'created "Created" 'modified "Modified" 'version "Version" 'tags-none "Tags: none"
|
||||||
|
'you-are-here "You are here"
|
||||||
|
'setup-title "Set up Racket Wiki" 'setup-description "Configure PostgreSQL, create the first administrator and install the browser libraries. This page does not require JavaScript."
|
||||||
|
'postgresql "PostgreSQL" 'server "Server" 'port "Port" 'database "Database" 'user "User"
|
||||||
|
'tls-ssl "TLS/SSL" 'ssl-no "No (typical localhost setup)" 'ssl-optional "Optional" 'ssl-required "Required"
|
||||||
|
'administrator "Administrator" 'administrator-username "Administrator username" 'administrator-password "Administrator password"
|
||||||
|
'repeat-password "Repeat password" 'ready "Ready" 'required "Required" 'administrator-account "Administrator account"
|
||||||
|
'frontend-libraries "Frontend libraries" 'will-download "Will be downloaded" 'complete-setup "Complete setup"
|
||||||
|
'tags-label "Tags: " 'page-address "Page address" 'page-address-generated "Page address will be generated from the title when you save."
|
||||||
|
'saving "Saving…" 'created-page "Created page" 'edited-page "Edited page" 'line "line"
|
||||||
|
'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote"
|
||||||
|
'bulleted-list "Bulleted list" 'numbered-list "Numbered list" 'checklist "Checklist" 'code-block "Code block"
|
||||||
|
'table "Table" 'link "Link" 'horizontal-rule "Horizontal rule" 'undo "Undo" 'redo "Redo"
|
||||||
|
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen"
|
||||||
|
'language "Language" 'language-en "English" 'language-nl "Dutch"
|
||||||
|
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
|
||||||
|
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
|
||||||
|
|
||||||
|
(define dutch
|
||||||
|
(hash
|
||||||
|
'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo"
|
||||||
|
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's"
|
||||||
|
'edit "Bewerken" '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 "Uitloggen" 'password "Wachtwoord"
|
||||||
|
'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."
|
||||||
|
'this-page-missing "Deze pagina bestaat nog niet." 'no-pages "Nog geen pagina's"
|
||||||
|
'view "Bekijken" 'compare-previous "Vergelijk vorige" 'new-password "Nieuw wachtwoord"
|
||||||
|
'created "Aangemaakt" 'modified "Gewijzigd" 'version "Versie" 'tags-none "Tags: geen"
|
||||||
|
'you-are-here "U bent hier"
|
||||||
|
'setup-title "Racket Wiki instellen" 'setup-description "Configureer PostgreSQL, maak de eerste beheerder aan en installeer de browserbibliotheken. Deze pagina heeft geen JavaScript nodig."
|
||||||
|
'postgresql "PostgreSQL" 'server "Server" 'port "Poort" 'database "Database" 'user "Gebruiker"
|
||||||
|
'tls-ssl "TLS/SSL" 'ssl-no "Nee (gebruikelijk op localhost)" 'ssl-optional "Optioneel" 'ssl-required "Verplicht"
|
||||||
|
'administrator "Beheerder" 'administrator-username "Gebruikersnaam beheerder" 'administrator-password "Wachtwoord beheerder"
|
||||||
|
'repeat-password "Herhaal wachtwoord" 'ready "Gereed" 'required "Verplicht" 'administrator-account "Beheerdersaccount"
|
||||||
|
'frontend-libraries "Frontendbibliotheken" 'will-download "Worden gedownload" 'complete-setup "Setup voltooien"
|
||||||
|
'tags-label "Tags: " 'page-address "Pagina-adres" 'page-address-generated "Het pagina-adres wordt bij opslaan uit de titel afgeleid."
|
||||||
|
'saving "Opslaan…" 'created-page "Pagina aangemaakt" 'edited-page "Pagina gewijzigd" 'line "regel"
|
||||||
|
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
|
||||||
|
'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok"
|
||||||
|
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw"
|
||||||
|
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm"
|
||||||
|
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
|
||||||
|
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
|
||||||
|
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))
|
||||||
|
|
||||||
|
(define (base-translations language)
|
||||||
|
(if (string-ci=? language "nl") dutch english))
|
||||||
|
(define (current-language config)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) (wiki-config-language config))))
|
||||||
|
(if (file-exists? (language-config-path config))
|
||||||
|
(call-with-input-file (language-config-path config)
|
||||||
|
(λ (in)
|
||||||
|
(define value (read in))
|
||||||
|
(if (string? value) value (wiki-config-language config))))
|
||||||
|
(wiki-config-language config))))
|
||||||
|
|
||||||
|
(define (write-language! config language)
|
||||||
|
(make-directory* (wiki-config-data-dir config))
|
||||||
|
(call-with-output-file (language-config-path config)
|
||||||
|
(λ (out)
|
||||||
|
(write language out)
|
||||||
|
(newline out))
|
||||||
|
#:exists 'truncate/replace)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
|
||||||
|
(define (translation-page-slug language)
|
||||||
|
(string-append "wiki-translations-" (string-downcase language)))
|
||||||
|
|
||||||
|
(define (parse-overrides markdown)
|
||||||
|
(for/fold ((result (hash)))
|
||||||
|
((line (in-list (string-split markdown "\n"))))
|
||||||
|
(define match
|
||||||
|
(regexp-match #px"^\\s*([A-Za-z0-9._-]+)\\s*=\\s*(.*?)\\s*$" line))
|
||||||
|
(if match
|
||||||
|
(hash-set result
|
||||||
|
(string->symbol (list-ref match 1))
|
||||||
|
(list-ref match 2))
|
||||||
|
result)))
|
||||||
|
|
||||||
|
(define (database-overrides config)
|
||||||
|
(with-handlers ((exn:fail? (λ (_e) (hash))))
|
||||||
|
(if (not (database-ready? config))
|
||||||
|
(hash)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(define markdown
|
||||||
|
(query-maybe-value db
|
||||||
|
"SELECT markdown FROM pages WHERE slug = $1 AND archived = FALSE"
|
||||||
|
(translation-page-slug (current-language config))))
|
||||||
|
(if markdown (parse-overrides markdown) (hash)))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Return all translations active for the configured wiki language.
|
||||||
|
; pre : config is a wiki-config value.
|
||||||
|
; post : Translation override page, when available, has only been read.
|
||||||
|
; result : A hash from translation symbols to strings.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (translations-for config)
|
||||||
|
(for/fold ((result (base-translations (current-language config))))
|
||||||
|
(((key value) (in-hash (database-overrides config))))
|
||||||
|
(hash-set result key value)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Translate one UI key for the configured wiki language.
|
||||||
|
; pre : key is a symbol.
|
||||||
|
; post : Translation data has only been read.
|
||||||
|
; result : The translated string, or the symbolic key when no translation exists.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (tr config key)
|
||||||
|
(hash-ref (translations-for config) key (symbol->string key)))
|
||||||
Reference in New Issue
Block a user