diff options
| author | chers <admin@tilde.tailb619f6.ts.net> | 2026-07-31 09:18:39 +0000 |
|---|---|---|
| committer | chers <admin@tilde.tailb619f6.ts.net> | 2026-07-31 09:18:39 +0000 |
| commit | 7023e4c7c8f158df78b5e71ddc2640290409eca1 (patch) | |
| tree | 077fb46bdb484bbbf7f9a81fd1d512f66650be10 /server/handlers.lisp | |
Diffstat (limited to 'server/handlers.lisp')
| -rw-r--r-- | server/handlers.lisp | 903 |
1 files changed, 903 insertions, 0 deletions
diff --git a/server/handlers.lisp b/server/handlers.lisp new file mode 100644 index 0000000..11ec701 --- /dev/null +++ b/server/handlers.lisp @@ -0,0 +1,903 @@ +(defpackage :cl-bbs/handlers + (:use :cl) + (:import-from :cl-bbs/storage + #:*base-dir* + #:read-sexp-file + #:write-sexp-file + #:is-board-locked + #:ensure-board-dirs) + (:import-from :cl-bbs/rss + #:generate-rss + #:get-all-boards-rss-threads) + (:import-from :cl-bbs/views + #:render-moderation + #:render-index + #:render-list + #:render-preferences + #:render-error-page + #:render-thread + #:render-search-results + #:render-playground) + (:export #:handle-request + #:*headline-limit* + #:*body-limit*)) + +(in-package :cl-bbs/handlers) + +(defparameter *headline-limit* + (or (and (uiop:getenv "SBBS_HEADLINE_LIMIT") + (parse-integer (uiop:getenv "SBBS_HEADLINE_LIMIT") :junk-allowed t)) + 128) + "Maximum character limit for post headlines.") + +(defparameter *body-limit* + (or (and (uiop:getenv "SBBS_BODY_LIMIT") + (parse-integer (uiop:getenv "SBBS_BODY_LIMIT") :junk-allowed t)) + 4096) + "Maximum character limit for post bodies.") + +(defun parse-cookies (cookie-string) + "Parses a Cookie header string like 'theme=dark; foo=bar' into an alist." + (when cookie-string + (let ((cookies '())) + (dolist (part (cl-ppcre:split ";" cookie-string)) + (let ((pair (cl-ppcre:split "=" (string-trim '(#\Space #\Tab #\Newline #\Return) part) :limit 2))) + (when (= (length pair) 2) + (let ((name (string-trim '(#\Space #\Tab #\Newline #\Return) (first pair))) + (val (string-trim '(#\Space #\Tab #\Newline #\Return) (second pair)))) + (push (cons name val) cookies))))) + (nreverse cookies)))) + +(defun sanitize-theme-name (theme-str) + "Validates and returns theme-str if it consists only of alphanumeric characters, +hyphens, and underscores. Otherwise returns nil to prevent injection/directory traversal." + (when (and theme-str (cl-ppcre:scan "^[a-zA-Z0-9_-]+$" theme-str)) + theme-str)) + +(defun sanitize-board-name (board-str) + "Validates and returns board-str if it consists only of alphanumeric characters, +hyphens, and underscores. Otherwise returns nil to prevent injection/directory traversal." + (when (and board-str (cl-ppcre:scan "^[a-zA-Z0-9_-]+$" board-str)) + board-str)) + +(defun get-default-board-from-env (env) + "Retrieves the default board name from the request cookies." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-board (sanitize-board-name (cdr (assoc "default_board" cookies :test #'string=))))) + (and (not (string= cookie-board "")) cookie-board))) + +(defun get-theme-from-env (env) + "Retrieves the active theme name from the request query parameters or cookies." + (let* ((query-str (getf env :query-string)) + (query-theme (when (and query-str (not (string= query-str ""))) + (let ((params (quri:url-decode-params query-str))) + (sanitize-theme-name (cdr (assoc "theme" params :test #'string=))))))) + (or query-theme + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-theme (sanitize-theme-name (cdr (assoc "theme" cookies :test #'string=))))) + (or cookie-theme "default"))))) + +(defun get-syntax-theme-from-env (env) + "Retrieves the active syntax theme name from the request cookies." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (cookie-theme (sanitize-theme-name (cdr (assoc "syntax_theme" cookies :test #'string=))))) + (or cookie-theme "simple"))) + +(defun get-search-hide-input-from-env (env) + "Retrieves the setting for hiding search input in board view (returns \"yes\" or \"no\", default \"no\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_hide_input" cookies :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + +(defun get-search-local-only-from-env (env) + "Retrieves the setting for forcing local search in current board (returns \"yes\" or \"no\", default \"no\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_local_only" cookies :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + +(defun get-search-position-from-env (env) + "Retrieves the setting for search input placement (returns \"top\" or \"bottom\", default \"top\")." + (let* ((headers (getf env :headers)) + (cookie-str (and headers (gethash "cookie" headers))) + (cookies (parse-cookies cookie-str)) + (val (cdr (assoc "search_position" cookies :test #'string=)))) + (if (member val '("top" "bottom") :test #'string=) val "top"))) + +(defun get-board-description (board-name) + "Maps a board-name string to a friendly human-readable title." + (cond + ((string= board-name "prog") "Programming") + ((string= board-name "foo") "Foo Reference") + ((string= board-name "b") "random") + (t (string-capitalize board-name)))) + +(defun generate-boards-html-list () + (let* ((sexp-dir (merge-pathnames "sexp/" cl-bbs/storage:*base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) + (car (last (pathname-directory path)))) + paths) + #'string<)) + (html-list '())) + (dolist (b boards) + (push (format nil "<li><a href=\"/~a/\">/~a/ - ~a</a></li>" b b (get-board-description b)) html-list)) + (format nil "<ul>~{~A~%~}</ul>" (nreverse html-list)))) + +(defun authenticate-admin (env) + "Checks if the request has valid admin credentials in environment variables." + (let* ((admin-user (or (uiop:getenv "SBBS_ADMIN_USER") "admin")) + (admin-password (or (uiop:getenv "SBBS_ADMIN_PASSWORD") "superchanner")) + (headers (getf env :headers)) + (auth-str (and headers (gethash "authorization" headers))) + (creds (and auth-str (cl-ppcre:regex-replace-all "^Basic " auth-str "")))) + (when creds + (let* ((decoded (handler-case (cl-base64:base64-string-to-string creds) + (error () nil))) + (parts (and decoded (cl-ppcre:split ":" decoded :limit 2)))) + (and (= (length parts) 2) + (string= (first parts) admin-user) + (string= (second parts) admin-password)))))) + +(defun delete-board-dir (board) + (let ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*))) + (when (probe-file board-dir) + (uiop:delete-directory-tree board-dir :validate t)))) + +(defun get-board-threads (dir) + (let ((threads nil)) + (loop for i from 1 + for filepath = (merge-pathnames (format nil "~D" i) dir) + for exists = (probe-file filepath) + while (or exists (<= i 200)) + do (when exists + (let ((data (read-sexp-file filepath))) + (when data + (push (cons i data) threads))))) + threads)) + +(defun search-posts (query &optional board-filter) + "Searches across all boards (or a specific board if BOARD-FILTER is provided) for posts or headlines matching QUERY." + (let ((results '()) + (sexp-dir (merge-pathnames "sexp/" *base-dir*))) + (when (and query (string/= "" (string-trim '(#\Space #\Tab #\Newline #\Return) query))) + (let ((board-dirs (if (and board-filter (string/= "" board-filter)) + (list (merge-pathnames (format nil "~a/" board-filter) sexp-dir)) + (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))))) + (dolist (board-dir board-dirs) + (let ((board-name (car (last (pathname-directory board-dir)))) + ;; Get only files whose namestring consists only of digits (which are the thread S-expression files) + (thread-files (and (probe-file board-dir) + (remove-if-not (lambda (file) + (let ((name (file-namestring file))) + (and (string/= name "") + (every #'digit-char-p name)))) + (uiop:directory-files board-dir))))) + (dolist (thread-file thread-files) + (let* ((thread-id (file-namestring thread-file)) + (thread-data (read-sexp-file thread-file)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-thread))) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let ((posts (get-flat-posts posts-assoc))) + (dolist (post posts) + (let* ((post-id (car post)) + (post-data (cdr post)) + (content (cdr (assoc 'cl-bbs/models:content post-data))) + (date (cdr (assoc 'cl-bbs/models:date post-data)))) + (when (or (and headline (search query headline :test #'char-equal)) + (and content (search query content :test #'char-equal))) + (push (list :board board-name + :thread-id thread-id + :headline (or headline "No Headline") + :post-id post-id + :date (or date "") + :content (or content "")) + results)))))))))))) + (nreverse results))) + +(defun get-flat-posts (posts-assoc) + (when posts-assoc + (let* ((cdr-val (cdr posts-assoc)) + (cadr-val (and (listp cdr-val) (car cdr-val)))) + (if (and (listp cadr-val) (listp (car cadr-val))) + cadr-val + cdr-val)))) + +(defun regenerate-board-index (board) + (let* ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (threads-data (get-board-threads board-dir))) + (if (null threads-data) + (let ((list-path (merge-pathnames "list" board-dir)) + (index-path (merge-pathnames "index" board-dir))) + (when (probe-file list-path) (delete-file list-path)) + (when (probe-file index-path) (delete-file index-path))) + (let* ((sorted-threads + (sort threads-data + (lambda (a b) + (let* ((raw-a (if (and (consp (cdr a)) (consp (cadr a)) (consp (caadr a))) (cadr a) (cdr a))) + (raw-b (if (and (consp (cdr b)) (consp (cadr b)) (consp (caadr b))) (cadr b) (cdr b))) + (posts-assoc-a (assoc 'cl-bbs/models:posts raw-a)) + (posts-assoc-b (assoc 'cl-bbs/models:posts raw-b)) + (posts-a (get-flat-posts posts-assoc-a)) + (posts-b (get-flat-posts posts-assoc-b)) + (date-a (or (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts-a))))) "")) + (date-b (or (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts-b))))) ""))) + (string> date-a date-b))))) + (list-entries + (mapcar (lambda (t-entry) + (let* ((id (car t-entry)) + (t-data (cdr t-entry)) + (raw-t (if (and (consp t-data) + (consp (car t-data)) + (consp (caar t-data))) + (car t-data) + t-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-t)) + (posts (get-flat-posts posts-assoc)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-t))) + (date (cdr (assoc 'cl-bbs/models:date (cdr (car (last posts)))))) + (messages (length posts))) + `(,id (cl-bbs/models:headline . ,headline) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . ,messages)))) + sorted-threads)) + (frontpage-count (min 10 (length sorted-threads))) + (index-entries + (mapcar (lambda (t-entry) + (let* ((id (car t-entry)) + (t-data (cdr t-entry)) + (raw-t (if (and (consp t-data) + (consp (car t-data)) + (consp (caar t-data))) + (car t-data) + t-data)) + (headline (cdr (assoc 'cl-bbs/models:headline raw-t))) + (posts-assoc (assoc 'cl-bbs/models:posts raw-t)) + (posts (get-flat-posts posts-assoc)) + (truncated-ids (if (> (length posts) 6) + (mapcar #'car (butlast (cdr posts) 5)) + nil)) + (selected-posts (if (> (length posts) 6) (cons (car posts) (last posts 5)) posts))) + `(,id (cl-bbs/models:headline . ,headline) + (cl-bbs/models:truncated . ,truncated-ids) + (cl-bbs/models:posts ,selected-posts)))) + (subseq sorted-threads 0 frontpage-count))) + (list-path (merge-pathnames "list" board-dir)) + (index-path (merge-pathnames "index" board-dir))) + (write-sexp-file list-path list-entries) + (write-sexp-file index-path index-entries))))) + +(defun delete-thread-file (board thread-id) + (let ((filepath (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file filepath) + (delete-file filepath) + (regenerate-board-index board)))) + +(defun get-next-handler-thread-id (board) + (let* ((board-dir (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (files (and (probe-file board-dir) + (uiop:directory-files board-dir))) + (max-id 0)) + (dolist (file files) + (let ((id (handler-case (parse-integer (pathname-name file)) + (error () nil)))) + (when (and id (> id max-id)) + (setf max-id id)))) + (1+ max-id))) + +(defun shame-thread-file (board thread-id) + (let* ((source-file (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*)) + (target-board "shame") + (target-dir (merge-pathnames (format nil "sexp/~a/" target-board) *base-dir*))) + (when (and (probe-file source-file) (string-not-equal board target-board)) + (ensure-directories-exist target-dir) + (let* ((new-id (get-next-handler-thread-id target-board)) + (target-file (merge-pathnames (format nil "~D" new-id) target-dir))) + (uiop:copy-file source-file target-file) + (delete-file source-file) + (regenerate-board-index board) + (regenerate-board-index target-board))))) + +(defun delete-thread-comment (board thread-id comment-id) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let* ((actual-posts (get-flat-posts posts-assoc)) + (new-posts (remove-if (lambda (p) (= (car p) comment-id)) actual-posts))) + (if (null new-posts) + (delete-file thread-path) + (progn + (setf (cdr posts-assoc) (list new-posts)) + (write-sexp-file thread-path thread-data))) + (regenerate-board-index board))))))) + +(defun edit-thread-comment (board thread-id comment-id new-content &optional new-headline) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (when posts-assoc + (let* ((actual-posts (get-flat-posts posts-assoc)) + (new-posts (mapcar (lambda (p) + (if (= (car p) comment-id) + (cons (car p) + (mapcar (lambda (kv) + (if (eq (car kv) 'cl-bbs/models:content) + (cons (car kv) new-content) + kv)) + (cdr p))) + p)) + actual-posts))) + (setf (cdr posts-assoc) (list new-posts)) + (when (and (= comment-id 1) new-headline) + (let ((headline-assoc (assoc 'cl-bbs/models:headline raw-thread))) + (if headline-assoc + (setf (cdr headline-assoc) new-headline) + (setf raw-thread (append raw-thread (list (cons 'cl-bbs/models:headline new-headline))))))) + (write-sexp-file thread-path thread-data) + (regenerate-board-index board))))))) + +(defun get-date () + (multiple-value-bind (second minute hour date month year) + (get-decoded-time) + (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D" + year month date hour minute second))) + +(defun get-next-thread-number (threads) + (if (null threads) + 1 + (1+ (apply #'max (mapcar #'car threads))))) + +(defun create-thread (path headline date message) + (let ((thread `((cl-bbs/models:headline . ,headline) + (cl-bbs/models:posts . ((1 (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,message))))))) + (write-sexp-file path thread))) + +(defun add-thread-to-list (path thread-number headline date) + (let ((threads (read-sexp-file path))) + (write-sexp-file path + (cons `(,thread-number (cl-bbs/models:headline . ,headline) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . 1)) + threads)))) + +(defun add-thread-to-index (path thread-number headline date message) + (let ((threads (read-sexp-file path)) + (thread `(,thread-number + (cl-bbs/models:headline . ,headline) + (cl-bbs/models:truncated . nil) + (cl-bbs/models:posts + ((1 (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,message))))))) + (write-sexp-file path (cons thread threads)))) + +(defun update-thread-data (thread-data new-post) + (let* ((raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (if posts-assoc + (let* ((posts-list (if (listp (cdr posts-assoc)) + (if (listp (cadr posts-assoc)) + (cadr posts-assoc) + (cdr posts-assoc)) + (cdr posts-assoc))) + (actual-posts (if (listp (car posts-list)) posts-list (list posts-list))) + (updated-posts (append actual-posts (list new-post)))) + (setf (cdr posts-assoc) (list updated-posts)) + thread-data) + (append thread-data (list `(cl-bbs/models:posts (,new-post))))))) + +(defvar *app* (make-instance 'ningle:<app>)) + +(defun normalize-path (path) + (if (stringp path) + (let ((len (length path))) + (if (and (> len 1) + (char= (char path (1- len)) #\/)) + (subseq path 0 (1- len)) + path)) + path)) + +(defun get-body-params (params env) + (let ((has-theme (assoc "theme" params :test #'string=)) + (has-epistula (assoc "epistula" params :test #'string=)) + (has-action (assoc "action" params :test #'string=))) + (if (or has-theme has-epistula has-action) + params + (let ((content-len (getf env :content-length))) + (if (and content-len (> content-len 0) (getf env :raw-body)) + (let ((body-bytes (make-array content-len :element-type '(unsigned-byte 8))) + (stream (getf env :raw-body))) + (let ((bytes-read (read-sequence body-bytes stream))) + (let ((body-str (flexi-streams:octets-to-string + body-bytes :start 0 :end bytes-read :external-format :utf-8))) + (quri:url-decode-params body-str)))) + params))))) + +(defun handle-request (env) + "Accepts a Clack request environment plist, normalizes it, and dispatches it through ningle endpoints." + (let ((normalized-env (copy-list env))) + (setf (getf normalized-env :path-info) (normalize-path (getf env :path-info))) + (unless (hash-table-p (getf normalized-env :headers)) + (setf (getf normalized-env :headers) (make-hash-table :test 'equal))) + (when (getf normalized-env :request-method) + (setf (getf normalized-env :request-method) + (intern (string-upcase (symbol-name (getf normalized-env :request-method))) :keyword))) + (let ((cl-bbs/views:*preferences* (cl-bbs/views:make-preferences + :theme (get-theme-from-env normalized-env) + :syntax-theme (get-syntax-theme-from-env normalized-env) + :default-board (or (get-default-board-from-env normalized-env) "") + :search-hide-input (get-search-hide-input-from-env normalized-env) + :search-local-only (get-search-local-only-from-env normalized-env) + :search-position (get-search-position-from-env normalized-env)))) + (lack.component:call *app* normalized-env)))) + +(defun render-main-index () + (let ((index-file (pathname (or (uiop:getenv "SBBS_INDEX_FILE") + (merge-pathnames "src/static/index.html" + (asdf:system-source-directory :cl-bbs/server)))))) + (if (probe-file index-file) + (let* ((html-content (uiop:read-file-string index-file)) + (boards-list-html (generate-boards-html-list)) + (dynamic-html (cl-ppcre:regex-replace-all "<!--BOARDS-LIST-PLACEHOLDER-->" + html-content + (lambda (match &rest regs) + (declare (ignore match regs)) + boards-list-html)))) + `(200 (:content-type "text/html; charset=utf-8" + :cache-control "no-store, no-cache, must-revalidate, max-age=0" + :pragma "no-cache" + :expires "0") + (,dynamic-html))) + `(200 (:content-type "text/plain" + :cache-control "no-store, no-cache, must-revalidate, max-age=0" + :pragma "no-cache" + :expires "0") ("SchemeBBS clone root"))))) + +;; 1. GET / +(setf (ningle:route *app* "/" :method :GET) + (lambda (params) + (declare (ignore params)) + (let* ((env (lack.request:request-env ningle:*request*)) + (default-board (get-default-board-from-env env))) + (if default-board + `(303 (:location ,(format nil "/~a/" default-board)) ("Redirecting...")) + (render-main-index))))) + +;; 1b. GET /index.html +(setf (ningle:route *app* "/index.html" :method :GET) + (lambda (params) + (declare (ignore params)) + (render-main-index))) + +;; 2. GET /about +(setf (ningle:route *app* "/about" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((about-file (pathname (merge-pathnames "src/static/about.html" + (asdf:system-source-directory :cl-bbs/server))))) + (if (probe-file about-file) + `(200 (:content-type "text/html; charset=utf-8") + (,(uiop:read-file-string about-file))) + `(404 (:content-type "text/plain") ("About page not found")))))) + +;; 3. GET /admin (Admin Control Panel) +(setf (ningle:route *app* "/admin" :method :GET) + (lambda (params) + (let ((env (lack.request:request-env ningle:*request*))) + (if (not (authenticate-admin env)) + `(401 (:content-type "text/plain" + :www-authenticate "Basic realm=\"cl-bbs Admin\"") + ("Unauthorized")) + (let* ((sexp-dir (merge-pathnames "sexp/" *base-dir*)) + (paths (and (probe-file sexp-dir) (uiop:subdirectories sexp-dir))) + (boards (sort (mapcar (lambda (path) (car (last (pathname-directory path)))) paths) #'string<)) + (board (cdr (assoc "board" params :test #'string=))) + (thread-id-str (cdr (assoc "thread" params :test #'string=))) + (threads nil) + (comments nil) + (headline nil)) + (when (and board (member board boards :test #'string=)) + (let ((list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*))) + (when (probe-file list-path) + (setf threads (read-sexp-file list-path))))) + (when (and board thread-id-str) + (let ((thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id-str) *base-dir*))) + (when (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (assoc 'cl-bbs/models:posts raw-thread))) + (setf headline (cdr (assoc 'cl-bbs/models:headline raw-thread))) + (when posts-assoc + (setf comments (get-flat-posts posts-assoc))))))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-moderation boards board threads thread-id-str comments + cl-bbs/views:*preferences* headline)))))))) + +;; 4. POST /admin/action (Admin Actions) +(setf (ningle:route *app* "/admin/action" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env))) + (if (not (authenticate-admin env)) + `(401 (:content-type "text/plain" + :www-authenticate "Basic realm=\"cl-bbs Admin\"") + ("Unauthorized")) + (let ((action (cdr (assoc "action" parsed-params :test #'string=))) + (board (cdr (assoc "board" parsed-params :test #'string=))) + (thread-id-str (cdr (assoc "thread" parsed-params :test #'string=))) + (comment-id-str (cdr (assoc "comment" parsed-params :test #'string=))) + (content (cdr (assoc "content" parsed-params :test #'string=)))) + (cond + ((string= action "delete-board") + (delete-board-dir board) + `(303 (:location "/admin") ("Redirecting..."))) + ((string= action "create-board") + (let ((sanitized (sanitize-board-name board))) + (if sanitized + (progn + (ensure-board-dirs sanitized) + `(303 (:location "/admin") ("Redirecting..."))) + `(400 (:content-type "text/plain") ("Invalid Board Name"))))) + ((string= action "delete-thread") + (delete-thread-file board thread-id-str) + `(303 (:location ,(format nil "/admin?board=~a" board)) ("Redirecting..."))) + ((string= action "shame-thread") + (shame-thread-file board thread-id-str) + `(303 (:location ,(format nil "/admin?board=~a" board)) ("Redirecting..."))) + ((string= action "delete-comment") + (let ((cid (and comment-id-str (parse-integer comment-id-str :junk-allowed t)))) + (when cid + (delete-thread-comment board thread-id-str cid))) + `(303 (:location ,(format nil "/admin?board=~a&thread=~a" board thread-id-str)) ("Redirecting..."))) + ((string= action "edit-comment") + (let ((cid (and comment-id-str (parse-integer comment-id-str :junk-allowed t))) + (headline (cdr (assoc "headline" parsed-params :test #'string=)))) + (when cid + (edit-thread-comment board thread-id-str cid content headline))) + `(303 (:location ,(format nil "/admin?board=~a&thread=~a" board thread-id-str)) ("Redirecting..."))) + (t + `(400 (:content-type "text/plain") ("Invalid Action"))))))))) + +;; 5. GET /sw.js +(setf (ningle:route *app* "/sw.js" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((sw-file (merge-pathnames "src/static/sw.js" + (asdf:system-source-directory :cl-bbs/server)))) + (if (probe-file sw-file) + `(200 (:content-type "application/javascript") + (,(uiop:read-file-string sw-file))) + `(404 (:content-type "text/plain") ("Service worker not found")))))) + +;; 6. GET /manifest.json +(setf (ningle:route *app* "/manifest.json" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((manifest-file (merge-pathnames "src/static/manifest.json" + (asdf:system-source-directory :cl-bbs/server)))) + (if (probe-file manifest-file) + `(200 (:content-type "application/json") + (,(uiop:read-file-string manifest-file))) + `(404 (:content-type "text/plain") ("Manifest not found")))))) + +;; 6b. GET /search +(setf (ningle:route *app* "/search" :method :GET) + (lambda (params) + (let* ((query (cdr (assoc "q" params :test #'string=))) + (board-filter (cdr (assoc "board" params :test #'string=))) + (results (if query (search-posts query board-filter) nil))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-search-results (or query "") results cl-bbs/views:*preferences*)))))) + +;; 6b-api. POST /api/colorize +(setf (ningle:route *app* "/api/colorize" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (code (cdr (assoc "code" parsed-params :test #'string=)))) + (if code + `(200 (:content-type "text/html; charset=utf-8") + (,(handler-case (colorize:html-colorization :common-lisp code) + (error () (cl-who:escape-string code))))) + `(400 (:content-type "text/plain") ("No code provided")))))) + +;; 6c. GET /playground (Global Playground) +(setf (ningle:route *app* "/playground" :method :GET) + (lambda (params) + (declare (ignore params)) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-playground nil))))) + +;; 6d. GET /:board/playground (Board-scoped Playground) +(setf (ningle:route *app* "/:board/playground" :method :GET) + (lambda (params) + (let ((board (cdr (assoc :board params)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-playground board)))))) + +;; 7. GET /:board/list +(setf (ningle:route *app* "/:board/list" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*))) + (if (probe-file list-path) + (let ((list-data (read-sexp-file list-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-list board list-data cl-bbs/views:*preferences*)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-list board nil cl-bbs/views:*preferences*))))))) + +;; 8. GET /:board/preferences +(setf (ningle:route *app* "/:board/preferences" :method :GET) + (lambda (params) + (let ((board (cdr (assoc :board params)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-preferences board cl-bbs/views:*preferences*)))))) + +;; 9. POST /:board/preferences +(setf (ningle:route *app* "/:board/preferences" :method :POST) + (lambda (params) + (let* ((env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (board (cdr (assoc :board params))) + (theme (cdr (assoc "theme" parsed-params :test #'string=))) + (syntax-theme (cdr (assoc "syntax_theme" parsed-params :test #'string=))) + (default-board (let ((val (cdr (assoc "default_board" parsed-params :test #'string=)))) + (if val (string-trim '(#\Space #\Tab #\Newline #\Return) val) ""))) + (search-hide-input (let ((val (cdr (assoc "search_hide_input" parsed-params :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + (search-local-only (let ((val (cdr (assoc "search_local_only" parsed-params :test #'string=)))) + (if (member val '("yes" "no") :test #'string=) val "no"))) + (search-position (let ((val (cdr (assoc "search_position" parsed-params :test #'string=)))) + (if (member val '("top" "bottom") :test #'string=) val "top")))) + `(303 (:location ,(format nil "/~a/preferences" board) + :set-cookie ,(format nil "theme=~a; Path=/; Max-Age=31536000" (or theme "default")) + :set-cookie ,(format nil "syntax_theme=~a; Path=/; Max-Age=31536000" (or syntax-theme "simple")) + :set-cookie ,(format nil "default_board=~a; Path=/; Max-Age=31536000" (or default-board "")) + :set-cookie ,(format nil "search_hide_input=~a; Path=/; Max-Age=31536000" search-hide-input) + :set-cookie ,(format nil "search_local_only=~a; Path=/; Max-Age=31536000" search-local-only) + :set-cookie ,(format nil "search_position=~a; Path=/; Max-Age=31536000" search-position)) + ("Redirecting..."))))) + +;; RSS Feed (All boards) +(setf (ningle:route *app* "/rss" :method :GET) + (lambda (params) + (declare (ignore params)) + (let ((env (lack.request:request-env ningle:*request*)) + (all-threads (cl-bbs/rss:get-all-boards-rss-threads 20))) + (list 200 '(:content-type "application/rss+xml; charset=utf-8") + (list (cl-bbs/rss:generate-rss "all" all-threads env)))))) + +;; RSS Feed (Specific board) +(setf (ningle:route *app* "/:board/rss" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (env (lack.request:request-env ningle:*request*)) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (threads (when (probe-file list-path) (read-sexp-file list-path)))) + (list 200 '(:content-type "application/rss+xml; charset=utf-8") + (list (cl-bbs/rss:generate-rss board threads env)))))) + +;; 10. POST /:board/post (New Thread) +(setf (ningle:route *app* "/:board/post" :method :POST) + (lambda (params) + (block out + (let* ((board (cdr (assoc :board params))) + (env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (epistula (cdr (assoc "epistula" parsed-params :test #'string=))) + (titulus (cdr (assoc "titulus" parsed-params :test #'string=))) + (date (get-date)) + (list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*)) + (threads (read-sexp-file list-path)) + (thread-number (get-next-thread-number threads)) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-number) *base-dir*))) + (cond + ((or (null epistula) + (string= "" (string-trim '(#\Space #\Tab #\Newline #\Return) epistula))) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Post body cannot be empty" cl-bbs/views:*preferences*)))) + ((and titulus (> (length titulus) *headline-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Headline exceeds maximum length of ~D characters" *headline-limit*) + cl-bbs/views:*preferences*)))) + ((and epistula (> (length epistula) *body-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Post body exceeds maximum length of ~D characters" *body-limit*) + cl-bbs/views:*preferences*)))) + (t + (progn + (when (is-board-locked board) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "This board is read-only" + cl-bbs/views:*preferences*))))) + (unless (probe-file (merge-pathnames (format nil "sexp/~a/" board) *base-dir*)) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Only administrators can create new boards" + cl-bbs/views:*preferences*))))) + (ensure-board-dirs board) + (create-thread thread-path titulus date epistula) + (add-thread-to-list list-path thread-number titulus date) + (add-thread-to-index index-path thread-number titulus date epistula) + `(303 (:location ,(format nil "/~a/" board)) ("Redirecting..."))))))))) + +;; 11. GET /:board +(setf (ningle:route *app* "/:board" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*))) + (if (probe-file index-path) + (let ((index-data (read-sexp-file index-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-index board index-data cl-bbs/views:*preferences*)))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-index board nil cl-bbs/views:*preferences*))))))) + +;; 12. GET /:board/:thread_id +(setf (ningle:route *app* "/:board/:thread_id" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (if (probe-file thread-path) + (let ((thread-data (read-sexp-file thread-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-thread board thread-id thread-data nil cl-bbs/views:*preferences*)))) + `(404 (:content-type "text/plain") ("Thread not found")))))) + +;; 13. POST /:board/:thread_id/post (Reply to Thread) +(setf (ningle:route *app* "/:board/:thread_id/post" :method :POST) + (lambda (params) + (block out + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (env (lack.request:request-env ningle:*request*)) + (parsed-params (get-body-params params env)) + (epistula (cdr (assoc "epistula" parsed-params :test #'string=))) + (date (get-date)) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (cond + ((or (null epistula) + (string= "" (string-trim '(#\Space #\Tab #\Newline #\Return) epistula))) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page "Post body cannot be empty" cl-bbs/views:*preferences*)))) + ((and epistula (> (length epistula) *body-limit*)) + `(400 (:content-type "text/html; charset=utf-8") + (,(render-error-page (format nil "Post body exceeds maximum length of ~D characters" *body-limit*) + cl-bbs/views:*preferences*)))) + (t + (progn + (when (is-board-locked board) + (return-from out + `(403 (:content-type "text/html; charset=utf-8") + (,(render-error-page "This board is read-only" cl-bbs/views:*preferences*))))) + (if (probe-file thread-path) + (let* ((thread-data (read-sexp-file thread-path)) + (raw-thread (if (and (consp thread-data) + (consp (car thread-data)) + (consp (caar thread-data))) + (car thread-data) + thread-data)) + (posts-assoc (let ((assoc-res (assoc 'cl-bbs/models:posts raw-thread))) + (if assoc-res + assoc-res + (cadr (if (consp thread-data) + thread-data + (list thread-data)))))) + (posts-list (if (listp (cdr posts-assoc)) + (if (listp (cadr posts-assoc)) + (cadr posts-assoc) + (cdr posts-assoc)) + (cdr posts-assoc))) + (posts (if (listp (car posts-list)) posts-list (list posts-list))) + (new-post `(,(1+ (reduce #'max posts :key #'car :initial-value 0)) + (cl-bbs/models:date . ,date) + (cl-bbs/models:vip . nil) + (cl-bbs/models:content . ,epistula))) + (new-thread-data (update-thread-data thread-data new-post))) + (write-sexp-file thread-path new-thread-data) + (let* ((list-path (merge-pathnames (format nil "sexp/~a/list" board) *base-dir*)) + (threads-list (read-sexp-file list-path)) + (str-id (if (stringp thread-id) (parse-integer thread-id) thread-id)) + (target-thread-assoc (assoc str-id threads-list))) + (when target-thread-assoc + (let* ((rem-threads (remove str-id threads-list :key #'car :test #'equal)) + (raw-new-thread (if (and (consp new-thread-data) + (consp (car new-thread-data)) + (consp (caar new-thread-data))) + (car new-thread-data) + new-thread-data)) + (messages-count (length (cadr (assoc 'cl-bbs/models:posts raw-new-thread)))) + (headline-val (if (stringp (cdr (assoc 'cl-bbs/models:headline + (cdr target-thread-assoc)))) + (cdr (assoc 'cl-bbs/models:headline + (cdr target-thread-assoc))) + (cdr (assoc 'headline (cdr target-thread-assoc))))) + (updated-entry `(,str-id + (cl-bbs/models:headline . ,headline-val) + (cl-bbs/models:date . ,date) + (cl-bbs/models:messages . ,messages-count)))) + (write-sexp-file list-path (cons updated-entry rem-threads)))) + (let* ((index-path (merge-pathnames (format nil "sexp/~a/index" board) *base-dir*)) + (index-list (read-sexp-file index-path)) + (target-index-assoc (assoc str-id index-list))) + (when target-index-assoc + (let* ((rem-index (remove str-id index-list :key #'car :test #'equal)) + (raw-new-thread (if (and (consp new-thread-data) + (consp (car new-thread-data)) + (consp (caar new-thread-data))) + (car new-thread-data) + new-thread-data)) + (headline-val (if (stringp (cdr (assoc 'cl-bbs/models:headline + (cdr target-index-assoc)))) + (cdr (assoc 'cl-bbs/models:headline + (cdr target-index-assoc))) + (cdr (assoc 'headline (cdr target-index-assoc))))) + (actual-posts (get-flat-posts (assoc 'cl-bbs/models:posts raw-new-thread))) + (truncated-ids (if (> (length actual-posts) 6) + (mapcar #'car (butlast (cdr actual-posts) 5)) + nil)) + (updated-entry `(,str-id + (cl-bbs/models:headline . ,headline-val) + (cl-bbs/models:truncated . ,truncated-ids) + (cl-bbs/models:posts + ,(if (> (length actual-posts) 6) + (cons (car actual-posts) (last actual-posts 5)) + actual-posts))))) + (write-sexp-file index-path (cons updated-entry rem-index)))))) + `(303 (:location ,(format nil "/~a/" board)) ("Redirecting..."))) + `(404 (:content-type "text/plain") ("Thread not found")))))))))) + +;; 14. GET /:board/:thread_id/:range (Thread Comments with Range) +(setf (ningle:route *app* "/:board/:thread_id/:range" :method :GET) + (lambda (params) + (let* ((board (cdr (assoc :board params))) + (thread-id (cdr (assoc :thread_id params))) + (range-str (cdr (assoc :range params))) + (thread-path (merge-pathnames (format nil "sexp/~a/~a" board thread-id) *base-dir*))) + (if (probe-file thread-path) + (let ((thread-data (read-sexp-file thread-path))) + `(200 (:content-type "text/html; charset=utf-8") + (,(render-thread board thread-id thread-data range-str cl-bbs/views:*preferences*)))) + `(404 (:content-type "text/plain") ("Thread not found")))))) |
