#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; First-run setup state and web setup processing. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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 #<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))))