#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PostgreSQL-backed page, history, search, bookmark and upload storage. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require db json racket/file racket/list racket/path racket/string "attachment-references.rkt" "config.rkt" "database.rkt" "todo.rkt") (provide ensure-wiki-data! valid-slug? valid-page-reference? page-reference split-page-reference title->slug list-pages read-page create-page! update-page! archive-page! page-history read-version search-pages list-todos list-recent-pages list-bookmarks set-bookmark! delete-bookmark! list-orphaned-uploads delete-orphaned-upload! 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. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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 '("." ".."))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Build the external wiki reference for a namespace and slug. ; pre : namespace and slug are strings; slug is a valid page slug. ; post : No state is changed. ; result : slug for the root namespace, otherwise namespace:slug. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (page-reference namespace slug) (if (string=? (string-trim namespace) "") slug (string-append (string-trim namespace) ":" slug))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Split an external page reference into namespace and slug. ; pre : reference is a string. ; post : No state is changed. ; result : Two values: namespace and slug. The namespace is empty for root pages. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (split-page-reference reference) (define match (regexp-match #px"^([^:]+):(.*)$" reference)) (if match (values (list-ref match 1) (list-ref match 2)) (values "" reference))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Check whether a namespace-qualified page reference is valid. ; pre : reference is a string. ; post : No state is changed. ; result : #t for root slugs or namespace:slug references with letter/number namespaces. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (valid-page-reference? reference) (define-values (namespace slug) (split-page-reference reference)) (and (valid-slug? slug) (or (string=? namespace "") (and (valid-slug? namespace) (<= (string-length namespace) 80))))) (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 namespace (vector-ref row 9)) (define slug (vector-ref row 0)) (define result (hash 'slug (page-reference namespace slug) 'pageSlug slug 'namespace namespace '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, namespace") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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(namespace), namespace, 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 reference) (if (not (valid-page-reference? reference)) #f (let-values (((namespace slug) (split-page-reference reference))) (call-with-wiki-database config (λ (db) (define row (query-maybe-row db (string-append "SELECT " page-columns " FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE") namespace 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-value db #<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 reference title markdown author [summary "Created page"] [tags '()]) (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define now (current-seconds)) (define page-id (query-value db #<text tags) now author)) (define page-version-id (insert-version! db page-id 1 title markdown author "create" summary now tags)) (replace-todos! db page-id markdown) (replace-current-attachment-references! db page-id markdown now) (record-version-attachment-references! db page-id page-version-id markdown now))))) (read-page config reference)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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 reference title markdown author base-version [summary "Edited page"] [tags #f] [new-namespace #f]) (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define row (query-maybe-row db "SELECT id, current_version, tags, namespace FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE FOR UPDATE" namespace 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 target-namespace (if (eq? new-namespace #f) (vector-ref row 3) (string-trim new-namespace))) (unless (or (string=? target-namespace "") (and (valid-slug? target-namespace) (<= (string-length target-namespace) 80))) (error 'update-page! "invalid namespace: ~a" target-namespace)) (define next-version (+ current-version 1)) (define now (current-seconds)) (query-exec db #<text page-tags) next-version now author target-namespace (vector-ref row 0)) (define page-id (vector-ref row 0)) (define page-version-id (insert-version! db page-id next-version title markdown author "edit" summary now page-tags)) (replace-todos! db page-id markdown) (replace-current-attachment-references! db page-id markdown now) (record-version-attachment-references! db page-id page-version-id markdown now))))) (read-page config (page-reference (if (eq? new-namespace #f) namespace (string-trim new-namespace)) 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 reference author) (define-values (namespace slug) (split-page-reference reference)) (call-with-wiki-database config (λ (db) (define id (query-maybe-value db #<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 reference version) (define-values (namespace slug) (split-page-reference reference)) (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 #<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 #<page row #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List bookmarks for one wiki user. ; pre : PostgreSQL schema 4 or newer is initialized and user-id identifies a user. ; post : Bookmark and page rows have only been read. ; result : Bookmarks ordered by section and position. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-bookmarks config user-id) (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db #< current-count 0) (error 'delete-orphaned-upload! "attachment is still referenced by a current page")) (define deleted-id (query-maybe-value db "DELETE FROM attachments WHERE id = $1 RETURNING id" attachment-id)) (unless deleted-id (error 'delete-orphaned-upload! "unknown attachment: ~a" attachment-id)))))) (void)) (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 reference original-name content author) (define id (page-id config reference)) (unless id (error 'save-upload! "unknown page: ~a" reference)) (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 #<