#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! rename-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! list-page-aliases list-page-alias-details cleanup-page-alias! delete-page-alias! 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") (define page-columns/prefixed "p.slug, p.title, p.markdown, p.created_at, p.updated_at, p.created_by, p.updated_by, p.tags, p.current_version, p.namespace") (define (page-id/db db namespace slug) (define current-id (query-maybe-value db "SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE" namespace slug)) (if current-id current-id (query-maybe-value db #<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)) (define resolved-row (if row row (query-maybe-row db (string-append "SELECT " page-columns/prefixed " FROM page_aliases a JOIN pages p ON p.id = a.page_id" " WHERE a.namespace = $1 AND a.slug = $2 AND p.archived = FALSE") namespace slug))) (if resolved-row (row->page resolved-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 (vector-ref row 3)) (when (and (not (eq? new-namespace #f)) (not (string=? (string-trim new-namespace) target-namespace))) (error 'update-page! "use rename-page! to change a page 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 namespace slug))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Rename or move a page while keeping its old address as an alias. ; pre : reference identifies a current page; target namespace/slug are valid and unused. ; post : The same page_id has the new address/title, the old address remains an alias, ; and a new immutable page version records the rename. ; result : The renamed page metadata with Markdown. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (rename-page! config reference title target-namespace target-slug author [summary "Renamed page"]) (define-values (namespace slug) (split-page-reference reference)) (define clean-namespace (string-trim target-namespace)) (define clean-slug (string-trim target-slug)) (unless (valid-page-reference? (page-reference clean-namespace clean-slug)) (error 'rename-page! "invalid page address: ~a" (page-reference clean-namespace clean-slug))) (when (string=? (string-trim title) "") (error 'rename-page! "title is required")) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define row (query-maybe-row db "SELECT id, title, markdown, tags, current_version FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE FOR UPDATE" namespace slug)) (unless row (error 'rename-page! "unknown page: ~a" reference)) (define page-id (vector-ref row 0)) (define old-title (vector-ref row 1)) (define markdown (vector-ref row 2)) (define tags (text->tags (vector-ref row 3))) (define current-version (vector-ref row 4)) (define address-changed? (or (not (string=? namespace clean-namespace)) (not (string=? slug clean-slug)))) (when address-changed? (define target-page-id (query-maybe-value db "SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE" clean-namespace clean-slug)) (when (and target-page-id (not (= target-page-id page-id))) (error 'rename-page! "page address is already in use: ~a" (page-reference clean-namespace clean-slug))) (define target-alias-page-id (query-maybe-value db "SELECT page_id FROM page_aliases WHERE namespace = $1 AND slug = $2" clean-namespace clean-slug)) (when (and target-alias-page-id (not (= target-alias-page-id page-id))) (error 'rename-page! "page address is already an alias: ~a" (page-reference clean-namespace clean-slug))) (when target-alias-page-id (query-exec db "DELETE FROM page_aliases WHERE namespace = $1 AND slug = $2 AND page_id = $3" clean-namespace clean-slug page-id)) (query-exec 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 id (page-id/db db namespace slug)) (define row (and id (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)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Page alias cleanup ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (title->wiki-word title) (define words '()) (define out (open-output-string)) (define (finish-word!) (define word (get-output-string out)) (when (> (string-length word) 0) (set! words (append words (list word)))) (set! out (open-output-string))) (for ((char (in-string title))) (if (char-alphabetic? char) (write-char char out) (finish-word!))) (finish-word!) (apply string-append (for/list ((word (in-list words))) (string-titlecase word)))) (define (classic-wiki-word? text) (regexp-match? #px"^(?:[A-Z][a-z]+){2,}$" text)) (define (alias-wiki-reference namespace title) (define wiki-word (title->wiki-word title)) (if (classic-wiki-word? wiki-word) (if (string=? namespace "") wiki-word (string-append namespace ":" wiki-word)) #f)) (define (replace-wiki-token line old-token new-text) (define out (open-output-string)) (define length (string-length line)) (let loop ((index 0)) (when (< index length) (define char (string-ref line index)) (if (or (char-alphabetic? char) (char=? char #\:)) (let find-end ((end index)) (if (and (< end length) (let ((candidate (string-ref line end))) (or (char-alphabetic? candidate) (char=? candidate #\:)))) (find-end (+ end 1)) (let ((token (substring line index end))) (display (if (string=? token old-token) new-text token) out) (loop end)))) (begin (write-char char out) (loop (+ index 1)))))) (get-output-string out)) (define (replace-alias-reference-in-line line old-reference new-reference old-wiki new-wiki) (define result line) (set! result (string-replace result (string-append "(" old-reference ")") (string-append "(" new-reference ")"))) (set! result (string-replace result (string-append "(" old-reference " ") (string-append "(" new-reference " "))) (set! result (string-replace result (string-append "/uploads/" old-reference "/") (string-append "/uploads/" new-reference "/"))) (if old-wiki (replace-wiki-token result old-wiki new-wiki) result)) (define (replace-alias-reference markdown old-namespace old-slug old-title new-namespace new-slug new-title) (define old-reference (page-reference old-namespace old-slug)) (define new-reference (page-reference new-namespace new-slug)) (define old-wiki (alias-wiki-reference old-namespace old-title)) (define target-wiki (alias-wiki-reference new-namespace new-title)) (define new-wiki (if target-wiki target-wiki (format "[~a](~a)" new-title new-reference))) (define in-fence? #f) (define result (for/list ((line (in-list (string-split markdown "\n" #:trim? #f)))) (define fence? (regexp-match? #px"^[ \t]*(```|~~~)" line)) (cond (fence? (set! in-fence? (not in-fence?)) line) ((or in-fence? (string-prefix? line " ") (string-prefix? line "\t") (string-contains? line "`")) line) (else (replace-alias-reference-in-line line old-reference new-reference old-wiki new-wiki))))) (string-join result "\n")) (define (page-alias-row db alias-id) (query-maybe-row db #<tags (vector-ref row 6)) 'changed (not (string=? markdown replaced)))))) (define (alias-historical-reference-pages db alias-row) (define old-namespace (vector-ref alias-row 1)) (define old-slug (vector-ref alias-row 2)) (define old-title (vector-ref alias-row 3)) (define new-namespace (vector-ref alias-row 5)) (define new-slug (vector-ref alias-row 6)) (define new-title (vector-ref alias-row 7)) (filter (λ (item) (hash-ref item 'changed #f)) (for/list ((row (in-list (query-rows db #< ~a" old-reference new-reference)) (query-exec db #< (length current-pages) 0) (error 'delete-page-alias! "page alias still has ~a current reference(s)" (length current-pages))) (query-exec db "DELETE FROM page_aliases WHERE id = $1" alias-id))))) (void)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List retained page aliases and current pages that still contain the old reference. ; pre : PostgreSQL schema 8 or newer is initialized. ; post : Alias and page rows have only been read. ; result : Newest-first alias hashes with canonical targets and literal reference pages. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-page-aliases config) (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db #<