Files
racket-wiki/private/storage.rkt
T

764 lines
30 KiB
Racket

#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
#<<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)
RETURNING id
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 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
#<<SQL
INSERT INTO pages(namespace, slug, title, markdown, tags, current_version,
created_at, updated_at, created_by, updated_by, search_document)
VALUES ($1, $2, $3, $4, $5, 1, $6, $6, $7, $7,
setweight(to_tsvector('simple', coalesce($3, '')), 'A') ||
setweight(to_tsvector('simple', coalesce($4, '')), 'B'))
RETURNING id
SQL
namespace slug title markdown (tags->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
#<<SQL
UPDATE pages
SET title = $1, markdown = $2, tags = $3, current_version = $4,
updated_at = $5, updated_by = $6, namespace = $7,
search_document = setweight(to_tsvector('simple', coalesce($1, '')), 'A') ||
setweight(to_tsvector('simple', coalesce($2, '')), 'B')
WHERE id = $8
SQL
title markdown (tags->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
#<<SQL
UPDATE pages
SET archived = TRUE, archived_at = $1, archived_by = $2
WHERE namespace = $3 AND slug = $4 AND archived = FALSE
RETURNING id
SQL
(current-seconds) author namespace slug))
(unless id
(error 'archive-page! "unknown page: ~a" slug))
(query-exec db
"DELETE FROM attachment_references WHERE page_id = $1 AND current_reference = TRUE"
id)))
(void))
(define (page-id config reference)
(define-values (namespace slug) (split-page-reference reference))
(call-with-wiki-database
config
(λ (db)
(query-maybe-value db
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
namespace 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 reference)
(define-values (namespace slug) (split-page-reference reference))
(call-with-wiki-database
config
(λ (db)
(define id
(query-maybe-value db "SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE" namespace 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 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
#<<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.namespace = $1 AND p.slug = $2 AND p.archived = FALSE AND v.version = $3
SQL
namespace 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,
p.namespace
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 (page-reference (vector-ref row 4) (vector-ref row 0))
'namespace (vector-ref row 4)
'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, p.namespace
FROM todo_items t
JOIN pages p ON p.id = t.page_id
WHERE p.archived = FALSE
ORDER BY lower(p.namespace), p.namespace, lower(p.title), p.title, t.item_number
SQL
))))
(hash 'slug (page-reference (vector-ref row 5) (vector-ref row 0))
'namespace (vector-ref row 5)
'title (vector-ref row 1)
'number (vector-ref row 2)
'line (vector-ref row 3)
'text (vector-ref row 4))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List recently edited current wiki pages.
; pre : PostgreSQL schema 1 or newer is initialized.
; post : Page rows have only been read.
; result : At most limit page metadata hashes, newest first.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-recent-pages config [limit 50])
(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 updated_at DESC, lower(title), title"
" LIMIT $1")
limit))))
(row->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
#<<SQL
SELECT p.slug, p.title, b.section, b.position, b.created_at, p.updated_at, p.namespace
FROM bookmarks b
JOIN pages p ON p.id = b.page_id
WHERE b.user_id = $1
AND p.archived = FALSE
ORDER BY lower(p.namespace), p.namespace, lower(b.section), b.section, b.position, b.created_at, lower(p.title), p.title
SQL
user-id))))
(hash 'slug (page-reference (vector-ref row 6) (vector-ref row 0))
'namespace (vector-ref row 6)
'title (vector-ref row 1)
'section (vector-ref row 2)
'position (vector-ref row 3)
'createdAt (vector-ref row 4)
'updatedAt (vector-ref row 5))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Add a page to a user's bookmarks or move it to another section.
; pre : PostgreSQL schema 4 or newer is initialized and slug identifies a current page.
; post : Exactly one bookmark exists for user-id and the page.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (set-bookmark! config user-id reference section)
(define-values (namespace slug) (split-page-reference reference))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define page-id
(query-maybe-value db
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
namespace slug))
(unless page-id
(error 'set-bookmark! "unknown page: ~a" slug))
(define clean-section (string-trim section))
(define position
(query-value db
#<<SQL
SELECT COALESCE(MAX(position), -1) + 1
FROM bookmarks
WHERE user_id = $1 AND section = $2
SQL
user-id
clean-section))
(query-exec db
#<<SQL
INSERT INTO bookmarks(user_id, page_id, section, position, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, page_id)
DO UPDATE SET section = EXCLUDED.section, position = EXCLUDED.position
SQL
user-id page-id clean-section position (current-seconds))))))
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Remove one page from a user's bookmarks.
; pre : PostgreSQL schema 4 or newer is initialized.
; post : The bookmark no longer exists; other bookmarks are unchanged.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (delete-bookmark! config user-id reference)
(define-values (namespace slug) (split-page-reference reference))
(call-with-wiki-database
config
(λ (db)
(query-exec db
#<<SQL
DELETE FROM bookmarks
WHERE user_id = $1
AND page_id = (SELECT id FROM pages WHERE namespace = $2 AND slug = $3)
SQL
user-id namespace slug)))
(void))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List uploads that no current page references.
; pre : PostgreSQL schema 6 or newer is initialized.
; post : Attachment and reference rows have only been read.
; result : A newest-first list with owner and last historical use metadata.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (list-orphaned-uploads config)
(call-with-wiki-database
config
(λ (db)
(for/list ((row (in-list
(query-rows db
#<<SQL
SELECT a.id,
a.original_name,
a.stored_name,
a.mime_type,
a.size,
a.uploaded_at,
a.uploaded_by,
owner.slug,
owner.title,
owner.namespace
FROM attachments a
JOIN pages owner ON owner.id = a.page_id
WHERE NOT EXISTS (
SELECT 1
FROM attachment_references current_ref
WHERE current_ref.attachment_id = a.id
AND current_ref.current_reference = TRUE
)
ORDER BY a.uploaded_at DESC, a.id DESC
SQL
))))
(define attachment-id (vector-ref row 0))
(define last-uses
(for/list ((use-row (in-list
(query-rows db
#<<SQL
SELECT p.slug, p.title, pv.version, ar.referenced_at, p.namespace
FROM attachment_references ar
JOIN pages p ON p.id = ar.page_id
LEFT JOIN page_versions pv ON pv.id = ar.page_version_id
WHERE ar.attachment_id = $1
AND ar.current_reference = FALSE
ORDER BY ar.referenced_at DESC, ar.id DESC
LIMIT 5
SQL
attachment-id))))
(hash 'slug (page-reference (vector-ref use-row 4) (vector-ref use-row 0))
'namespace (vector-ref use-row 4)
'title (vector-ref use-row 1)
'version (if (sql-null? (vector-ref use-row 2)) #f (vector-ref use-row 2))
'referencedAt (vector-ref use-row 3))))
(hash 'id attachment-id
'originalName (vector-ref row 1)
'storedName (vector-ref row 2)
'mimeType (vector-ref row 3)
'size (vector-ref row 4)
'uploadedAt (vector-ref row 5)
'uploadedBy (vector-ref row 6)
'ownerSlug (page-reference (vector-ref row 9) (vector-ref row 7))
'ownerNamespace (vector-ref row 9)
'ownerTitle (vector-ref row 8)
'lastUses last-uses)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Delete an upload only when no current page references it.
; pre : attachment-id identifies a possible attachment.
; post : The attachment and its reference rows are deleted, or an error is raised.
; result : void.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (delete-orphaned-upload! config attachment-id)
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(define current-count
(query-value db
"SELECT COUNT(*) FROM attachment_references WHERE attachment_id = $1 AND current_reference = TRUE"
attachment-id))
(when (> 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
#<<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" reference 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 reference stored-name)
(define-values (namespace slug) (split-page-reference reference))
(and (valid-page-reference? reference)
(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
ORDER BY CASE WHEN p.namespace = $3 THEN 0 ELSE 1 END, a.id
LIMIT 1
SQL
slug stored-name namespace))
(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)))))