From 7023e4c7c8f158df78b5e71ddc2640290409eca1 Mon Sep 17 00:00:00 2001
From: chers
Date: Fri, 31 Jul 2026 09:18:39 +0000
Subject: Update cl-bbs
---
server/admin.lisp | 574 ++++++++++++++++++++++++++++
server/handlers.lisp | 903 ++++++++++++++++++++++++++++++++++++++++++++
server/main.lisp | 61 +++
server/models.lisp | 35 ++
server/rss.lisp | 122 ++++++
server/storage.lisp | 43 +++
server/views.lisp | 1009 ++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 2747 insertions(+)
create mode 100644 server/admin.lisp
create mode 100644 server/handlers.lisp
create mode 100644 server/main.lisp
create mode 100644 server/models.lisp
create mode 100644 server/rss.lisp
create mode 100644 server/storage.lisp
create mode 100644 server/views.lisp
(limited to 'server')
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 [args...]~%~%")
+ (format t "Commands:~%")
+ (format t " generate-index 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 Remove an entire thread/post~%")
+ (format t " remove-duplicates Remove sequential duplicate comments across all boards~%")
+ (format t " remove-comment Remove a specific comment from a thread~%")
+ (format t " edit Edit a specific comment using $EDITOR~%")
+ (format t " move 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 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 ~%")))
+ ((string= cmd "remove-post")
+ (if (>= (length argv) 3)
+ (remove-post (second argv) (third argv))
+ (format t "Usage: cl-bbs-admin remove-post ~%")))
+ ((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 ~%")))
+ ((string= cmd "edit")
+ (if (>= (length argv) 4)
+ (edit-comment (second argv) (third argv) (fourth argv))
+ (format t "Usage: cl-bbs-admin edit ~%")))
+ ((string= cmd "move")
+ (if (>= (length argv) 4)
+ (move-post (second argv) (third argv) (fourth argv))
+ (format t "Usage: cl-bbs-admin move ~%")))
+ ((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 ~%")))
+ (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 "/~a/ - ~a " b b (get-board-description b)) html-list))
+ (format nil "" (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:))
+
+(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 ""
+ 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 "~%")
+ (format s "~%")
+ (format s " ~%")
+ (if (string= board "all")
+ (progn
+ (format s " cl-bbs - all boards ~%")
+ (format s " Latest threads from all boards on cl-bbs. ~%"))
+ (progn
+ (format s " cl-bbs - /~a/ ~%" board)
+ (format s " Latest threads from /~a/. ~%" board)))
+ (format s " ~a~%" request-url)
+ (format s " ~a ~%" rfc822-date)
+ (format s " cl-bbs RSS 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 " - ~%")
+ (format s "
~%" headline)
+ (format s " ~a~%" thread-url)
+ (format s " ~%" headline) ;; Fallback to headline
+ (format s " ~%" content)
+ (format s " ~a ~%" pub-date)
+ (format s " ~a ~%" thread-url)
+ (format s " ~%")))
+
+ (format s " ~%")
+ (format s " ~%"))))
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 "" :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 "")
+ (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
+ "\\1 "))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "\\*\\*(.*?)\\*\\*"
+ processed
+ "\\1 "))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "__(.*?)__"
+ processed
+ "\\1 "))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "`([^`]+)`"
+ processed
+ "\\1"))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "~~(.*?)~~"
+ processed
+ "\\1"))
+ (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 " thread-id num-val num-val)
+ (format nil ">>~a " num-val num-val))))
+ :simple-calls t))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "https?://[\\w\\-\\.\\/\\?\\=\\&\\%#\\+:\\;]+"
+ processed
+ "\\& "))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ (concatenate 'string
+ ".*? ")
+ processed
+ (concatenate 'string
+ ""
+ " ")))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "image\\+.*? "
+ processed
+ (concatenate 'string
+ ""
+ " ")))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "\\r\\n"
+ processed
+ (string #\Newline)))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "\\n\\n+"
+ processed
+ "
"))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "\\n"
+ processed
+ " "))
+ (setf processed (format nil "
~a
" 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 ...
+ ;; We wrap it in 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 "~a " colorized-code))
+ (format nil "
~a " content))))
+ :simple-calls t))))
+ (setf processed
+ (cl-ppcre:regex-replace-all
+ "
\\s*
"
+ 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)))))
--
cgit v1.2.3