diff options
| author | chers <admin@tilde.tailb619f6.ts.net> | 2026-07-31 09:18:39 +0000 |
|---|---|---|
| committer | chers <admin@tilde.tailb619f6.ts.net> | 2026-07-31 09:18:39 +0000 |
| commit | 7023e4c7c8f158df78b5e71ddc2640290409eca1 (patch) | |
| tree | 077fb46bdb484bbbf7f9a81fd1d512f66650be10 /server | |
Diffstat (limited to 'server')
| -rw-r--r-- | server/admin.lisp | 574 | ||||
| -rw-r--r-- | server/handlers.lisp | 903 | ||||
| -rw-r--r-- | server/main.lisp | 61 | ||||
| -rw-r--r-- | server/models.lisp | 35 | ||||
| -rw-r--r-- | server/rss.lisp | 122 | ||||
| -rw-r--r-- | server/storage.lisp | 43 | ||||
| -rw-r--r-- | server/views.lisp | 1009 |
7 files changed, 2747 insertions, 0 deletions
diff --git a/server/admin.lisp b/server/admin.lisp new file mode 100644 index 0000000..3864fad --- /dev/null +++ b/server/admin.lisp @@ -0,0 +1,574 @@ +(defpackage :cl-bbs-admin + (:use :cl) + (:export #:main)) + +(in-package :cl-bbs-admin) + +(defun expand-tilde (path) + (if (and (>= (length path) 2) (string= (subseq path 0 2) "~/")) + (concatenate 'string (namestring (user-homedir-pathname)) (subseq path 2)) + path)) + +(defun get-data-dir () + (let ((env (uiop:getenv "SBBS_DATADIR"))) + (if (and env (not (string= env ""))) + (namestring (uiop:ensure-absolute-pathname (expand-tilde env) (uiop:getcwd))) + (let ((root-dir (asdf:system-source-directory :cl-bbs/server))) + (if root-dir + (namestring (merge-pathnames "data/" root-dir)) + (namestring (merge-pathnames "bbs/" (user-homedir-pathname)))))))) + +(defun lookup-def (key alist) + (let ((p (assoc key alist :test (lambda (k1 k2) (string-equal (string k1) (string k2)))))) + (if p + (let ((res (cdr p))) + (cond ((string-equal (string key) "posts") res) + ((not (consp res)) res) + ((null (cdr res)) (car res)) + (t res))) + nil))) + +(defun last-element (lst) + (car (last lst))) + +(defun take-right (lst n) + (let ((len (length lst))) + (if (<= len n) + lst + (nthcdr (- len n) lst)))) + +(defun take (lst n) + (if (or (null lst) (<= n 0)) + nil + (cons (car lst) (take (cdr lst) (1- n))))) + +(defun latest-posts (posts) + (if (> (length posts) 6) + `((cl-bbs/models:truncated . ,(mapcar #'car (butlast (cdr posts) 5))) + (cl-bbs/models:posts ,(cons (car posts) (take-right posts 5)))) + `((cl-bbs/models:truncated . nil) (cl-bbs/models:posts ,posts)))) + +(defun get-flat-posts (posts) + (when posts + (let* ((cdr-val posts) + (cadr-val (and (listp cdr-val) (car cdr-val)))) + (if (and (listp cadr-val) (listp (car cadr-val))) + cadr-val + cdr-val)))) + +(defun build-list-entry (t-entry) + (let* ((id (car t-entry)) + (thread-data (cdr t-entry)) + (headline (lookup-def 'cl-bbs/models:headline thread-data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts thread-data))) + (last-post (last-element posts)) + (date (lookup-def 'cl-bbs/models:date (cdr last-post))) + (messages (length posts))) + `(,id (cl-bbs/models:headline . ,headline) (cl-bbs/models:date . ,date) (cl-bbs/models:messages . ,messages)))) + +(defun build-index-entry (t-entry) + (let* ((id (car t-entry)) + (thread-data (cdr t-entry)) + (headline (lookup-def 'cl-bbs/models:headline thread-data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts thread-data)))) + `(,id (cl-bbs/models:headline . ,headline) . ,(latest-posts posts)))) + +(defun read-sexp-file (path) + (with-open-file (stream path :direction :input :if-does-not-exist nil) + (if stream + (let ((*read-eval* nil)) + (read stream nil nil)) + nil))) + +(defun write-sexp-file (path data) + (with-open-file (stream path :direction :output :if-exists :supersede + :if-does-not-exist :create + :external-format :utf-8) + (write data :stream stream :pretty t) + (terpri stream))) + +(defun get-threads (dir) + (let ((threads nil)) + (loop for i from 1 + for misses = 0 then (if exists 0 (1+ misses)) + for filepath = (format nil "~A~D" dir i) + for exists = (probe-file filepath) + while (<= misses 200) + do (when exists + (let ((data (read-sexp-file filepath))) + (when data + (push (cons i data) threads))))) + threads)) + +(defun generate-index (board) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (dir (format nil "~A/sexp/~A/" data-dir board)) + (threads-data (get-threads dir))) + (unless threads-data + (format t "No threads found for board ~A.~%" board) + (return-from generate-index nil)) + (let* ((sorted-threads + (sort threads-data + (lambda (a b) + (let* ((posts-a (get-flat-posts (lookup-def 'cl-bbs/models:posts (cdr a)))) + (posts-b (get-flat-posts (lookup-def 'cl-bbs/models:posts (cdr b)))) + (date-a (or (lookup-def 'cl-bbs/models:date (cdr (last-element posts-a))) "")) + (date-b (or (lookup-def 'cl-bbs/models:date (cdr (last-element posts-b))) ""))) + (string> date-a date-b))))) + (list-entries (mapcar #'build-list-entry sorted-threads)) + (frontpage-count 10) + (index-entries (mapcar #'build-index-entry + (if (> (length sorted-threads) frontpage-count) + (take sorted-threads frontpage-count) + sorted-threads))) + (list-path (format nil "~Alist" dir)) + (index-path (format nil "~Aindex" dir))) + (write-sexp-file list-path list-entries) + (write-sexp-file index-path index-entries) + (format t "Generated list and index for ~A~%" board)))) + +(defun get-iso-datetime () + (multiple-value-bind (second minute hour date month year) + (get-decoded-time) + (format nil "~4,'0D-~2,'0D-~2,'0DT~2,'0D:~2,'0D:~2,'0D" + year month date hour minute second))) + +(defun read-backup-metadata (archive-name) + (let ((output (make-string-output-stream))) + (handler-case + (progn + (uiop:run-program (list "tar" "-xzf" archive-name "-O" "metadata.txt") + :output output :error-output nil) + (string-trim '(#\Space #\Tab #\Newline #\Return) (get-output-stream-string output))) + (error () "")))) + +(defun list-backups () + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (backup-dir (format nil "~A/backup" data-dir)) + (backups (and (probe-file backup-dir) + (sort (directory (format nil "~A/*.tar.gz" backup-dir)) + #'string> :key #'namestring)))) + (if backups + (progn + (format t "Available backups:~%") + (dolist (b backups) + (let ((meta (read-backup-metadata (namestring b)))) + (format t " ~A ~A~%" (file-namestring b) + (if (and meta (not (string= meta ""))) + (format nil "- ~A" meta) + ""))))) + (format t "No backups found.~%")))) + +(defun ask-confirmation (prompt) + (format t "~A [y/N]: " prompt) + (force-output) + (let ((response (read-line *standard-input* nil ""))) + (or (string-equal response "y") + (string-equal response "yes")))) + +(defun backup (&optional arg1) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (backup-dir (format nil "~A/backup" data-dir)) + (is-filename (and arg1 (uiop:string-suffix-p arg1 ".tar.gz"))) + (message (if is-filename nil arg1)) + (archive-name (if is-filename + arg1 + (format nil "~A/sbbs-~A.tar.gz" backup-dir (get-iso-datetime)))) + (metadata-file (format nil "~A/metadata.txt" backup-dir))) + (ensure-directories-exist (format nil "~A/" backup-dir)) + (when message + (with-open-file (stream metadata-file :direction :output :if-does-not-exist :create :if-exists :supersede) + (write-line message stream))) + (if message + (uiop:run-program (list "tar" "-czf" archive-name "-C" data-dir "sexp" "-C" backup-dir "metadata.txt") + :output *standard-output* :error-output *error-output*) + (uiop:run-program (list "tar" "-czf" archive-name "-C" data-dir "sexp") + :output *standard-output* :error-output *error-output*)) + (when (and message (probe-file metadata-file)) + (delete-file metadata-file)) + (format t "Backup created: ~A~%" archive-name))) + +(defun restore (&optional archive-name) + (if archive-name + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (backup-dir (format nil "~A/backup" data-dir)) + (archive-path (if (probe-file archive-name) + archive-name + (format nil "~A/~A" backup-dir archive-name)))) + (if (probe-file archive-path) + (progn + (uiop:run-program (list "tar" "-xzf" archive-path "-C" data-dir) + :output *standard-output* :error-output *error-output*) + (format t "Restore completed from: ~A~%" archive-path)) + (format t "Archive not found: ~A~%" archive-name))) + (list-backups))) + +(defun remove-post (board post-id) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-file (format nil "~A/sexp/~A/~A" data-dir board post-id))) + (if (probe-file sexp-file) + (let* ((data (read-sexp-file sexp-file)) + (headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (first-post (car posts)) + (date (lookup-def 'cl-bbs/models:date (cdr first-post))) + (content (lookup-def 'cl-bbs/models:content (cdr first-post)))) + (format t "Post Headline: ~A~%" headline) + (format t "Post Date: ~A~%" date) + (format t "Post Content:~%~A~%" content) + (when (ask-confirmation (format nil "Are you sure you want to remove thread ~A?" post-id)) + (backup (format nil "Before removing thread ~A from board ~A" post-id board)) + (delete-file sexp-file) + (format t "Removed thread ~A.~%" sexp-file) + (generate-index board))) + (format t "Thread ~A does not exist.~%" sexp-file)))) + +(defun remove-comment (board post-id comment-id-str) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-file (format nil "~A/sexp/~A/~A" data-dir board post-id)) + (comment-id (parse-integer comment-id-str :junk-allowed t))) + (unless (probe-file sexp-file) + (format t "Thread ~A does not exist.~%" sexp-file) + (return-from remove-comment nil)) + (let* ((data (read-sexp-file sexp-file)) + (headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (comment (find comment-id posts :key #'car))) + (if (not comment) + (format t "Comment ~A not found in thread ~A.~%" comment-id post-id) + (let ((comment-content (lookup-def 'cl-bbs/models:content (cdr comment))) + (comment-date (lookup-def 'cl-bbs/models:date (cdr comment)))) + (format t "Post Headline: ~A~%" headline) + (format t "Comment Date: ~A~%" comment-date) + (format t "Comment Content:~%~A~%" comment-content) + (when (ask-confirmation (format nil "Are you sure you want to remove comment ~A from thread ~A?" + comment-id post-id)) + (backup (format nil "Before removing comment ~A from thread ~A on board ~A" comment-id post-id board)) + (let ((new-posts (remove-if (lambda (p) (= (car p) comment-id)) posts))) + (if (null new-posts) + (progn + (when (probe-file sexp-file) (delete-file sexp-file)) + (format t "Removed thread ~A entirely as it has no remaining comments.~%" post-id)) + (progn + (write-sexp-file sexp-file `((cl-bbs/models:headline . ,headline) + (cl-bbs/models:posts ,new-posts))) + (format t "Removed comment ~A from thread ~A.~%" comment-id post-id))) + (generate-index board)))))))) + +(defun edit-comment (board post-id comment-id-str) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-file (format nil "~A/sexp/~A/~A" data-dir board post-id)) + (comment-id (parse-integer comment-id-str :junk-allowed t))) + (unless (probe-file sexp-file) + (format t "Thread ~A does not exist.~%" sexp-file) + (return-from edit-comment nil)) + (let* ((data (read-sexp-file sexp-file)) + (headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (comment (find comment-id posts :key #'car))) + (if (not comment) + (format t "Comment ~A not found in thread ~A.~%" comment-id post-id) + (let* ((content-cell (assoc 'cl-bbs/models:content (cdr comment))) + (content-value (cdr content-cell))) + (uiop:with-temporary-file (:pathname tmp-path :keep t) + (with-open-file (stream tmp-path :direction :output :if-exists :supersede + :external-format :utf-8) + (write-line content-value stream)) + (let ((editor (or (uiop:getenv "EDITOR") "vi"))) + (format t "Opening ~A with ~A...~%" tmp-path editor) + (uiop:run-program (format nil "~A ~A" editor (namestring tmp-path)) + :output :interactive + :input :interactive + :error-output :interactive) + (let ((new-content-value (uiop:read-file-string tmp-path))) + (delete-file tmp-path) + (when (and new-content-value (string/= new-content-value "")) + (backup (format nil "Before editing comment ~A from thread ~A on board ~A" + comment-id post-id board)) + (let ((new-posts (mapcar (lambda (p) + (if (= (car p) comment-id) + (cons (car p) + (mapcar (lambda (kv) + (if (eq (car kv) 'cl-bbs/models:content) + (cons (car kv) + (string-right-trim '(#\Newline #\Return) + new-content-value)) + kv)) + (cdr p))) + p)) + posts))) + (write-sexp-file sexp-file `((cl-bbs/models:headline . ,headline) + (cl-bbs/models:posts ,new-posts))) + (format t "Edited comment ~A from thread ~A.~%" comment-id post-id) + (generate-index board))))))))))) + +(defun get-next-post-id (board) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-dir (format nil "~A/sexp/~A/" data-dir board)) + (files (and (probe-file sexp-dir) + (uiop:directory-files (uiop:ensure-absolute-pathname sexp-dir (uiop:getcwd))))) + (max-id 0)) + (dolist (file files) + (let ((id (handler-case (parse-integer (pathname-name file)) + (error () nil)))) + (when (and id (> id max-id)) + (setf max-id id)))) + (1+ max-id))) + +(defun move-post (source-board post-id target-board) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (source-file (format nil "~A/sexp/~A/~A" data-dir source-board post-id)) + (target-dir (format nil "~A/sexp/~A/" data-dir target-board))) + (if (probe-file source-file) + (progn + (unless (uiop:directory-exists-p (uiop:ensure-absolute-pathname target-dir (uiop:getcwd))) + (format t "Target board ~A does not exist.~%" target-board) + (return-from move-post nil)) + (let* ((new-id (get-next-post-id target-board)) + (target-file (format nil "~A~A" target-dir new-id))) + (backup (format nil "Before moving thread ~A from ~A to ~A as ~A" post-id source-board target-board new-id)) + (uiop:copy-file source-file target-file) + (delete-file source-file) + (format t "Moved thread ~A from ~A to ~A as thread ~A.~%" post-id source-board target-board new-id) + (generate-index source-board) + (generate-index target-board))) + (format t "Thread ~A does not exist in board ~A.~%" post-id source-board)))) + +(defun parse-post-date (date-string) + (let ((year (parse-integer date-string :start 0 :end 4)) + (month (parse-integer date-string :start 5 :end 7)) + (day (parse-integer date-string :start 8 :end 10)) + (hour (parse-integer date-string :start 11 :end 13)) + (minute (parse-integer date-string :start 14 :end 16))) + (encode-universal-time 0 minute hour day month year 0))) + +(defun format-post-date (universal-time) + (multiple-value-bind (second minute hour day month year) + (decode-universal-time universal-time 0) + (declare (ignore second)) + (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D" year month day hour minute))) + +(defun add-timezone-offset (date-string offset-hours) + (let* ((ut (parse-post-date date-string)) + (new-ut (+ ut (* offset-hours 3600)))) + (format-post-date new-ut))) + +(defun list-all (&optional board post-id-str) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-dir (format nil "~A/sexp/" data-dir))) + (cond + ((null board) + (let ((boards (and (probe-file sexp-dir) + (uiop:subdirectories (uiop:ensure-absolute-pathname sexp-dir (uiop:getcwd)))))) + (if boards + (progn + (format t "Boards:~%") + (dolist (b-path boards) + (format t " ~A~%" (car (last (pathname-directory b-path)))))) + (format t "No boards found in ~A~%" sexp-dir)))) + ((and board post-id-str) + (let ((sexp-file (format nil "~A/sexp/~A/~A" data-dir board post-id-str)) + (post-id (parse-integer post-id-str :junk-allowed t))) + (if (and post-id (probe-file sexp-file)) + (let* ((data (read-sexp-file sexp-file)) + (headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data)))) + (format t "Thread ~A: ~A~%" post-id-str headline) + (dolist (p posts) + (let* ((cid (car p)) + (pdata (cdr p)) + (date (lookup-def 'cl-bbs/models:date pdata)) + (author (lookup-def 'cl-bbs/models:name pdata)) + (content (lookup-def 'cl-bbs/models:content pdata))) + (format t "~%[~A] #~A by ~A~%" date cid (or author "Anonymous")) + (format t "~A~%" content)))) + (format t "Thread ~A not found in board ~A.~%" post-id-str board)))) + (board + (let* ((board-dir (format nil "~A/sexp/~A/" data-dir board)) + (threads-data (get-threads board-dir))) + (if threads-data + (let ((sorted (sort threads-data + (lambda (a b) + (let* ((posts-a (get-flat-posts (lookup-def 'cl-bbs/models:posts (cdr a)))) + (last-a (last-element posts-a)) + (date-a (lookup-def 'cl-bbs/models:date (cdr last-a))) + (ut-a (parse-post-date date-a)) + (posts-b (get-flat-posts (lookup-def 'cl-bbs/models:posts (cdr b)))) + (last-b (last-element posts-b)) + (date-b (lookup-def 'cl-bbs/models:date (cdr last-b))) + (ut-b (parse-post-date date-b))) + (> ut-a ut-b)))))) + (progn + (format t "Threads in board ~A:~%" board) + (format t "~10A ~20A ~A~%" "ID" "Last Update" "Headline") + (format t "~v@{~A~:*~}~%" 60 "-") + (dolist (t-data sorted) + (let* ((tid (car t-data)) + (t-content (cdr t-data)) + (headline (lookup-def 'cl-bbs/models:headline t-content)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts t-content))) + (last-date (lookup-def 'cl-bbs/models:date (cdr (last-element posts))))) + (format t "~10D ~20A ~A~%" tid last-date headline))))) + (format t "No threads found in board ~A.~%" board))))))) + +(defun set-timezone (offset-hours) + (backup (format nil "Before setting timezone offset to ~D" offset-hours)) + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-dir (format nil "~A/sexp/" data-dir)) + (boards (uiop:subdirectories (uiop:ensure-absolute-pathname sexp-dir (uiop:getcwd))))) + (dolist (board-path boards) + (let ((board (car (last (pathname-directory board-path)))) + (threads (uiop:directory-files board-path))) + (dolist (thread threads) + (when (handler-case (parse-integer (pathname-name thread)) + (error () nil)) + (let* ((data (read-sexp-file thread)) + (headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (new-posts (mapcar (lambda (p) + (let* ((post-id (car p)) + (post-data (cdr p)) + (old-date (lookup-def 'cl-bbs/models:date post-data)) + (new-date (add-timezone-offset old-date offset-hours))) + (cons post-id + (mapcar (lambda (kv) + (if (eq (car kv) 'cl-bbs/models:date) + (cons (car kv) new-date) + kv)) + post-data)))) + posts))) + (write-sexp-file thread `((cl-bbs/models:headline . ,headline) (cl-bbs/models:posts ,new-posts))))) + (generate-index board)))))) + +(defun find-duplicates (posts) + (let ((duplicates nil) + (prev-post (car posts))) + (dolist (curr-post (cdr posts)) + (let ((prev-content (lookup-def 'cl-bbs/models:content (cdr prev-post))) + (curr-content (lookup-def 'cl-bbs/models:content (cdr curr-post)))) + (if (equal prev-content curr-content) + (push curr-post duplicates) + (setf prev-post curr-post)))) + (nreverse duplicates))) + +(defun remove-duplicates-command () + (let* ((data-dir (string-right-trim "/" (get-data-dir))) + (sexp-dir (format nil "~A/sexp/" data-dir)) + (boards (uiop:subdirectories (uiop:ensure-absolute-pathname sexp-dir (uiop:getcwd)))) + (all-duplicates nil)) + (dolist (board-path boards) + (let ((board (car (last (pathname-directory board-path)))) + (threads (uiop:directory-files board-path))) + (dolist (thread-path threads) + (let ((thread-id (pathname-name thread-path))) + (when (handler-case (parse-integer thread-id) + (error () nil)) + (let* ((data (read-sexp-file thread-path)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (dups (find-duplicates posts))) + (when dups + (push (list board thread-id dups thread-path data) all-duplicates))))))) + (if (null all-duplicates) + (format t "No sequential duplicate comments found.~%") + (progn + (format t "Found sequential duplicate comments:~%~%") + (dolist (entry (reverse all-duplicates)) + (destructuring-bind (board thread-id dups thread-path data) entry + (declare (ignore thread-path data)) + (format t "Board: ~A, Thread: ~A~%" board thread-id) + (dolist (dup dups) + (let ((comment-id (car dup)) + (content (lookup-def 'cl-bbs/models:content (cdr dup)))) + (format t " - Duplicate Comment ID: ~A~%" comment-id) + (format t " Content: ~A~%" content))))) + (format t "~%") + (when (ask-confirmation "Are you sure you want to delete these duplicate comments?") + (backup "Before removing sequential duplicate comments") + (let ((affected-boards nil)) + (dolist (entry all-duplicates) + (destructuring-bind (board thread-id dups thread-path data) entry + (let* ((headline (lookup-def 'cl-bbs/models:headline data)) + (posts (get-flat-posts (lookup-def 'cl-bbs/models:posts data))) + (dup-ids (mapcar #'car dups)) + (new-posts (remove-if (lambda (p) (member (car p) dup-ids)) posts))) + (write-sexp-file thread-path `((cl-bbs/models:headline . ,headline) + (cl-bbs/models:posts ,new-posts))) + (pushnew board affected-boards :test #'string-equal) + (format t "Removed ~D duplicates from ~A/~A~%" (length dups) board thread-id)))) + (dolist (board affected-boards) + (generate-index board))))))))) + +(defun print-help () + (format t "Usage: cl-bbs-admin <command> [args...]~%~%") + (format t "Commands:~%") + (format t " generate-index <board> Regenerate index and list files for a board~%") + (format t " list [board] [post-id] List boards, threads in a board, or comments in a thread~%") + (format t " remove-post <board> <post-id> Remove an entire thread/post~%") + (format t " remove-duplicates Remove sequential duplicate comments across all boards~%") + (format t " remove-comment <board> <post-id> <comment-id> Remove a specific comment from a thread~%") + (format t " edit <board> <post-id> <comment-id> Edit a specific comment using $EDITOR~%") + (format t " move <source-board> <post-id> <target-board> Move a thread to a different board~%") + (format t " backup [\"description msg\"] Backup the sexp directory to a tarball~%") + (format t " restore [archive.tar.gz] Restore or list backups~%") + (format t " set-timezone <int> Offset all posts timezone and recreate indices~%~%") + (format t "Environment Variables:~%") + (format t " SBBS_DATADIR Path to data directory (default: project data/)~%")) + +(defun main (&rest argv) + "Main entry point for cl-bbs-admin command line interface, parsing and dispatching command line arguments ARGV." + (handler-case + (if (null argv) + (print-help) + (let ((cmd (first argv))) + (cond + ((string= cmd "list") + (list-all (second argv) (third argv))) + ((string= cmd "generate-index") + (if (>= (length argv) 2) + (generate-index (second argv)) + (format t "Usage: cl-bbs-admin generate-index <board>~%"))) + ((string= cmd "remove-post") + (if (>= (length argv) 3) + (remove-post (second argv) (third argv)) + (format t "Usage: cl-bbs-admin remove-post <board> <post-id>~%"))) + ((string= cmd "remove-duplicates") + (remove-duplicates-command)) + ((string= cmd "remove-comment") + (if (>= (length argv) 4) + (remove-comment (second argv) (third argv) (fourth argv)) + (format t "Usage: cl-bbs-admin remove-comment <board> <post-id> <comment-id>~%"))) + ((string= cmd "edit") + (if (>= (length argv) 4) + (edit-comment (second argv) (third argv) (fourth argv)) + (format t "Usage: cl-bbs-admin edit <board> <post-id> <comment-id>~%"))) + ((string= cmd "move") + (if (>= (length argv) 4) + (move-post (second argv) (third argv) (fourth argv)) + (format t "Usage: cl-bbs-admin move <source-board> <post-id> <target-board>~%"))) + ((string= cmd "backup") + (if (>= (length argv) 2) + (backup (format nil "~{~A~^ ~}" (cdr argv))) + (backup))) + ((string= cmd "restore") + (if (>= (length argv) 2) + (restore (second argv)) + (restore))) + ((string= cmd "set-timezone") + (if (>= (length argv) 2) + (let ((offset (parse-integer (second argv) :junk-allowed t))) + (if offset + (set-timezone offset) + (format t "Invalid timezone offset: ~A~%" (second argv)))) + (format t "Usage: cl-bbs-admin set-timezone <int>~%"))) + (t + (format t "Unknown command: ~A~%" cmd) + (print-help))))) + (#+sbcl sb-sys:interactive-interrupt + #+ccl ccl:interrupt-condition + #+clisp system:simple-interrupt-condition + #+ecl ext:interactive-interrupt + #+allegro excl:interrupt-signal + () + (progn + (format *error-output* "~&Interrupted.~%") + (uiop:quit 1))))) diff --git a/server/handlers.lisp b/server/handlers.lisp new file mode 100644 index 0000000..11ec701 --- /dev/null +++ b/server/handlers.lisp @@ -0,0 +1,903 @@ +(defpackage :cl-bbs/handlers + (:use :cl) + (:import-from :cl-bbs/storage + #:*base-dir* + #:read-sexp-file + #:write-sexp-file + #:is-board-locked + #:ensure-board-dirs) + (:import-from :cl-bbs/rss + #:generate-rss + #:get-all-boards-rss-threads) + (:import-from :cl-bbs/views + #:render-moderation + #:render-index + #:render-list + #:render-preferences + #:render-error-page + #:render-thread + #:render-search-results + #:render-playground) + (:export #:handle-request + #:*headline-limit* + #:*body-limit*)) + +(in-package :cl-bbs/handlers) + +(defparameter *headline-limit* + (or (and (uiop:getenv "SBBS_HEADLINE_LIMIT") + (parse-integer (uiop:getenv "SBBS_HEADLINE_LIMIT") :junk-allowed t)) + 128) + "Maximum character limit for post headlines.") + +(defparameter *body-limit* + (or (and (uiop:getenv "SBBS_BODY_LIMIT") + (parse-integer (uiop:getenv "SBBS_BODY_LIMIT") :junk-allowed t)) + 4096) + "Maximum character limit for post bodies.") + +(defun parse-cookies (cookie-string) + "Parses a Cookie header string like 'theme=dark; foo=bar' into an alist." + (when cookie-string + (let ((cookies '())) + (dolist (part (cl-ppcre:split ";" cookie-string)) + (let ((pair (cl-ppcre:split "=" (string-trim '(#\Space #\Tab #\Newline #\Return) part) :limit 2))) + (when (= (length pair) 2) + (let ((name (string-trim '(#\Space #\Tab #\Newline #\Return) (first pair))) + (val (string-trim '(#\Space #\Tab #\Newline #\Return) (second pair)))) + (push (cons name val) cookies))))) + (nreverse cookies)))) + +(defun sanitize-theme-name (theme-str) + "Validates and returns theme-str if it consists only of alphanumeric characters, +hyphens, and underscores. Otherwise returns nil to prevent injection/directory traversal." + (when (and theme-str (cl-ppcre:scan "^[a-zA-Z0-9_-]+$" theme-str)) + theme-str)) + +(defun sanitize-board-name (board-str) + "Validates and returns board-str if it consists only of alphanumeric characters, +hyphens, and underscores. Otherwise returns nil to prevent injection/directory traversal." + (when (and board-str (cl-ppcre:scan "^[a-zA-Z0-9_-]+$" board-str)) + board-str)) + +(defun get-default-board-from-env (env) + "Retrieves the default board name from the request cookies." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-board (sanitize-board-name (cdr (assoc "default_board" cookies :test #'string=))))) + (and (not (string= cookie-board "")) cookie-board))) + +(defun get-theme-from-env (env) + "Retrieves the active theme name from the request query parameters or cookies." + (let* ((query-str (getf env :query-string)) + (query-theme (when (and query-str (not (string= query-str ""))) + (let ((params (quri:url-decode-params query-str))) + (sanitize-theme-name (cdr (assoc "theme" params :test #'string=))))))) + (or query-theme + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-theme (sanitize-theme-name (cdr (assoc "theme" cookies :test #'string=))))) + (or cookie-theme "default"))))) + +(defun get-syntax-theme-from-env (env) + "Retrieves the active syntax theme name from the request cookies." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-theme (sanitize-theme-name (cdr (assoc "syntax_theme" cookies :test #'string=))))) + (or cookie-theme "simple"))) + +(defun get-search-hide-input-from-env (env) + "Retrieves the setting for hiding search input in board view (returns \"yes\" or \"no\", default \"no\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_hide_input" cookies :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + +(defun get-search-local-only-from-env (env) + "Retrieves the setting for forcing local search in current board (returns \"yes\" or \"no\", default \"no\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_local_only" cookies :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + +(defun get-search-position-from-env (env) + "Retrieves the setting for search input placement (returns \"top\" or \"bottom\", default \"top\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_position" cookies :test #'string=)))) + (if (member val '("top" "bottom") :test #'string=) val "top"))) + +(defun get-board-description (board-name) + "Maps a board-name string to a friendly human-readable title." + (cond + ((string= board-name "prog") "Programming") + ((string= board-name "foo") "Foo Reference") + ((string= board-name "b") "random") + (t (string-capitalize board-name)))) + +(defun generate-boards-html-list () + (let* ((sexp-dir (merge-pathnames "sexp/" cl-bbs/storage:*base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) + (car (last (pathname-directory path)))) + paths) + #'string<)) + (html-list '())) + (dolist (b boards) + (push (format nil "<li><a href=\"/~a/\">/~a/ - ~a</a></li>" b b (get-board-description b)) html-list)) + (format nil "<ul>~{~A~%~}</ul>" (nreverse html-list)))) + +(defun authenticate-admin (env) + "Checks if the request has valid admin credentials in environment variables." + (let* ((admin-user (or (uiop:getenv "SBBS_ADMIN_USER") "admin")) + (admin-password (or (uiop:getenv "SBBS_ADMIN_PASSWORD") "superchanner")) + (headers (getf env :headers)) + (auth-str (and headers (gethash "authorization" headers))) + (creds (and auth-str (cl-ppcre:regex-replace-all "^Basic " auth-str "")))) + (when creds + (let* ((decoded (handler-case (cl-base64:base64-string-to-string creds) + (error () nil))) + (parts (and decoded (cl-ppcre:split ":" decoded :limit 2)))) + (and (= (length parts) 2) + (string= (first parts) admin-user) + (string= (second parts) admin-password)))))) + +(defun delete-board-dir (board) + (let ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*))) + (when (probe-file board-dir) + (uiop:delete-directory-tree board-dir :validate t)))) + +(defun get-board-threads (dir) + (let ((threads nil)) + (loop for i from 1 + for filepath = (merge-pathnames (format nil "~D" i) dir) + for exists = (probe-file filepath) + while (or exists (<= i 200)) + do (when exists + (let ((data (read-sexp-file filepath))) + (when data + (push (cons i data) threads))))) + threads)) + +(defun search-posts (query &optional board-filter) + "Searches across all boards (or a specific board if BOARD-FILTER is provided) for posts or headlines matching QUERY." + (let ((results '()) + (sexp-dir (merge-pathnames "sexp/" *base-dir*))) + (when (and query (string/= "" (string-trim '(#\Space #\Tab #\Newline #\Return) query))) + (let ((board-dirs (if (and board-filter (string/= "" board-filter)) + (list (merge-pathnames (format nil "~a/" board-filter) sexp-dir)) + (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))))) + (dolist (board-dir board-dirs) + (let ((board-name (car (last (pathname-directory board-dir)))) + ;; Get only files whose namestring consists only of digits (which are the thread S-expression files) + (thread-files (and (probe-file board-dir) + (remove-if-not (lambda (file) + (let ((name (file-namestring file))) + (and (string/= name "") + (every #'digit-char-p name)))) + (uiop:directory-files board-dir))))) + (dolist (thread-file thread-files) + (let* ((thread-id (file-namestring thread-file)) + (thread-data (read-sexp-file thread-file)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-thread))) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let ((posts (get-flat-posts posts-assoc))) + (dolist (post posts) + (let* ((post-id (car post)) + (post-data (cdr post)) + (content (cdr (assoc 'cl-bbs/models:content post-data))) + (date (cdr (assoc 'cl-bbs/models:date post-data)))) + (when (or (and headline (search query headline :test #'char-equal)) + (and content (search query content :test #'char-equal))) + (push (list :board board-name + :thread-id thread-id + :headline (or headline "No Headline") + :post-id post-id + :date (or date "") + :content (or content "")) + results)))))))))))) + (nreverse results))) + +(defun get-flat-posts (posts-assoc) + (when posts-assoc + (let* ((cdr-val (cdr posts-assoc)) + (cadr-val (and (listp cdr-val) (car cdr-val)))) + (if (and (listp cadr-val) (listp (car cadr-val))) + cadr-val + cdr-val)))) + +(defun regenerate-board-index (board) + (let* ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (threads-data (get-board-threads board-dir))) + (if (null threads-data) + (let ((list-path (merge-pathnames "list" board-dir)) + (index-path (merge-pathnames "index" board-dir))) + (when (probe-file list-path) (delete-file list-path)) + (when (probe-file index-path) (delete-file index-path))) + (let* ((sorted-threads + (sort threads-data + (lambda (a b) + (let* ((raw-a (if (and (consp (cdr a)) (consp (cadr a)) (consp (caadr a))) (cadr a) (cdr a))) + (raw-b (if (and (consp (cdr b)) (consp (cadr b)) (consp (caadr b))) (cadr b) (cdr b))) + (posts-assoc-a (assoc 'cl-bbs/models:posts raw-a)) + (posts-assoc-b (assoc 'cl-bbs/models:posts raw-b)) + (posts-a (get-flat-posts posts-assoc-a)) + (posts-b (get-flat-posts posts-assoc-b)) + (date-a (or (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts-a))))) "")) + (date-b (or (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts-b))))) ""))) + (string> date-a date-b))))) + (list-entries + (mapcar (lambda (t-entry) + (let* ((id (car t-entry)) + (t-data (cdr t-entry)) + (raw-t (if (and (consp t-data) + (consp (car t-data)) + (consp (caar t-data))) + (car t-data) + t-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-t)) + (posts (get-flat-posts posts-assoc)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-t))) + (date (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts)))))) + (messages (length posts))) + `(,id (cl-bbs/models:headline . ,headline) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . ,messages)))) + sorted-threads)) + (frontpage-count (min 10 (length sorted-threads))) + (index-entries + (mapcar (lambda (t-entry) + (let* ((id (car t-entry)) + (t-data (cdr t-entry)) + (raw-t (if (and (consp t-data) + (consp (car t-data)) + (consp (caar t-data))) + (car t-data) + t-data)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-t))) + (posts-assoc (assoc 'cl-bbs/models:posts raw-t)) + (posts (get-flat-posts posts-assoc)) + (truncated-ids (if (> (length posts) 6) + (mapcar #'car (butlast (cdr posts) 5)) + nil)) + (selected-posts (if (> (length posts) 6) (cons (car posts) (last posts 5)) posts))) + `(,id (cl-bbs/models:headline . ,headline) + (cl-bbs/models:truncated . ,truncated-ids) + (cl-bbs/models:posts ,selected-posts)))) + (subseq sorted-threads 0 frontpage-count))) + (list-path (merge-pathnames "list" board-dir)) + (index-path (merge-pathnames "index" board-dir))) + (write-sexp-file list-path list-entries) + (write-sexp-file index-path index-entries))))) + +(defun delete-thread-file (board thread-id) + (let ((filepath (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file filepath) + (delete-file filepath) + (regenerate-board-index board)))) + +(defun get-next-handler-thread-id (board) + (let* ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (files (and (probe-file board-dir) + (uiop:directory-files board-dir))) + (max-id 0)) + (dolist (file files) + (let ((id (handler-case (parse-integer (pathname-name file)) + (error () nil)))) + (when (and id (> id max-id)) + (setf max-id id)))) + (1+ max-id))) + +(defun shame-thread-file (board thread-id) + (let* ((source-file (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*)) + (target-board "shame") + (target-dir (merge-pathnames (format nil "sexp/~a/" target-board) *base-dir*))) + (when (and (probe-file source-file) (string-not-equal board target-board)) + (ensure-directories-exist target-dir) + (let* ((new-id (get-next-handler-thread-id target-board)) + (target-file (merge-pathnames (format nil "~D" new-id) target-dir))) + (uiop:copy-file source-file target-file) + (delete-file source-file) + (regenerate-board-index board) + (regenerate-board-index target-board))))) + +(defun delete-thread-comment (board thread-id comment-id) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let* ((actual-posts (get-flat-posts posts-assoc)) + (new-posts (remove-if (lambda (p) (= (car p) comment-id)) actual-posts))) + (if (null new-posts) + (delete-file thread-path) + (progn + (setf (cdr posts-assoc) (list new-posts)) + (write-sexp-file thread-path thread-data))) + (regenerate-board-index board))))))) + +(defun edit-thread-comment (board thread-id comment-id new-content &optional new-headline) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let* ((actual-posts (get-flat-posts posts-assoc)) + (new-posts (mapcar (lambda (p) + (if (= (car p) comment-id) + (cons (car p) + (mapcar (lambda (kv) + (if (eq (car kv) 'cl-bbs/models:content) + (cons (car kv) new-content) + kv)) + (cdr p))) + p)) + actual-posts))) + (setf (cdr posts-assoc) (list new-posts)) + (when (and (= comment-id 1) new-headline) + (let ((headline-assoc (assoc 'cl-bbs/models:headline raw-thread))) + (if headline-assoc + (setf (cdr headline-assoc) new-headline) + (setf raw-thread (append raw-thread (list (cons 'cl-bbs/models:headline new-headline))))))) + (write-sexp-file thread-path thread-data) + (regenerate-board-index board))))))) + +(defun get-date () + (multiple-value-bind (second minute hour date month year) + (get-decoded-time) + (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D" + year month date hour minute second))) + +(defun get-next-thread-number (threads) + (if (null threads) + 1 + (1+ (apply #'max (mapcar #'car threads))))) + +(defun create-thread (path headline date message) + (let ((thread `((cl-bbs/models:headline . ,headline) + (cl-bbs/models:posts . ((1 (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,message))))))) + (write-sexp-file path thread))) + +(defun add-thread-to-list (path thread-number headline date) + (let ((threads (read-sexp-file path))) + (write-sexp-file path + (cons `(,thread-number (cl-bbs/models:headline . ,headline) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . 1)) + threads)))) + +(defun add-thread-to-index (path thread-number headline date message) + (let ((threads (read-sexp-file path)) + (thread `(,thread-number + (cl-bbs/models:headline . ,headline) + (cl-bbs/models:truncated . nil) + (cl-bbs/models:posts + ((1 (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,message))))))) + (write-sexp-file path (cons thread threads)))) + +(defun update-thread-data (thread-data new-post) + (let* ((raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (if posts-assoc + (let* ((posts-list (if (listp (cdr posts-assoc)) + (if (listp (cadr posts-assoc)) + (cadr posts-assoc) + (cdr posts-assoc)) + (cdr posts-assoc))) + (actual-posts (if (listp (car posts-list)) posts-list (list posts-list))) + (updated-posts (append actual-posts (list new-post)))) + (setf (cdr posts-assoc) (list updated-posts)) + thread-data) + (append thread-data (list `(cl-bbs/models:posts (,new-post))))))) + +(defvar *app* (make-instance 'ningle:<app>)) + +(defun normalize-path (path) + (if (stringp path) + (let ((len (length path))) + (if (and (> len 1) + (char= (char path (1- len)) #\/)) + (subseq path 0 (1- len)) + path)) + path)) + +(defun get-body-params (params env) + (let ((has-theme (assoc "theme" params :test #'string=)) + (has-epistula (assoc "epistula" params :test #'string=)) + (has-action (assoc "action" params :test #'string=))) + (if (or has-theme has-epistula has-action) + params + (let ((content-len (getf env :content-length))) + (if (and content-len (> content-len 0) (getf env :raw-body)) + (let ((body-bytes (make-array content-len :element-type '(unsigned-byte 8))) + (stream (getf env :raw-body))) + (let ((bytes-read (read-sequence body-bytes stream))) + (let ((body-str (flexi-streams:octets-to-string + body-bytes :start 0 :end bytes-read :external-format :utf-8))) + (quri:url-decode-params body-str)))) + params))))) + +(defun handle-request (env) + "Accepts a Clack request environment plist, normalizes it, and dispatches it through ningle endpoints." + (let ((normalized-env (copy-list env))) + (setf (getf normalized-env :path-info) (normalize-path (getf env :path-info))) + (unless (hash-table-p (getf normalized-env :headers)) + (setf (getf normalized-env :headers) (make-hash-table :test 'equal))) + (when (getf normalized-env :request-method) + (setf (getf normalized-env :request-method) + (intern (string-upcase (symbol-name (getf normalized-env :request-method))) :keyword))) + (let ((cl-bbs/views:*preferences* (cl-bbs/views:make-preferences + :theme (get-theme-from-env normalized-env) + :syntax-theme (get-syntax-theme-from-env normalized-env) + :default-board (or (get-default-board-from-env normalized-env) "") + :search-hide-input (get-search-hide-input-from-env normalized-env) + :search-local-only (get-search-local-only-from-env normalized-env) + :search-position (get-search-position-from-env normalized-env)))) + (lack.component:call *app* normalized-env)))) + +(defun render-main-index () + (let ((index-file (pathname (or (uiop:getenv "SBBS_INDEX_FILE") + (merge-pathnames "src/static/index.html" + (asdf:system-source-directory :cl-bbs/server)))))) + (if (probe-file index-file) + (let* ((html-content (uiop:read-file-string index-file)) + (boards-list-html (generate-boards-html-list)) + (dynamic-html (cl-ppcre:regex-replace-all "<!--BOARDS-LIST-PLACEHOLDER-->" + html-content + (lambda (match &rest regs) + (declare (ignore match regs)) + boards-list-html)))) + `(200 (:content-type "text/html; charset=utf-8" + :cache-control "no-store, no-cache, must-revalidate, max-age=0" + :pragma "no-cache" + :expires "0") + (,dynamic-html))) + `(200 (:content-type "text/plain" + :cache-control "no-store, no-cache, must-revalidate, max-age=0" + :pragma "no-cache" + :expires "0") ("SchemeBBS clone root"))))) + +;; 1. GET / +(setf (ningle:route *app* "/" :method :GET) + (lambda (params) + (declare (ignore params)) + (let* ((env (lack.request:request-env ningle:*request*)) + (default-board (get-default-board-from-env env))) + (if default-board + `(303 (:location ,(format nil "/~a/" default-board)) ("Redirecting...")) + (render-main-index))))) + +;; 1b. GET /index.html +(setf (ningle:route *app* "/index.html" :method :GET) + (lambda (params) + (declare (ignore params)) + (render-main-index))) + +;; 2. GET /about +(setf (ningle:route *app* "/about" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((about-file (pathname (merge-pathnames "src/static/about.html" + (asdf:system-source-directory :cl-bbs/server))))) + (if (probe-file about-file) + `(200 (:content-type "text/html; charset=utf-8") + (,(uiop:read-file-string about-file))) + `(404 (:content-type "text/plain") ("About page not found")))))) + +;; 3. GET /admin (Admin Control Panel) +(setf (ningle:route *app* "/admin" :method :GET) + (lambda (params) + (let ((env (lack.request:request-env ningle:*request*))) + (if (not (authenticate-admin env)) + `(401 (:content-type "text/plain" + :www-authenticate "Basic realm=\"cl-bbs Admin\"") + ("Unauthorized")) + (let* ((sexp-dir (merge-pathnames "sexp/" *base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) (car (last (pathname-directory path)))) paths) #'string<)) + (board (cdr (assoc "board" params :test #'string=))) + (thread-id-str (cdr (assoc "thread" params :test #'string=))) + (threads nil) + (comments nil) + (headline nil)) + (when (and board (member board boards :test #'string=)) + (let ((list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*))) + (when (probe-file list-path) + (setf threads (read-sexp-file list-path))))) + (when (and board thread-id-str) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id-str) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (setf headline (cdr (assoc 'cl-bbs/models:headline raw-thread))) + (when posts-assoc + (setf comments (get-flat-posts posts-assoc))))))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-moderation boards board threads thread-id-str comments + cl-bbs/views:*preferences* headline)))))))) + +;; 4. POST /admin/action (Admin Actions) +(setf (ningle:route *app* "/admin/action" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env))) + (if (not (authenticate-admin env)) + `(401 (:content-type "text/plain" + :www-authenticate "Basic realm=\"cl-bbs Admin\"") + ("Unauthorized")) + (let ((action (cdr (assoc "action" parsed-params :test #'string=))) + (board (cdr (assoc "board" parsed-params :test #'string=))) + (thread-id-str (cdr (assoc "thread" parsed-params :test #'string=))) + (comment-id-str (cdr (assoc "comment" parsed-params :test #'string=))) + (content (cdr (assoc "content" parsed-params :test #'string=)))) + (cond + ((string= action "delete-board") + (delete-board-dir board) + `(303 (:location "/admin") ("Redirecting..."))) + ((string= action "create-board") + (let ((sanitized (sanitize-board-name board))) + (if sanitized + (progn + (ensure-board-dirs sanitized) + `(303 (:location "/admin") ("Redirecting..."))) + `(400 (:content-type "text/plain") ("Invalid Board Name"))))) + ((string= action "delete-thread") + (delete-thread-file board thread-id-str) + `(303 (:location ,(format nil "/admin?board=~a" board)) ("Redirecting..."))) + ((string= action "shame-thread") + (shame-thread-file board thread-id-str) + `(303 (:location ,(format nil "/admin?board=~a" board)) ("Redirecting..."))) + ((string= action "delete-comment") + (let ((cid (and comment-id-str (parse-integer comment-id-str :junk-allowed t)))) + (when cid + (delete-thread-comment board thread-id-str cid))) + `(303 (:location ,(format nil "/admin?board=~a&thread=~a" board thread-id-str)) ("Redirecting..."))) + ((string= action "edit-comment") + (let ((cid (and comment-id-str (parse-integer comment-id-str :junk-allowed t))) + (headline (cdr (assoc "headline" parsed-params :test #'string=)))) + (when cid + (edit-thread-comment board thread-id-str cid content headline))) + `(303 (:location ,(format nil "/admin?board=~a&thread=~a" board thread-id-str)) ("Redirecting..."))) + (t + `(400 (:content-type "text/plain") ("Invalid Action"))))))))) + +;; 5. GET /sw.js +(setf (ningle:route *app* "/sw.js" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((sw-file (merge-pathnames "src/static/sw.js" + (asdf:system-source-directory :cl-bbs/server)))) + (if (probe-file sw-file) + `(200 (:content-type "application/javascript") + (,(uiop:read-file-string sw-file))) + `(404 (:content-type "text/plain") ("Service worker not found")))))) + +;; 6. GET /manifest.json +(setf (ningle:route *app* "/manifest.json" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((manifest-file (merge-pathnames "src/static/manifest.json" + (asdf:system-source-directory :cl-bbs/server)))) + (if (probe-file manifest-file) + `(200 (:content-type "application/json") + (,(uiop:read-file-string manifest-file))) + `(404 (:content-type "text/plain") ("Manifest not found")))))) + +;; 6b. GET /search +(setf (ningle:route *app* "/search" :method :GET) + (lambda (params) + (let* ((query (cdr (assoc "q" params :test #'string=))) + (board-filter (cdr (assoc "board" params :test #'string=))) + (results (if query (search-posts query board-filter) nil))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-search-results (or query "") results cl-bbs/views:*preferences*)))))) + +;; 6b-api. POST /api/colorize +(setf (ningle:route *app* "/api/colorize" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (code (cdr (assoc "code" parsed-params :test #'string=)))) + (if code + `(200 (:content-type "text/html; charset=utf-8") + (,(handler-case (colorize:html-colorization :common-lisp code) + (error () (cl-who:escape-string code))))) + `(400 (:content-type "text/plain") ("No code provided")))))) + +;; 6c. GET /playground (Global Playground) +(setf (ningle:route *app* "/playground" :method :GET) + (lambda (params) + (declare (ignore params)) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-playground nil))))) + +;; 6d. GET /:board/playground (Board-scoped Playground) +(setf (ningle:route *app* "/:board/playground" :method :GET) + (lambda (params) + (let ((board (cdr (assoc :board params)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-playground board)))))) + +;; 7. GET /:board/list +(setf (ningle:route *app* "/:board/list" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*))) + (if (probe-file list-path) + (let ((list-data (read-sexp-file list-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-list board list-data cl-bbs/views:*preferences*)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-list board nil cl-bbs/views:*preferences*))))))) + +;; 8. GET /:board/preferences +(setf (ningle:route *app* "/:board/preferences" :method :GET) + (lambda (params) + (let ((board (cdr (assoc :board params)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-preferences board cl-bbs/views:*preferences*)))))) + +;; 9. POST /:board/preferences +(setf (ningle:route *app* "/:board/preferences" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (board (cdr (assoc :board params))) + (theme (cdr (assoc "theme" parsed-params :test #'string=))) + (syntax-theme (cdr (assoc "syntax_theme" parsed-params :test #'string=))) + (default-board (let ((val (cdr (assoc "default_board" parsed-params :test #'string=)))) + (if val (string-trim '(#\Space #\Tab #\Newline #\Return) val) ""))) + (search-hide-input (let ((val (cdr (assoc "search_hide_input" parsed-params :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + (search-local-only (let ((val (cdr (assoc "search_local_only" parsed-params :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + (search-position (let ((val (cdr (assoc "search_position" parsed-params :test #'string=)))) + (if (member val '("top" "bottom") :test #'string=) val "top")))) + `(303 (:location ,(format nil "/~a/preferences" board) + :set-cookie ,(format nil "theme=~a; Path=/; Max-Age=31536000" (or theme "default")) + :set-cookie ,(format nil "syntax_theme=~a; Path=/; Max-Age=31536000" (or syntax-theme "simple")) + :set-cookie ,(format nil "default_board=~a; Path=/; Max-Age=31536000" (or default-board "")) + :set-cookie ,(format nil "search_hide_input=~a; Path=/; Max-Age=31536000" search-hide-input) + :set-cookie ,(format nil "search_local_only=~a; Path=/; Max-Age=31536000" search-local-only) + :set-cookie ,(format nil "search_position=~a; Path=/; Max-Age=31536000" search-position)) + ("Redirecting..."))))) + +;; RSS Feed (All boards) +(setf (ningle:route *app* "/rss" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((env (lack.request:request-env ningle:*request*)) + (all-threads (cl-bbs/rss:get-all-boards-rss-threads 20))) + (list 200 '(:content-type "application/rss+xml; charset=utf-8") + (list (cl-bbs/rss:generate-rss "all" all-threads env)))))) + +;; RSS Feed (Specific board) +(setf (ningle:route *app* "/:board/rss" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (env (lack.request:request-env ningle:*request*)) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (threads (when (probe-file list-path) (read-sexp-file list-path)))) + (list 200 '(:content-type "application/rss+xml; charset=utf-8") + (list (cl-bbs/rss:generate-rss board threads env)))))) + +;; 10. POST /:board/post (New Thread) +(setf (ningle:route *app* "/:board/post" :method :POST) + (lambda (params) + (block out + (let* ((board (cdr (assoc :board params))) + (env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (epistula (cdr (assoc "epistula" parsed-params :test #'string=))) + (titulus (cdr (assoc "titulus" parsed-params :test #'string=))) + (date (get-date)) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*)) + (threads (read-sexp-file list-path)) + (thread-number (get-next-thread-number threads)) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-number) *base-dir*))) + (cond + ((or (null epistula) + (string= "" (string-trim '(#\Space #\Tab #\Newline #\Return) epistula))) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Post body cannot be empty" cl-bbs/views:*preferences*)))) + ((and titulus (> (length titulus) *headline-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Headline exceeds maximum length of ~D characters" *headline-limit*) + cl-bbs/views:*preferences*)))) + ((and epistula (> (length epistula) *body-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Post body exceeds maximum length of ~D characters" *body-limit*) + cl-bbs/views:*preferences*)))) + (t + (progn + (when (is-board-locked board) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "This board is read-only" + cl-bbs/views:*preferences*))))) + (unless (probe-file (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Only administrators can create new boards" + cl-bbs/views:*preferences*))))) + (ensure-board-dirs board) + (create-thread thread-path titulus date epistula) + (add-thread-to-list list-path thread-number titulus date) + (add-thread-to-index index-path thread-number titulus date epistula) + `(303 (:location ,(format nil "/~a/" board)) ("Redirecting..."))))))))) + +;; 11. GET /:board +(setf (ningle:route *app* "/:board" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*))) + (if (probe-file index-path) + (let ((index-data (read-sexp-file index-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-index board index-data cl-bbs/views:*preferences*)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-index board nil cl-bbs/views:*preferences*))))))) + +;; 12. GET /:board/:thread_id +(setf (ningle:route *app* "/:board/:thread_id" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (if (probe-file thread-path) + (let ((thread-data (read-sexp-file thread-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-thread board thread-id thread-data nil cl-bbs/views:*preferences*)))) + `(404 (:content-type "text/plain") ("Thread not found")))))) + +;; 13. POST /:board/:thread_id/post (Reply to Thread) +(setf (ningle:route *app* "/:board/:thread_id/post" :method :POST) + (lambda (params) + (block out + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (epistula (cdr (assoc "epistula" parsed-params :test #'string=))) + (date (get-date)) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (cond + ((or (null epistula) + (string= "" (string-trim '(#\Space #\Tab #\Newline #\Return) epistula))) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Post body cannot be empty" cl-bbs/views:*preferences*)))) + ((and epistula (> (length epistula) *body-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Post body exceeds maximum length of ~D characters" *body-limit*) + cl-bbs/views:*preferences*)))) + (t + (progn + (when (is-board-locked board) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "This board is read-only" cl-bbs/views:*preferences*))))) + (if (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (let ((assoc-res (assoc 'cl-bbs/models:posts raw-thread))) + (if assoc-res + assoc-res + (cadr (if (consp thread-data) + thread-data + (list thread-data)))))) + (posts-list (if (listp (cdr posts-assoc)) + (if (listp (cadr posts-assoc)) + (cadr posts-assoc) + (cdr posts-assoc)) + (cdr posts-assoc))) + (posts (if (listp (car posts-list)) posts-list (list posts-list))) + (new-post `(,(1+ (reduce #'max posts :key #'car :initial-value 0)) + (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,epistula))) + (new-thread-data (update-thread-data thread-data new-post))) + (write-sexp-file thread-path new-thread-data) + (let* ((list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (threads-list (read-sexp-file list-path)) + (str-id (if (stringp thread-id) (parse-integer thread-id) thread-id)) + (target-thread-assoc (assoc str-id threads-list))) + (when target-thread-assoc + (let* ((rem-threads (remove str-id threads-list :key #'car :test #'equal)) + (raw-new-thread (if (and (consp new-thread-data) + (consp (car new-thread-data)) + (consp (caar new-thread-data))) + (car new-thread-data) + new-thread-data)) + (messages-count (length (cadr (assoc 'cl-bbs/models:posts raw-new-thread)))) + (headline-val (if (stringp (cdr (assoc 'cl-bbs/models:headline + (cdr target-thread-assoc)))) + (cdr (assoc 'cl-bbs/models:headline + (cdr target-thread-assoc))) + (cdr (assoc 'headline (cdr target-thread-assoc))))) + (updated-entry `(,str-id + (cl-bbs/models:headline . ,headline-val) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . ,messages-count)))) + (write-sexp-file list-path (cons updated-entry rem-threads)))) + (let* ((index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*)) + (index-list (read-sexp-file index-path)) + (target-index-assoc (assoc str-id index-list))) + (when target-index-assoc + (let* ((rem-index (remove str-id index-list :key #'car :test #'equal)) + (raw-new-thread (if (and (consp new-thread-data) + (consp (car new-thread-data)) + (consp (caar new-thread-data))) + (car new-thread-data) + new-thread-data)) + (headline-val (if (stringp (cdr (assoc 'cl-bbs/models:headline + (cdr target-index-assoc)))) + (cdr (assoc 'cl-bbs/models:headline + (cdr target-index-assoc))) + (cdr (assoc 'headline (cdr target-index-assoc))))) + (actual-posts (get-flat-posts (assoc 'cl-bbs/models:posts raw-new-thread))) + (truncated-ids (if (> (length actual-posts) 6) + (mapcar #'car (butlast (cdr actual-posts) 5)) + nil)) + (updated-entry `(,str-id + (cl-bbs/models:headline . ,headline-val) + (cl-bbs/models:truncated . ,truncated-ids) + (cl-bbs/models:posts + ,(if (> (length actual-posts) 6) + (cons (car actual-posts) (last actual-posts 5)) + actual-posts))))) + (write-sexp-file index-path (cons updated-entry rem-index)))))) + `(303 (:location ,(format nil "/~a/" board)) ("Redirecting..."))) + `(404 (:content-type "text/plain") ("Thread not found")))))))))) + +;; 14. GET /:board/:thread_id/:range (Thread Comments with Range) +(setf (ningle:route *app* "/:board/:thread_id/:range" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (range-str (cdr (assoc :range params))) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (if (probe-file thread-path) + (let ((thread-data (read-sexp-file thread-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-thread board thread-id thread-data range-str cl-bbs/views:*preferences*)))) + `(404 (:content-type "text/plain") ("Thread not found")))))) diff --git a/server/main.lisp b/server/main.lisp new file mode 100644 index 0000000..755d68a --- /dev/null +++ b/server/main.lisp @@ -0,0 +1,61 @@ +(in-package :cl-bbs/server) + +(defvar *server* nil) +(defvar *app* nil) + +;; Initialize colorize and HyperSpec lookup paths safely +(defun init-colorize () + (setf colorize:*debug* nil) + (handler-case + (let* ((base-dir (asdf:system-source-directory :cl-bbs/server)) + (local-clhs-dir (and base-dir (merge-pathnames "src/HyperSpec/" base-dir))) + (local-map-file (and local-clhs-dir (merge-pathnames "Data/Map_Sym.txt" local-clhs-dir))) + (mop-map-file (and local-clhs-dir (merge-pathnames "Mop_Sym.txt" local-clhs-dir)))) + (if (and local-map-file (probe-file local-map-file)) + (progn + (setf clhs-lookup::*hyperspec-pathname* local-clhs-dir) + (setf clhs-lookup::*hyperspec-map-file* local-map-file) + (setf clhs-lookup::*mop-map-file* mop-map-file)) + (setf clhs-lookup::*hyperspec-map-file* #p"nonexistent-map-sym.txt"))) + (error (e) + (declare (ignore e)) + (setf clhs-lookup::*hyperspec-map-file* #p"nonexistent-map-sym.txt")))) + + +(defun make-real-ip-middleware (app) + (lambda (env) + (let ((x-forwarded-for (gethash "x-forwarded-for" (getf env :headers)))) + ;; If X-Forwarded-For exists, update REMOTE_ADDR to the first IP in the list + (when x-forwarded-for + (let ((real-ip (first (uiop:split-string x-forwarded-for :separator '(#\,))))) + (setf (getf env :remote-addr) (string-trim " " real-ip))))) + (funcall app env))) + +(defun build-app () + (lack:builder + (:static :path "/static/" + :root (merge-pathnames "src/static/" (asdf:system-source-directory :cl-bbs/server))) + (lambda (app) (make-real-ip-middleware app)) + :accesslog + (lambda (env) + (cl-bbs/handlers:handle-request env)))) + +(defun start-app (host port &key (async t)) + "Starts the Hunchentoot server running the cl-bbs application on the specified PORT." + (init-colorize) + (when *server* + (stop-app)) + (setf *app* (build-app)) + (setf *server* (clack:clackup *app* :address host + :port port + :server :hunchentoot + :use-thread async)) + (format t "cl-bbs running on port ~a~%" port) + t) + +(defun stop-app () + "Stops the currently running Hunchentoot server instance if one exists." + (when *server* + (clack:stop *server*) + (setf *server* nil)) + t) diff --git a/server/models.lisp b/server/models.lisp new file mode 100644 index 0000000..8ee7f4d --- /dev/null +++ b/server/models.lisp @@ -0,0 +1,35 @@ +(defpackage :cl-bbs/models + (:use :cl) + (:export #:thread + #:post + #:board + #:headline + #:posts + #:truncated + #:content + #:date + #:messages + #:vip + #:name)) + +(in-package :cl-bbs/models) + +(defclass post () + ((id :initarg :id :accessor post-id) + (date :initarg :date :accessor post-date) + (vip :initarg :vip :accessor post-vip :initform nil) + (content :initarg :content :accessor post-content)) + (:documentation "Represents a single post on a message board.")) + +(defclass thread () + ((id :initarg :id :accessor thread-id) + (headline :initarg :headline :accessor thread-headline) + (date :initarg :date :accessor thread-date) + (messages :initarg :messages :accessor thread-messages :initform 1) + (truncated :initarg :truncated :accessor thread-truncated :initform nil) + (posts :initarg :posts :accessor thread-posts :initform nil)) + (:documentation "Represents a thread consisting of a series of posts.")) + +(defclass board () + ((name :initarg :name :accessor board-name)) + (:documentation "Represents a message board.")) diff --git a/server/rss.lisp b/server/rss.lisp new file mode 100644 index 0000000..aef3013 --- /dev/null +++ b/server/rss.lisp @@ -0,0 +1,122 @@ +(defpackage #:cl-bbs/rss + (:use #:cl) + (:local-nicknames (#:models #:cl-bbs/models) + (#:storage #:cl-bbs/storage)) + (:export #:generate-rss + #:get-all-boards-rss-threads)) + +(in-package #:cl-bbs/rss) + +(defun get-url-scheme (env headers) + (if (string= "https" (gethash "x-forwarded-proto" headers)) + "https" + (let ((url-scheme (getf env :url-scheme))) + (if url-scheme + (string-downcase (string url-scheme)) + "http")))) + +(defun get-request-base-url (env) + "Construct the base URL from the request environment." + (let* ((headers (getf env :headers)) + (scheme (get-url-scheme env headers)) + (host (gethash "host" headers))) + (if host + (format nil "~a://~a" scheme host) + ""))) + +(defun get-tz-offset-string () + "Returns the basic timezone offset of the machine like '-0300' or '+0000'." + (multiple-value-bind (sec min hr date month year day-of-week dst-p tz) + (get-decoded-time) + (declare (ignore sec min hr date month year day-of-week dst-p)) + (let* ((offset-hours (- tz)) + (sign (if (>= offset-hours 0) #\+ #\-)) + (abs-hours (abs offset-hours))) + (format nil "~c~2,'0d00" sign (truncate abs-hours))))) + +(defun convert-to-rfc822 (iso-8601-string) + "Convert simple ISO 8601 string to RFC1123/RFC822 retaining literal parsing and appending manual offset." + (let ((clean-string (cl-ppcre:regex-replace-all " " iso-8601-string "T"))) + (handler-case + (let* ((parsed (local-time:parse-timestring clean-string)) + (utc-str (local-time:format-rfc1123-timestring nil parsed :timezone local-time:+utc-zone+))) + (cl-ppcre:regex-replace "(?:GMT|\\+0000)$" utc-str (get-tz-offset-string))) + (error () + (let ((now-utc (local-time:format-rfc1123-timestring nil (local-time:now) :timezone local-time:+utc-zone+))) + (cl-ppcre:regex-replace "(?:GMT|\\+0000)$" now-utc (get-tz-offset-string))))))) + + + +(defun get-all-boards-rss-threads (limit) + "Fetch latest threads from all boards combining them for RSS." + (let ((all-threads nil) + (sexp-base (merge-pathnames "sexp/" storage:*base-dir*))) + (when (probe-file sexp-base) + (loop for board-dir in (uiop:subdirectories sexp-base) do + (let ((board-name (car (last (pathname-directory board-dir)))) + (list-path (merge-pathnames "list" board-dir))) + (when (probe-file list-path) + (let ((board-threads (storage:read-sexp-file list-path))) + (loop for thread in board-threads do + ;; thread is (ID (models:headline . "...") (models:date . "...")) + (push (cons (car thread) (cons `(models:board . ,board-name) (cdr thread))) all-threads))))))) + ;; Sort by date descending + (setf all-threads (sort all-threads + (lambda (a b) + (string> (cdr (assoc 'models:date (cdr a))) + (cdr (assoc 'models:date (cdr b))))))) + ;; Take top LIMIT + (if (> (length all-threads) limit) + (subseq all-threads 0 limit) + all-threads))) + +(defun generate-rss (board threads env) + "Generate an RSS feed for the given board and threads." + (let* ((now (local-time:now)) + (utc-str (local-time:format-rfc1123-timestring nil now)) + (rfc822-date utc-str) + (base-url (get-request-base-url env)) + (request-url (format nil "~a~a" base-url (getf env :request-uri)))) + (with-output-to-string (s) + (format s "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>~%") + (format s "<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">~%") + (format s " <channel>~%") + (if (string= board "all") + (progn + (format s " <title>cl-bbs - all boards</title>~%") + (format s " <description>Latest threads from all boards on cl-bbs.</description>~%")) + (progn + (format s " <title>cl-bbs - /~a/</title>~%" board) + (format s " <description>Latest threads from /~a/.</description>~%" board))) + (format s " <link>~a</link>~%" request-url) + (format s " <pubDate>~a</pubDate>~%" rfc822-date) + (format s " <generator>cl-bbs RSS generator</generator>~%") + + (loop for t-entry in threads do + (let* ((id (car t-entry)) + (thread-data (cdr t-entry)) + (board-val (if (string= board "all") + (or (cdr (assoc 'models:board thread-data)) board) + board)) + (headline (or (cdr (assoc 'models:headline thread-data)) "Untitled")) + (date (or (cdr (assoc 'models:date thread-data)) rfc822-date)) ; ISO 8601 + (pub-date (if (string= date rfc822-date) rfc822-date (convert-to-rfc822 date))) + (thread-url (if (not (string= base-url "")) + (format nil "~a/~a/~a" base-url board-val id) + (format nil "/~a/~a" board-val id))) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board-val id) storage:*base-dir*)) + (thread-full-data (when (probe-file thread-path) (storage:read-sexp-file thread-path))) + (posts (cdr (assoc 'models:posts thread-full-data))) + (first-post (when posts (cdar (car posts)))) + (content (if first-post (cdr (assoc 'models:content first-post)) ""))) + (format s " <item>~%") + (format s " <title><![CDATA[~a]]></title>~%" headline) + (format s " <link>~a</link>~%" thread-url) + (format s " <description><![CDATA[~a]]></description>~%" headline) ;; Fallback to headline + (format s " <content:encoded><![CDATA[~a]]></content:encoded>~%" content) + (format s " <pubDate>~a</pubDate>~%" pub-date) + (format s " <guid>~a</guid>~%" thread-url) + (format s " </item>~%"))) + + (format s " </channel>~%") + (format s "</rss>~%")))) diff --git a/server/storage.lisp b/server/storage.lisp new file mode 100644 index 0000000..48c57b3 --- /dev/null +++ b/server/storage.lisp @@ -0,0 +1,43 @@ +(defpackage :cl-bbs/storage + (:use :cl) + (:export #:*base-dir* + #:ensure-board-dirs + #:read-sexp-file + #:write-sexp-file + #:is-board-locked)) + +(in-package :cl-bbs/storage) + +(defvar *base-dir* + (pathname (or (uiop:getenv "SBBS_DATADIR") + (merge-pathnames "data/" (asdf:system-source-directory :cl-bbs/server))))) + +(defun ensure-board-dirs (board-name) + "Ensures that directories for storing the board S-expressions and HTML exist." + (let ((sexp-dir (merge-pathnames (format nil "sexp/~a/" board-name) *base-dir*)) + (html-dir (merge-pathnames (format nil "html/~a/" board-name) *base-dir*))) + (ensure-directories-exist sexp-dir) + (ensure-directories-exist html-dir))) + +(defun read-sexp-file (path) + "Reads a safe S-expression from the specified file path, or returns NIL if file does not exist." + (with-open-file (stream path :direction :input :if-does-not-exist nil) + (if stream + (let ((*read-eval* nil)) + (read stream nil nil)) + nil))) + +(defun write-sexp-file (path data) + "Writes the given data as a pretty-printed S-expression to the specified file path." + (with-open-file (stream path :direction :output :if-exists :supersede :if-does-not-exist :create) + (write data :stream stream :pretty t) + (terpri stream))) + +(defun is-board-locked (board-name) + "Checks if a board is locked by examining the SBBS_LOCKED_BOARDS environment variable. +BOARD-NAME can be a string or a symbol." + (let ((locked-env (uiop:getenv "SBBS_LOCKED_BOARDS")) + (board-str (if (symbolp board-name) (string-downcase (symbol-name board-name)) board-name))) + (when (and locked-env board-str) + (let ((locked-boards (cl-ppcre:split "," locked-env))) + (member board-str locked-boards :test #'string=))))) diff --git a/server/views.lisp b/server/views.lisp new file mode 100644 index 0000000..e3f035a --- /dev/null +++ b/server/views.lisp @@ -0,0 +1,1009 @@ +# (defpackage :cl-bbs/views + (:use :cl) + (:import-from :cl-who + #:with-html-output-to-string + #:htm + #:str + #:esc + #:fmt) + (:import-from :cl-bbs/storage + #:is-board-locked) + (:export #:render-index + #:render-list + #:render-thread + #:render-preferences + #:render-moderation + #:render-error-page + #:render-search-results + #:render-playground + #:preferences + #:make-preferences + #:preferences-theme + #:preferences-syntax-theme + #:preferences-default-board + #:preferences-search-hide-input + #:preferences-search-local-only + #:preferences-search-position + #:*preferences*)) + +(in-package :cl-bbs/views) + +(defstruct preferences + (theme "dark") + (syntax-theme "simple") + (default-board "") + (search-hide-input "no") + (search-local-only "no") + (search-position "top")) + +(defvar *preferences* (make-preferences)) + +(defun get-git-commit-hash () + "Gets the git commit hash from environmental dynamics (APP_COMMIT_HASH) with a fallback to uiop:run-program." + (let ((env-hash (uiop:getenv "APP_COMMIT_HASH"))) + (if (and env-hash (string/= env-hash "")) + env-hash + (or (handler-case + (string-trim '(#\Space #\Tab #\Newline #\Return) + (uiop:run-program '("git" "rev-parse" "--short" "HEAD") + :output :string)) + (error () nil)) + "unknown")))) + +(defun render-footer-html (&optional board) + "Renders the common footer HTML with cl-bbs version hash and a GitHub link." + (let ((hash (get-git-commit-hash))) + (cl-who:with-html-output-to-string (s nil :indent t) + (:p :class "footer" + "cl-bbs version:" + (:a :href (format nil "https://github.com/ryukinix/cl-bbs/commit/~a" hash) + :target "_blank" + (cl-who:esc hash)) + " - " + (:a :href (if board (format nil "/~a/rss" board) "/rss") + :target "_blank" + "RSS Feed"))))) + +(defun render-board-name (board) + (cl-who:with-html-output-to-string (s nil :indent t) + (:h1 + (cl-who:esc + (if (is-board-locked board) + (concatenate 'string board " 🔒") + board))))) + +(defun get-hash-hue (id-val) + (let ((id-num (cond ((integerp id-val) id-val) + ((stringp id-val) (or (handler-case (parse-integer id-val :junk-allowed t) + (error () nil)) + 0)) + (t 0)))) + (mod (* id-num 137) 360))) + +(defmacro layout (title class prefs &body body) + `(cl-who:with-html-output-to-string (s nil :prologue "<!DOCTYPE html>" :indent t) + (:html + (:head + (:meta :charset "utf-8") + (:meta :name "viewport" :content "width=device-width, initial-scale=1.0") + (:title (cl-who:esc ,title)) + (:link :rel "manifest" :href "/manifest.json") + (:link :rel "icon" :href "/static/favicon.ico" :type "image/png") + (:link :rel "stylesheet" + :href (format nil "/static/styles/themes/~a.css" (or (preferences-theme ,prefs) "default")) + :type "text/css") + (:link :rel "stylesheet" + :href (format nil "/static/styles/syntax/~a.css" (or (preferences-syntax-theme ,prefs) "simple")) + :type "text/css") + (:script "if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js'); + }); +}") + (:script " +function validatePostForm(form, errorId) { + const content = form.epistula.value.trim(); + const errorEl = document.getElementById(errorId); + if (!content) { + if (errorEl) { + errorEl.textContent = 'Post body cannot be empty!'; + errorEl.style.display = 'block'; + } else { + alert('Post body cannot be empty!'); + } + return false; + } + if (errorEl) { + errorEl.style.display = 'none'; + } + return true; +} + +document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('textarea[name=\"epistula\"]').forEach(function(ta) { + ta.addEventListener('keydown', function(e) { + if (e.ctrlKey && e.key === 'Enter') { + e.preventDefault(); + ta.form.requestSubmit(); + } + }); + }); +}); +") + (:script :src "/static/jscl-snippets.js" :defer t)) + (:body :class ,class + (cl-who:str (render-boards-header)) + (:hr) + (cl-who:str (progn ,@body)) + (when (show-search-at-bottom-p) + (cl-who:htm + (:hr) + (cl-who:str (render-search-form)))))))) + +(defun render-error-page (error-message &optional (prefs *preferences*)) + "Renders an HTML error page displaying the given ERROR-MESSAGE, using the specified layout PREFS." + (layout "Error - cl-bbs" "error-page" prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (:h1 "Error") + (:hr) + (:div :class "error-container" + (:p :class "error-title" (cl-who:esc error-message)) + (:p "We were unable to process your post because it does not meet the validation requirements.") + (:p (:button :class "error-back-button" :onclick "history.back();" "← Go Back and Edit Post"))) + (:hr) + (cl-who:str (render-footer-html))))) + +(defun board-view-p (path) + "Checks if the given PATH represents a board-specific view." + (and path + (string/= path "/") + (string/= path "/index.html") + (not (uiop:string-prefix-p "/search" path)) + (not (uiop:string-prefix-p "/admin" path)) + (not (uiop:string-prefix-p "/about" path)) + (not (uiop:string-prefix-p "/sw.js" path)) + (not (uiop:string-prefix-p "/manifest.json" path)) + (not (uiop:string-prefix-p "/playground" path)))) + +(defun get-current-board-from-path (path) + "Extracts the board name from the request PATH." + (when (and path (string/= path "") (char= (char path 0) #\/)) + (let ((parts (cl-ppcre:split "/" path))) + (when (>= (length parts) 2) + (let ((b (second parts))) + (and (string/= b "") b)))))) + +(defun show-search-at-top-p () + "Determines whether the search form should be rendered at the top header." + (let* ((env (and (boundp 'ningle:*request*) ningle:*request* (lack.request:request-env ningle:*request*))) + (path (and env (getf env :path-info))) + (is-board (board-view-p path))) + (and (not (and is-board (string= (preferences-search-hide-input *preferences*) "yes"))) + (string= (preferences-search-position *preferences*) "top")))) + +(defun show-search-at-bottom-p () + "Determines whether the search form should be rendered at the bottom of the page." + (and (not (string= (preferences-search-hide-input *preferences*) "yes")) + (string= (preferences-search-position *preferences*) "bottom"))) + +(defun render-search-form () + "Renders the search form as a standalone block, with board filter if local search is configured." + (let* ((env (and (boundp 'ningle:*request*) ningle:*request* (lack.request:request-env ningle:*request*))) + (path (and env (getf env :path-info))) + (board (and (board-view-p path) (get-current-board-from-path path)))) + (cl-who:with-html-output-to-string (s nil :indent t) + (:form :action "/search" :method "GET" :style "margin: 1em 2%; display: inline-flex;" + (when (and (string= (preferences-search-local-only *preferences*) "yes") board) + (cl-who:htm (:input :type "hidden" :name "board" :value board))) + (:input :type "text" :name "q" :placeholder "Search posts..." + :style "padding: 2px 5px; font-size: 0.85em; margin-right: 5px;") + (:input :type "submit" :value "Search"))))) + +(defun render-boards-header () + (let* ((sexp-dir (merge-pathnames "sexp/" cl-bbs/storage:*base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) + (car (last (pathname-directory path)))) + paths) + #'string<)) + (env (and (boundp 'ningle:*request*) ningle:*request* (lack.request:request-env ningle:*request*))) + (path (and env (getf env :path-info))) + (board (and (board-view-p path) (get-current-board-from-path path)))) + (cl-who:with-html-output-to-string (s nil :indent t) + (:div :style "display: flex; justify-content: space-between; align-items: center; margin: 0.5em 2% 1em 2%;" + (:p :class "boards" :style "font-size: 0.9em; margin: 0;" + "[ " + (loop for board in boards + for i from 0 + unless (zerop i) + do (cl-who:str " | ") + do (cl-who:htm (:a :href (format nil "/~a/" board) (cl-who:esc board)))) + " ]") + (when (show-search-at-top-p) + (cl-who:htm + (:form :action "/search" :method "GET" :style "margin: 0; display: inline-flex;" + (when (and (string= (preferences-search-local-only *preferences*) "yes") board) + (cl-who:htm (:input :type "hidden" :name "board" :value board))) + (:input :type "text" :name "q" :placeholder "Search posts..." + :style "padding: 2px 5px; font-size: 0.85em; margin-right: 5px;") + (:input :type "submit" :value "Search")))))))) + +(defun render-menu (board selected) + (cl-who:with-html-output-to-string (s nil :indent t) + (:p :class "nav" + (if (string= selected "front") + (cl-who:str "front") + (cl-who:htm (:a :href (format nil "/~a" board) "front"))) + " - " + (if (string= selected "list") + (cl-who:str "list") + (cl-who:htm (:a :href (format nil "/~a/list" board) "list"))) + " - " + (if (string= selected "front") + (cl-who:htm (:a :href "#newthread" "new")) + (cl-who:htm (:a :href (format nil "/~a#newthread" board) "new"))) + " - " + (:a :href (format nil "/~a/preferences" board) "preferences") + " - " + (if (string= selected "playground") + (cl-who:str "λ") + (cl-who:htm (:a :href (format nil "/~a/playground" board) "λ"))) + " - " + (:a :href "/index.html" "?")))) + +(defun render-thread-form (board) + (cl-who:with-html-output-to-string (s nil :indent t) + (:div :class "newthread-form" + (:h2 :id "newthread" "New thread") + (:p :id "newthread-error" :style "color: red; font-weight: bold; display: none;") + (:form :action (format nil "/~a/post" board) + :method "POST" + :onsubmit "return validatePostForm(this, 'newthread-error');" + (:p (:input :type "text" :name "titulus" :size 35 :placeholder "Headline")) + (:p (:textarea :name "epistula" + :rows 5 + :cols 50 + :placeholder "Message")) + (:p (:input :type "text" :name "name" :style "display:none") + (:input :type "text" :name "message" :style "display:none") + (:input :type "submit" :value "Post")))))) + +(defun unescape-html (string) + (let ((s string)) + (setf s (cl-ppcre:regex-replace-all """ s "\"")) + (setf s (cl-ppcre:regex-replace-all "<" s "<")) + (setf s (cl-ppcre:regex-replace-all ">" s ">")) + (setf s (cl-ppcre:regex-replace-all "'" s "'")) + (setf s (cl-ppcre:regex-replace-all "'" s "'")) + (setf s (cl-ppcre:regex-replace-all "'" s "'")) + (setf s (cl-ppcre:regex-replace-all "&#[xX]([0-9a-fA-F]+);" s + (lambda (match-string hex-str) + (declare (ignore match-string)) + (string (code-char (parse-integer hex-str :radix 16)))) + :simple-calls t)) + (setf s (cl-ppcre:regex-replace-all "&#([0-9]+);" s + (lambda (match-string dec-str) + (declare (ignore match-string)) + (string (code-char (parse-integer dec-str :radix 10)))) + :simple-calls t)) + (setf s (cl-ppcre:regex-replace-all "&" s "&")) + s)) + +(defun format-text (text &optional thread-id) + (let* ((escaped (cl-who:escape-string text)) + ;; 1. Extract code blocks + (code-blocks '()) + (code-block-placeholder-format "<!--CODEBLOCK-PLACEHOLDER-~a-->") + (placeholder-idx 0) + (processed escaped)) + (setf processed + (cl-ppcre:regex-replace-all + "(?s)```\\n*(.*?)\\n*```" + processed + (lambda (match-string &optional content &rest others) + (declare (ignore match-string others)) + (let ((placeholder (format nil code-block-placeholder-format (incf placeholder-idx)))) + (push (cons placeholder (or content "")) code-blocks) + placeholder)) + :simple-calls t)) + (setf processed + (cl-ppcre:regex-replace-all + "(?m)^>(?!>)\\s*(.*?)$" + processed + "<blockquote>\\1</blockquote>")) + (setf processed + (cl-ppcre:regex-replace-all + "\\*\\*(.*?)\\*\\*" + processed + "<b>\\1</b>")) + (setf processed + (cl-ppcre:regex-replace-all + "__(.*?)__" + processed + "<i>\\1</i>")) + (setf processed + (cl-ppcre:regex-replace-all + "`([^`]+)`" + processed + "<code>\\1</code>")) + (setf processed + (cl-ppcre:regex-replace-all + "~~(.*?)~~" + processed + "<del>\\1</del>")) + (setf processed + (cl-ppcre:regex-replace-all + ">>(\\d+)" + processed + (lambda (match-string &optional num &rest others) + (declare (ignore match-string others)) + (let ((num-val (or num ""))) + (if thread-id + (format nil "<a href=\"#t~ap~a\">>>~a</a>" thread-id num-val num-val) + (format nil "<a href=\"#t~a\">>>~a</a>" num-val num-val)))) + :simple-calls t)) + (setf processed + (cl-ppcre:regex-replace-all + "https?://[\\w\\-\\.\\/\\?\\=\\&\\%#\\+:\\;]+" + processed + "<a href=\"\\&\" target=\"_blank\">\\&</a>")) + (setf processed + (cl-ppcre:regex-replace-all + (concatenate 'string + "<a href=\"(https?://[\\w\\-\\.\\/\\?\\=\\&\\%#\\+:\\;]+" + "\\.(?:png|jpg|jpeg|gif|webp|bmp))\" " + "target=\"_blank\">.*?</a>") + processed + (concatenate 'string + "<br /><a href=\"\\1\" target=\"_blank\">" + "<img src=\"\\1\" style=\"max-width:300px; " + "max-height:300px; display:block; margin:0.5em 0;\" " + "alt=\"preview\" /></a><br />"))) + (setf processed + (cl-ppcre:regex-replace-all + "image\\+<a href=\"(https?://[\\w\\-\\.\\/\\?\\=\\&\\%#\\+:\\;]+)\" target=\"_blank\">.*?</a>" + processed + (concatenate 'string + "<br /><a href=\"\\1\" target=\"_blank\">" + "<img src=\"\\1\" style=\"max-width:300px; " + "max-height:300px; display:block; margin:0.5em 0;\" " + "alt=\"preview\" /></a><br />"))) + (setf processed + (cl-ppcre:regex-replace-all + "\\r\\n" + processed + (string #\Newline))) + (setf processed + (cl-ppcre:regex-replace-all + "\\n\\n+" + processed + "</p><p>")) + (setf processed + (cl-ppcre:regex-replace-all + "\\n" + processed + "<br />")) + (setf processed (format nil "<p>~a</p>" processed)) + (dolist (pair code-blocks) + (let ((placeholder (car pair)) + (content (cdr pair))) + (setf processed + (cl-ppcre:regex-replace-all + placeholder + processed + (lambda (match-string &optional regs) + (declare (ignore match-string regs)) + (multiple-value-bind (match-start match-end reg-starts reg-ends) + (cl-ppcre:scan "^(?i)(lisp|cl|common-lisp)\\r?\\n" content) + (declare (ignore reg-starts reg-ends)) + (if match-start + (let* ((escaped-code (subseq content match-end)) + (raw-code (unescape-html escaped-code)) + (colorized-code (handler-case (colorize:html-colorization :common-lisp raw-code) + (error () (cl-who:escape-string raw-code))))) + ;; Note: colorize already wraps the result in <span class="..."><span class="paren1">... + ;; We wrap it in <pre class="lisp-code-block"> but keep a data-raw-code attribute + ;; or just use content for JS execution. JSCL needs the raw text. To avoid JSCL trying + ;; to parse HTML, we'll embed the raw code in a hidden div, or rely on JS `textContent` + ;; which extracts raw text from nested HTML elements. `textContent` works well. + (format nil "</p><pre class=\"lisp-code-block\">~a</pre><p>" colorized-code)) + (format nil "</p><pre>~a</pre><p>" content)))) + :simple-calls t)))) + (setf processed + (cl-ppcre:regex-replace-all + "<p>\\s*</p>" + processed + "")) + processed)) + +(defun render-post-form (board thread-id) + (let ((error-id (format nil "reply-error-~a" thread-id))) + (cl-who:with-html-output-to-string (s nil :indent t) + (:p :id error-id :style "color: red; font-weight: bold; display: none;") + (:form :action (format nil "/~a/~a/post" board thread-id) + :method "POST" + :onsubmit (format nil "return validatePostForm(this, '~a');" error-id) + (:p (:textarea :name "epistula" + :rows 8 + :cols 78 + :placeholder "Message") + (:br) + (:input :type "text" :name "name" :class "name" :style "display:none") + (:input :type "text" :name "message" :class "message" :style "display:none") + (:input :type "submit" :value "Post")))))) + +(defun render-frontpage-thread (board thread-data index &optional (prefs *preferences*)) + (let* ((thread-id (car thread-data)) + (props (cdr thread-data)) + (headline (cdr (assoc 'cl-bbs/models:headline props))) + (posts (second (assoc 'cl-bbs/models:posts props))) + (next-post-number (if posts (1+ (reduce #'max posts :key #'car :initial-value 0)) 1)) + (truncated (cdr (assoc 'cl-bbs/models:truncated props))) + (theme (preferences-theme prefs))) + (cl-who:with-html-output-to-string (s nil :indent t) + (:pre :class "jump" + (:a :id (format nil "d~a" index) + :href (if (= index 10) "#d1" (format nil "#d~a" (1+ index))) "↓") + (cl-who:str " ")) + (let ((heading-style (if (string= theme "colored") + (format nil (concatenate 'string + "border-left: 5px solid hsl(~D, 80%, 45%); " + "padding-left: 10px; margin-left: 2%;") + (get-hash-hue thread-id)) + ""))) + (cl-who:htm + (:h2 :style heading-style + (:a :href (format nil "/~a/~a" board thread-id) (cl-who:esc headline))))) + (:dl + (let ((prev-id nil)) + (dolist (post posts) + (let* ((post-id (car post)) + (post-data (cdr post)) + (content (cdr (assoc 'cl-bbs/models:content post-data))) + (date (cdr (assoc 'cl-bbs/models:date post-data)))) + (when (and prev-id (> post-id (1+ prev-id))) + ;; Instead of hard assumption based just on IDs, actually check via truncated list if + ;; the missing IDs are meant to be rendered as collapsed (i.e. they actually exist in the background). + ;; Find the maximum contiguous subsegment of truncated IDs bridging prev-id and post-id. + (let* ((missing-ids (loop for id from (1+ prev-id) to (1- post-id) collect id)) + (actual-missing (if (listp truncated) + (remove-if-not (lambda (id) (member id truncated)) missing-ids) + missing-ids))) + (when (>= (length actual-missing) 2) + (let ((fst (car actual-missing)) + (lst (car (last actual-missing)))) + (cl-who:htm + (:dt :class "collapsed" :style "margin: 0.5em 2%; margin-left: 0; padding-left: 0;" + (:a :href (format nil "/~a/~a#t~ap~a" board thread-id thread-id fst) + (cl-who:str (format nil "~D" fst))) + (when (> lst fst) + (cl-who:htm + (cl-who:str "...") + (:a :href (format nil "/~a/~a#t~ap~a" board thread-id thread-id lst) + (cl-who:str (format nil "~D" lst))))))))))) + (setf prev-id post-id) + (let ((post-style (if (string= theme "colored") + (let ((hue (get-hash-hue post-id))) + (format nil (concatenate 'string + "background-color: hsl(~D, 85%, 96%); " + "border-left: 4px solid hsl(~D, 85%, 45%); " + "padding: 0.5em 1em; " + "margin: 0.3em 2% 1.2em 2%; " + "border-radius: 0 4px 4px 0;") + hue hue)) + ""))) + (cl-who:htm + (:dt :style "margin: 0.5em 2%; margin-left: 0; padding-left: 0;" + (:a :href (format nil "/~a/~a#t~ap~a" board thread-id thread-id post-id) + :id (format nil "t~ap~a" thread-id post-id) + (cl-who:str (format nil "~a" post-id))) + " " + (:samp (cl-who:esc date))) + (:dd :style post-style (cl-who:str (format-text content thread-id)))))))) + (unless (is-board-locked board) + (cl-who:htm + (:dt :style "margin: 0.5em 2%; margin-left: 0; padding-left: 0;" + (:a :href (format nil "#t~ap~a" thread-id next-post-number) + :id (format nil "t~ap~a" thread-id next-post-number) + (cl-who:str (format nil "~a" next-post-number)))) + (:dd (cl-who:str (render-post-form board thread-id)))))) + (:hr)))) + +(defun render-index (board threads &optional (prefs *preferences*)) + "Renders the board index (frontpage) HTML with the list of active THREADS +and the new thread form, using layout PREFS." + (layout (format nil "/~a/" board) nil prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (cl-who:str (render-board-name board)) + (cl-who:str (render-menu board "front")) + (:hr) + (loop for t-data in threads + for i from 1 + do (cl-who:htm (cl-who:str (render-frontpage-thread board t-data i prefs)))) + (unless (is-board-locked board) + (cl-who:htm (cl-who:str (render-thread-form board)))) + (:hr) + (cl-who:str (render-footer-html board))))) + +(defun render-list (board threads &optional (prefs *preferences*)) + "Renders the board thread-list HTML page, showing all THREADS in tabular format, using layout PREFS." + (layout (format nil "/~a/" board) nil prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (cl-who:str (render-board-name board)) + (cl-who:str (render-menu board "list")) + (:hr) + (:table :summary "Thread list" + (:thead (:tr (:th "#") (:th "headline") (:th "posts") (:th "last update"))) + (:tbody + (loop for t-data in threads + for i from 1 + do (let* ((thread-id (car t-data)) + (props (cdr t-data)) + (headline (cdr (assoc 'cl-bbs/models:headline props))) + (messages (cdr (assoc 'cl-bbs/models:messages props))) + (date (cdr (assoc 'cl-bbs/models:date props)))) + (cl-who:htm + (:tr (:td (cl-who:str (format nil "~a" i))) + (:td (:a :href (format nil "/~a/~a" board thread-id) (cl-who:esc headline))) + (:td (cl-who:str (format nil "~a" messages))) + (:td (:samp (cl-who:esc date))))))))) + (:hr) + (cl-who:str (render-footer-html board))))) + +(defun render-thread (board thread-id thread-data &optional range-string (prefs *preferences*)) + "Renders a single thread page HTML for THREAD-ID under BOARD with THREAD-DATA (comments), +optionally filtered by RANGE-STRING, using layout PREFS." + (let* ((theme (preferences-theme prefs)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (headline (if (consp (car raw-thread)) + (cdr (assoc 'cl-bbs/models:headline raw-thread)) + (cdr (assoc 'cl-bbs/models:headline (list raw-thread))))) + (posts-assoc (if (consp (car raw-thread)) (assoc 'cl-bbs/models:posts raw-thread) (cadr thread-data))) + (posts-list (if (and posts-assoc (listp (cdr posts-assoc)) (not (keywordp (cdr posts-assoc)))) + (if (listp (cadr posts-assoc)) (cadr posts-assoc) (cdr posts-assoc)) + (cdr posts-assoc))) + (posts (if (listp (car posts-list)) posts-list (list posts-list))) + (next-post-number (if posts (1+ (reduce #'max posts :key #'car :initial-value 0)) 1)) + (filter-func (if (and range-string (string/= range-string "")) + (let ((allowed-ids (make-hash-table :test #'eql))) + (dolist (part (cl-ppcre:split "," range-string)) + (let ((subparts (cl-ppcre:split "-" part))) + (cond + ((= (length subparts) 1) + (let ((id (parse-integer (first subparts) :junk-allowed t))) + (when id + (setf (gethash id allowed-ids) t)))) + ((= (length subparts) 2) + (let ((start (parse-integer (first subparts) :junk-allowed t)) + (end (parse-integer (second subparts) :junk-allowed t))) + (when (and start end (<= start end)) + (loop for id from start to end + do (setf (gethash id allowed-ids) t)))))))) + (lambda (id) (gethash id allowed-ids))) + (lambda (id) (declare (ignore id)) t)))) + (layout (format nil "/~a/" board) "thread" prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (cl-who:str (render-board-name board)) + (cl-who:str (render-menu board "thread")) + (:hr) + (let ((heading-style (if (string= theme "colored") + (format nil "border-left: 5px solid hsl(~D, 80%, 45%); padding-left: 10px;" + (get-hash-hue thread-id)) + ""))) + (cl-who:htm + (:h2 :style heading-style (cl-who:esc headline)))) + (:dl + (loop for post in posts + for post-id = (car post) + for post-data = (cdr post) + for content = (cdr (assoc 'cl-bbs/models:content post-data)) + for date = (cdr (assoc 'cl-bbs/models:date post-data)) + when (funcall filter-func post-id) + do (let ((post-style (if (string= theme "colored") + (let ((hue (get-hash-hue post-id))) + (format nil (concatenate 'string + "background-color: hsl(~D, 85%, 96%); " + "border-left: 4px solid hsl(~D, 85%, 45%); " + "padding: 0.5em 1em; margin: 0.3em 0 1.2em 0; " + "border-radius: 0 4px 4px 0;") + hue hue)) + ""))) + (cl-who:htm + (:dt (:a :href (format nil "/~a/~a#t~ap~a" board thread-id thread-id post-id) + :id (format nil "t~ap~a" thread-id post-id) + (cl-who:str (format nil "~a" post-id))) + " " + (:samp (cl-who:esc date))) + (:dd :style post-style (cl-who:str (format-text content thread-id)))))) + (unless (is-board-locked board) + (cl-who:htm + (:dt (:a :href (format nil "#t~ap~a" thread-id next-post-number) + :id (format nil "t~ap~a" thread-id next-post-number) + (cl-who:str (format nil "~a" next-post-number)))) + (:dd (cl-who:str (render-post-form board thread-id)))))) + (:hr) + (cl-who:str (render-footer-html board)))))) + +(defun render-preferences (board &optional (prefs *preferences*)) + "Renders the board preferences HTML page, allowing users to choose a custom +stylesheet THEME, default-board and search configuration." + (let* ((sexp-dir (merge-pathnames "sexp/" cl-bbs/storage:*base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) + (car (last (pathname-directory path)))) + paths) + #'string<)) + (theme (preferences-theme prefs)) + (syntax-theme (preferences-syntax-theme prefs)) + (default-board (preferences-default-board prefs)) + (search-hide-input (preferences-search-hide-input prefs)) + (search-local-only (preferences-search-local-only prefs)) + (search-position (preferences-search-position prefs))) + (layout (format nil "/~a/ - Preferences" board) "preferences" prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (cl-who:str (render-board-name board)) + (cl-who:str (render-menu board "preferences")) + (:hr) + (:h2 "Preferences") + (:form :action (format nil "/~a/preferences" board) :method "POST" :class "preferences-form" + (:div :style "margin-bottom: 2em;" + (:h3 :style "margin-bottom: 0.5em;" "Style Theme") + (:p :style "color: #555; font-size: 0.9em; margin-bottom: 0.8em;" + "Customize the look and feel of the textboard.") + (:div :class "theme-selector-container" + (dolist (item '("default" "dark" "no" "colored" "matrix")) + (cl-who:htm + (:label :class "theme-option-label" :style "margin-right: 15px;" + (:input :type "radio" + :name "theme" + :value item + :checked (and theme (string= theme item)) + :onchange "updateThemePreview(this.value)") + (:span :class "theme-option-text" (cl-who:str item))))))) + + (:div :style "margin-bottom: 2em;" + (:h3 :style "margin-bottom: 0.5em;" "Syntax Theme") + (:p :style "color: #555; font-size: 0.9em; margin-bottom: 0.8em;" + "Choose syntax highlighting color scheme for Lisp code.") + (:div :class "syntax-theme-selector-container" + (dolist (item '("simple" "colorful")) + (cl-who:htm + (:label :class "theme-option-label" :style "margin-right: 15px;" + (:input :type "radio" + :name "syntax_theme" + :value item + :checked (and syntax-theme (string= syntax-theme item))) + (:span :class "theme-option-text" (cl-who:str item))))))) + + (:div :style "margin-bottom: 2em;" + (:h3 :style "margin-bottom: 0.5em;" "Default Board") + (:p :style "color: #555; font-size: 0.9em; margin-bottom: 0.8em;" + "Select the board you land on when visiting the root domain.") + (:div :class "board-selector-container" + (:select :name "default_board" :style "padding: 4px; font-size: 0.95em;" + (:option :value "" + :selected (or (null default-board) (string= default-board "")) + "None (Main Page)") + (dolist (item boards) + (cl-who:htm + (:option :value item + :selected (and default-board (string= default-board item)) + (cl-who:str (format nil "/~a/" item)))))))) + + (:div :style "margin-bottom: 2em;" + (:h3 :style "margin-bottom: 0.5em;" "Search Settings") + (:p :style "color: #555; font-size: 0.9em; margin-bottom: 0.8em;" + "Configure how the search bar behaves on board indices and thread views.") + (:div :class "search-preferences-container" :style "line-height: 1.8em;" + (:div :style "margin-bottom: 0.8em;" + (:label :style "font-weight: bold;" + "Hide search input in board view: ") + (:br) + (:select :name "search_hide_input" :style "padding: 4px; font-size: 0.95em;" + (:option :value "no" :selected (string= search-hide-input "no") "No") + (:option :value "yes" :selected (string= search-hide-input "yes") "Yes"))) + (:div :style "margin-bottom: 0.8em;" + (:label :style "font-weight: bold;" + "Only make local searches in the current board: ") + (:br) + (:select :name "search_local_only" + :style "padding: 4px; font-size: 0.95em;" + (:option :value "no" + :selected (string= search-local-only "no") + "No (Global)") + (:option :value "yes" + :selected (string= search-local-only "yes") + "Yes (Local)"))) + (:div :style "margin-bottom: 0.8em;" + (:label :style "font-weight: bold;" + "Placement of search input: ") + (:br) + (:select :name "search_position" + :style "padding: 4px; font-size: 0.95em;" + (:option :value "top" + :selected (string= search-position "top") + "Top (Header)") + (:option :value "bottom" + :selected (string= search-position "bottom") + "Bottom (Footer)"))))) + + (:p :style "margin-top: 2em;" + (:input :type "submit" :value "Save Preferences"))) + (:script " +function updateThemePreview(themeValue) { + // Find all stylesheet links + const links = document.querySelectorAll('link[rel=\"stylesheet\"]'); + for (const link of links) { + if (link.href.includes('/static/styles/themes/')) { + link.href = '/static/styles/themes/' + themeValue + '.css'; + } + } +} +") + (:hr) + (cl-who:str (render-footer-html)))))) + +(defun render-moderation (boards &optional board threads thread comments (prefs *preferences*) headline) + "Renders the admin/moderation control panel HTML page showing BOARDS and allowing deletions/edits." + (layout "cl-bbs Moderation Panel" "moderation" prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (:h1 "Moderation Panel") + (:p (:a :href "/" "Back to Home")) + (:hr) + (:h2 "Boards") + (:ul + (dolist (b boards) + (cl-who:htm + (:li (:strong (:a :href (format nil "/admin?board=~a" b) (cl-who:esc b))) + " " + (:form :action "/admin/action" :method "POST" :style "display:inline;" + (:input :type "hidden" :name "action" :value "delete-board") + (:input :type "hidden" :name "board" :value b) + (:input :type "submit" :value "Delete Board" :class "delete-button" + :onclick (concatenate 'string + "return confirm('Are you sure you want to " + "delete the ENTIRE board? " + "This cannot be undone.');"))))))) + (:h2 "Create Board") + (:p "Enter a board name below to create a new board. Board names must be in " + (:strong "kebab-case") + " (only lowercase letters, numbers, and hyphens; no spaces or underlines).") + (:div :style "margin: 1em 0;" + (:form :action "/admin/action" :method "POST" :onsubmit "return validateCreateBoard()" + (:input :type "hidden" :name "action" :value "create-board") + (:input :type "text" :name "board" :id "new-board-name" :placeholder "board-name" + :style "padding: 6px; font-size: 1em; border: 1px solid #bababa; + border-radius: 4px; font-family: monospace;") + " " + (:input :type "submit" :value "Create Board")) + (:p :id "board-error" :style "color: red; font-size: 0.9em; margin: 0.5em 0; display: none;")) + (:script :type "text/javascript" + "function validateCreateBoard() { + const input = document.getElementById('new-board-name'); + const error = document.getElementById('board-error'); + const boardName = input.value.trim(); + + // Regex for kebab-case (lowercase alphanumeric and hyphens only, no start/end hyphens) + const kebabRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + + if (!boardName) { + error.textContent = 'Please enter a board name.'; + error.style.display = 'block'; + return false; + } + + if (!kebabRegex.test(boardName)) { + error.textContent = 'Invalid board name! Must contain only lowercase ' + + 'alphanumeric characters and hyphens (e.g. \"lisp-board\", no spaces, ' + + 'underlines or capitals).'; + error.style.display = 'block'; + return false; + } + + error.style.display = 'none'; + return true; +}") + (when board + (cl-who:htm + (:hr) + (:h2 (cl-who:fmt "Threads in /~a/" board)) + (if threads + (cl-who:htm + (:table :border 1 :cellpadding 5 + (:thead (:tr (:th "ID") (:th "Headline") (:th "Date") (:th "Actions"))) + (:tbody + (dolist (t-data threads) + (let* ((tid (car t-data)) + (props (cdr t-data)) + (headline (cdr (assoc 'cl-bbs/models:headline props)))) + (cl-who:htm + (:tr (:td (cl-who:str (format nil "~a" tid))) + (:td (:a :href (format nil "/admin?board=~a&thread=~a" board tid) + (cl-who:esc headline))) + (:td (cl-who:str (format nil "~a" (cdr (assoc 'cl-bbs/models:date props))))) + (:td (:form :action "/admin/action" :method "POST" :style "display:inline;" + (:input :type "hidden" :name "action" :value "delete-thread") + (:input :type "hidden" :name "board" :value board) + (:input :type "hidden" :name "thread" :value tid) + (:input :type "submit" :value "Delete Thread" :class "delete-button" + :onclick + (concatenate 'string + "return confirm('Are you sure you want " + "to delete this thread?');"))) + (unless (string-equal board "shame") + (cl-who:htm + (:form :action "/admin/action" :method "POST" + :style "display:inline; margin-left: 5px;" + (:input :type "hidden" :name "action" :value "shame-thread") + (:input :type "hidden" :name "board" :value board) + (:input :type "hidden" :name "thread" :value tid) + (:input :type "submit" :value "Shame" :class "shame-button" + :onclick + (concatenate 'string + "return confirm('Are you sure you want " + "to move this thread to the shame " + "board?');"))))))))))))) + (cl-who:htm (:p "No threads found on this board."))))) + (when (and board thread) + (cl-who:htm + (:hr) + (:h2 (cl-who:fmt "Comments in Thread #~a (~a)" thread board)) + (if comments + (cl-who:htm + (:dl + (dolist (p comments) + (let* ((pid (car p)) + (pdata (cdr p)) + (content (cdr (assoc 'cl-bbs/models:content pdata))) + (date (cdr (assoc 'cl-bbs/models:date pdata)))) + (cl-who:htm + (:dt "No." (cl-who:str (format nil "~a" pid)) " " (:samp (cl-who:esc date)) + " " + (:form :action "/admin/action" :method "POST" :style "display:inline;" + (:input :type "hidden" :name "action" :value "delete-comment") + (:input :type "hidden" :name "board" :value board) + (:input :type "hidden" :name "thread" :value thread) + (:input :type "hidden" :name "comment" :value pid) + (:input :type "submit" :value "Delete Comment" :class "delete-button" + :onclick + "return confirm('Are you sure you want to delete this comment?');"))) + (:dd + (:div :class "comment-preview" + (cl-who:str (format-text content thread))) + (:form :action "/admin/action" :method "POST" :style "margin-top: 0.5em;" + (:input :type "hidden" :name "action" :value "edit-comment") + (:input :type "hidden" :name "board" :value board) + (:input :type "hidden" :name "thread" :value thread) + (:input :type "hidden" :name "comment" :value pid) + (when (and (= pid 1) headline) + (cl-who:htm + (:p (:label :for "headline" "Thread Headline: ") + (:br) + (:input :type "text" :name "headline" :id "headline" + :size 60 :value headline)))) + (:textarea :name "content" :rows 3 :cols 60 (cl-who:str content)) + (:br) + (:input :type "submit" :value "Save Changes")))))))) + (cl-who:htm (:p "No comments found.")))))))) + +(defun render-search-results (query results &optional (prefs *preferences*)) + "Renders the search results page HTML, showing matching posts for the given QUERY, using layout PREFS." + (let ((theme (preferences-theme prefs))) + (layout (format nil "Search: ~a" query) "search-results" prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (:h1 "Search Results") + (:p :style "margin: 0.5em 2%;" + (:button :class "lisp-btn" :onclick "history.back();" "← Go Back") + " - Query: " + (:strong (cl-who:esc query))) + (:hr) + (if (null results) + (cl-who:htm (:p :style "margin: 2em; text-align: center;" "No results found matching your query.")) + (cl-who:htm + (:dl :style "margin: 1em 2%;" + (dolist (match results) + (let* ((board (getf match :board)) + (thread-id (getf match :thread-id)) + (headline (getf match :headline)) + (post-id (getf match :post-id)) + (date (getf match :date)) + (content (getf match :content)) + (post-style (if (string= theme "colored") + (let ((hue (get-hash-hue post-id))) + (format nil (concatenate 'string + "background-color: hsl(~D, 85%, 96%); " + "border-left: 4px solid hsl(~D, 85%, 45%); " + "padding: 0.5em 1em; " + "margin: 0.3em 0 1.2em 0; " + "border-radius: 0 4px 4px 0;") + hue hue)) + ""))) + (cl-who:htm + (:dt :style "margin-top: 1.5em; font-size: 0.95em;" + "[" (:a :href (format nil "/~a/" board) (cl-who:esc board)) "] " + (:a :href (format nil "/~a/~a" board thread-id) (:strong (cl-who:esc headline))) + " - Post " + (:a :href (format nil "/~a/~a#t~ap~a" board thread-id thread-id post-id) + (cl-who:str (format nil "#~a" post-id))) + " " + (:samp (cl-who:esc date))) + (:dd :style post-style + (cl-who:str (format-text content thread-id))))))))) + (:hr) + (cl-who:str (render-footer-html)))))) + +(defun render-playground (&optional board (prefs *preferences*)) + "Renders the interactive Common Lisp playground view." + (layout (if board (format nil "/~a/ - Lisp Playground" board) "Lisp Playground") + "playground-page" + prefs + (cl-who:with-html-output-to-string (s nil :indent t) + (when board + (cl-who:htm (cl-who:str (render-menu board "playground")))) + (:h2 "Common Lisp Playground") + (:p :style "margin: 0.5em 2%; font-size: 0.95em;" + "Write and execute Common Lisp code directly in your browser using " + (:a :href "https://github.com/jscl-project/jscl" :target "_blank" "JSCL") + ". Everything runs completely client-side in a sandboxed environment.") + (:div :class "playground-container" :style "margin: 1.5em 2%;" + (:div :style "margin-bottom: 1em; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;" + (:span "Load Example: ") + (:select :id "playground-examples" :style "padding: 4px;" + (:option :value "" "-- Select Example --") + (:option :value "hello" "Hello World") + (:option :value "fib" "Fibonacci Numbers") + (:option :value "loop" "Loop Macro") + (:option :value "clos" "Common Lisp Object System (CLOS)"))) + (:div :id "example-data-hello" :style "display:none;" + (cl-who:str (colorize:html-colorization :common-lisp "(format t \"Hello, World!~%\")"))) + (:div :id "example-data-fib" :style "display:none;" + (cl-who:str (colorize:html-colorization :common-lisp "(defun fib (n) + (if (< n 2) + n + (+ (fib (- n 1)) (fib (- n 2))))) + +(format t \"Fibonacci of 10 is: ~a~%\" (fib 10))"))) + (:div :id "example-data-loop" :style "display:none;" + (cl-who:str (colorize:html-colorization :common-lisp "(loop for x from 1 to 5 + do (format t \"Square of ~d is ~d~%\" x (* x x)))"))) + (:div :id "example-data-clos" :style "display:none;" + (cl-who:str (colorize:html-colorization :common-lisp "(defclass person () + ((name :accessor person-name :initarg :name) + (age :accessor person-age :initarg :age))) + +(defmethod introduce ((p person)) + (format t \"Hi, I am ~a and I am ~a years old.~%\" + (person-name p) + (person-age p))) + +(let ((p (make-instance 'person :name \"Alice\" :age 30))) + (introduce p))"))) + (:pre :id "playground-editor" + :class "lisp-code-block" + :contenteditable "true" + :spellcheck "false" + :style (concatenate 'string + "min-height: 200px; width: 96%; max-width: 800px; " + "font-family: monospace; font-size: 1.1em; padding: 10px; " + "border: 1px solid currentColor; background: transparent; " + "color: inherit; margin-bottom: 1em; outline: none; " + "overflow: auto; white-space: pre-wrap;") + "") + (:div :style "display: flex; gap: 10px; margin-bottom: 1em;" + (:button :id "playground-run" :class "lisp-btn" "Run Code") + (:button :id "playground-clear" :class "lisp-btn" "Clear Output")) + (:h3 "Output Console") + (:pre :id "playground-output" + :style (concatenate 'string + "display: none; padding: 10px; width: 96%; max-width: 800px; " + "border: 1px dashed currentColor; " + "background-color: rgba(128, 128, 128, 0.05); white-space: pre-wrap; " + "word-break: break-all; font-family: monospace; font-size: 1.1em; " + "line-height: 1.4em;") + "")) + (:hr) + (cl-who:str (render-footer-html))))) |
