Give up on using catch and use conditions instead.

This is a major rewrite of how we're catching for trouble during
parsing and the posting of articles.  When fetching articles by
message-id, we don't need to enforce that a group has been chosen.
This commit is contained in:
Circling Skies 2026-08-18 15:33:26 -03:00
parent 05be4aad57
commit e1826ad9eb
3 changed files with 575 additions and 183 deletions

335
loop.lisp
View file

@ -45,6 +45,10 @@
("NEXT" ,#'cmd-next "increments the article pointer") ("NEXT" ,#'cmd-next "increments the article pointer")
("HELP" ,#'cmd-help "displays this menu") ("HELP" ,#'cmd-help "displays this menu")
("LIST" ,#'cmd-list "lists all groups") ("LIST" ,#'cmd-list "lists all groups")
("LIST NEWSGROUPS" ,#'cmd-list "same as LIST")
("LIST ACTIVE" ,#'cmd-list "same as LIST")
("LIST OVERVIEW.FMT" ,#'cmd-list "lists xover fields")
("CAPABILITIES" ,#'cmd-capabilities "displays our capabilities")
("AUTHINFO" ,#'cmd-authinfo "makes me trust you") ("AUTHINFO" ,#'cmd-authinfo "makes me trust you")
("LOGIN" ,#'cmd-login "shorter interface to AUTHINFO") ("LOGIN" ,#'cmd-login "shorter interface to AUTHINFO")
("HEAD" ,#'cmd-head "fetches article headers") ("HEAD" ,#'cmd-head "fetches article headers")
@ -53,6 +57,7 @@
("POST" ,#'cmd-post "posts your article") ("POST" ,#'cmd-post "posts your article")
("ARTICLE" ,#'cmd-article "fetches full articles") ("ARTICLE" ,#'cmd-article "fetches full articles")
("XOVER" ,#'cmd-xover "fetches the overview database of a group") ("XOVER" ,#'cmd-xover "fetches the overview database of a group")
("XHDR" ,#'cmd-xhdr "fetches a specific header from articles")
("CREATE-GROUP" ,#'cmd-create-group ("CREATE-GROUP" ,#'cmd-create-group
"creates a new group so you can discuss your favorite topic") "creates a new group so you can discuss your favorite topic")
("CREATE-ACCOUNT",#'cmd-create-account ("CREATE-ACCOUNT",#'cmd-create-account
@ -411,15 +416,22 @@
(defun hs-lines (lines) (str:split (crlf-string) lines)) (defun hs-lines (lines) (str:split (crlf-string) lines))
(define-condition article-syntax-error (error)
((message :initarg :message :reader article-syntax-error-message))
(:report (lambda (condition stream)
(format stream "~a" (article-syntax-error-message condition)))))
(defun article-syntax-error! (fmt-str &rest args)
(error 'article-syntax-error
:message (apply #'fmt fmt-str args)))
(defun parse-header (header) (defun parse-header (header)
(let* ((h (str:collapse-whitespaces header)) (let* ((h (str:collapse-whitespaces header))
(pos (search ":" h))) (pos (search ":" h)))
(when (null pos) (when (null pos)
(throw 'article-syntax-error (article-syntax-error! (fmt "missing colon in header |~a|" h)))
(values nil (fmt "missing colon in header |~a|" h))))
(when (<= (length h) (+ 2 pos)) (when (<= (length h) (+ 2 pos))
(throw 'article-syntax-error (article-syntax-error! (fmt "empty header ~a" h)))
(values nil (fmt "empty header ~a" h))))
(multiple-value-bind (key val) (multiple-value-bind (key val)
(values (subseq h 0 pos) (subseq h (+ 2 pos))) (values (subseq h 0 pos) (subseq h (+ 2 pos)))
(cons (str:downcase key) val)))) (cons (str:downcase key) val))))
@ -559,6 +571,22 @@
(defun dispatch-line (ln) (defun dispatch-line (ln)
(dispatch (parse-request (make-request :said ln)))) (dispatch (parse-request (make-request :said ln))))
(defun cmd-capabilities (r)
(prepend-response-with
"We are very capable! Please, take note:"
(make-response
:code 101 :multi-line 'yes :request r
:data (str:join (crlf-string) (capabilities-lines)))))
(defun capabilities-lines ()
(append
(list "VERSION 2"
"READER")
(when (auth?) (list "POST"))
(list "AUTHINFO USER"
"LIST ACTIVE NEWSGROUPS OVERVIEW.FMT"
(fmt "IMPLEMENTATION LOOP ~a" *git-hash*))))
(defun cmd-authinfo (r) (defun cmd-authinfo (r)
(let* ((args (mapcar #'str:upcase (request-args r)))) (let* ((args (mapcar #'str:upcase (request-args r))))
(cond (cond
@ -596,30 +624,28 @@
(defun cmd-mode (r) ;; Whatever. (defun cmd-mode (r) ;; Whatever.
(make-response :code 200 :request r :data "Sure thing.")) (make-response :code 200 :request r :data "Sure thing."))
(defun typical-cmd-head-body-article (r fn-name) (defun typical-cmd-head-body-article (r fn-by-number fn-by-mid)
(with-auth (with-auth
(with-group-set (let ((args (request-args r)))
(let ((args (request-args r))) (cond ((null args)
(cond ((null args) (with-group-set
(funcall fn-name r (client-group *client*) (client-article *client*))) (funcall fn-by-number r (client-group *client*) (client-article *client*))))
((= 1 (length args)) ((= 1 (length args))
(let* ((n-or-mid (car args))) (let* ((n-or-mid (car args)))
(cond ((string-integer? n-or-mid) (cond ((string-integer? n-or-mid)
(funcall fn-name r (client-group *client*) n-or-mid)) (with-group-set
(t (multiple-value-bind (group n-str) (lookup-index n-or-mid) (funcall fn-by-number r (client-group *client*) n-or-mid)))
(if (and group n-str) (t (funcall fn-by-mid r n-or-mid)))))
(funcall fn-name r group n-str) (t (bad-input r "No, no: it takes at most two arguments."))))))
(bad-input r (fmt "Unknown article ~a." n-or-mid))))))))
(t (bad-input r "No, no: it takes at most two arguments.")))))))
(defun cmd-head (r) (defun cmd-head (r)
(typical-cmd-head-body-article r #'head-response)) (typical-cmd-head-body-article r #'head-response #'head-response-by-mid))
(defun cmd-body (r) (defun cmd-body (r)
(typical-cmd-head-body-article r #'body-response)) (typical-cmd-head-body-article r #'body-response #'body-response-by-mid))
(defun cmd-article (r) (defun cmd-article (r)
(typical-cmd-head-body-article r #'article-response)) (typical-cmd-head-body-article r #'article-response #'article-response-by-mid))
(defun article-response (r g i) (defun article-response (r g i)
(typical-cmd-response 220 r g i :article)) (typical-cmd-response 220 r g i :article))
@ -630,12 +656,21 @@
(defun body-response (r g i) (defun body-response (r g i)
(typical-cmd-response 222 r g i :body)) (typical-cmd-response 222 r g i :body))
(defun typical-cmd-response (code r g i requested-part) (defun article-response-by-mid (r mid)
(typical-cmd-response-by-mid 220 r mid :article))
(defun head-response-by-mid (r mid)
(typical-cmd-response-by-mid 221 r mid :head))
(defun body-response-by-mid (r mid)
(typical-cmd-response-by-mid 222 r mid :body))
(defun typical-cmd-response-at-path (code r path error-label response-number requested-part
&optional (not-found-code 400))
(handler-case (handler-case
(let* ((path (fmt "groups/~a/~a" g i)) (let* ((headers-raw (read-headers-raw path)))
(headers-raw (read-headers-raw path)))
(if (null headers-raw) (if (null headers-raw)
(make-response :code 400 :request r :data (fmt "article ~a/~a does not exist" g i)) (make-response :code 400 :request r :data (fmt "article ~a does not exist" error-label))
(let* ((a (parse-article headers-raw)) (let* ((a (parse-article headers-raw))
(mid (extract-mid headers-raw)) (mid (extract-mid headers-raw))
(head-length (length headers-raw)) (head-length (length headers-raw))
@ -648,19 +683,35 @@
(crlf) (crlf) (crlf) (crlf)
(make-file-stream-payload :path path (make-file-stream-payload :path path
:start-pos body-start)))))) :start-pos body-start))))))
(prepend-response-with (fmt "~a ~a" i mid) (prepend-response-with (fmt "~a ~a" response-number mid)
(make-response :multi-line 'yes :code code (make-response :multi-line 'yes :code code
:request r :data payload))))) :request r :data payload)))))
(file-error (c)
(declare (ignore c))
(make-response :code not-found-code :request r
:data (fmt "Sorry, no such article ~a." error-label)))
(sb-posix:syscall-error (c) (sb-posix:syscall-error (c)
(let ((msg (fmt "article ~a/~a: ~a" g i c))) (let ((msg (fmt "article ~a: ~a" error-label c)))
(stderr msg) (stderr msg)
(make-response :code 400 :request r :data msg))) (make-response :code 400 :request r :data msg)))
(error (c) (error (c)
(let ((msg (fmt "unexpected condition when reading article ~a/~a: ~a" g i c))) (let ((msg (fmt "sorry ~a: ~a" error-label c)))
(stderr msg) (stderr msg)
(make-response :code 400 :request r :data msg))))) (make-response :code 400 :request r :data msg)))))
(defun typical-cmd-response (code r g i requested-part)
(typical-cmd-response-at-path
code r
(fmt "groups/~a/~a" g i)
(fmt "~a/~a" g i) i requested-part 423))
(defun typical-cmd-response-by-mid (code r mid requested-part)
(typical-cmd-response-at-path
code r
(index-path (index-hash mid))
mid
0 requested-part 400))
(defun cmd-next (r) (defun cmd-next (r)
(with-auth (with-auth
(let ((g (client-group *client*)) (let ((g (client-group *client*))
@ -732,6 +783,74 @@
(defun xover-headers () (defun xover-headers ()
'("subject" "from" "date" "message-id" "references" "line-count" "byte-count")) '("subject" "from" "date" "message-id" "references" "line-count" "byte-count"))
(defun cmd-xhdr (r)
(with-auth
(with-group-set
(let ((args (request-args r)))
(cond
((or (null args) (> (length args) 2))
(make-response :code 502 :request r
:data "bad syntax: usage is XHDR header [range|message-id]"))
(t
(let ((header (string-downcase (car args)))
(range-arg (cadr args)))
(cond
((null range-arg)
(xhdr r header (client-article *client*) (client-article *client*)))
((message-id-syntax? range-arg)
(xhdr-by-message-id r header range-arg))
(t
(multiple-value-bind (fr to) (parse-range range-arg)
(if (not fr)
(make-response :code 502 :request r :data "bad syntax")
(xhdr r header fr to))))))))))))
(defun message-id-syntax? (s)
(and (> (length s) 0) (char= (char s 0) #\<)))
(defun parse-range (s)
"Parses S of the form N, N-, or N-M. Returns (values from to), or
(values nil nil) when S doesn't conform."
(multiple-value-bind (matched v) (cl-ppcre:scan-to-strings "([0-9]+)([-]?)([0-9]*)" s)
(if (not matched)
(values nil nil)
(let ((fr (parse-integer (aref v 0)))
(hifen (aref v 1))
(to (ignore-errors (parse-integer (aref v 2)))))
(values fr (if (string= hifen "-") to fr))))))
(defun xhdr (r header from to)
(assert (client-group *client*))
(let* ((g (client-group *client*))
(ls (get-articles g from to)))
(cond ((= 0 (length ls))
(make-response :code 420 :request r :data "no articles in the range"))
(t
(prepend-response-with
(fmt "~a header follows" header)
(make-response
:code 221 :request r :multi-line 'yes
:data (str:join
(crlf-string)
(loop for i in ls
collect (xhdr-format-line i (get-header header (fetch-headers g i)))))))))))
(defun xhdr-by-message-id (r header mid)
(multiple-value-bind (g n) (lookup-by-message-id mid)
(if (not (and g n))
(make-response :code 430 :request r :data "no such article")
(handler-case
(prepend-response-with
(fmt "~a header follows" header)
(make-response :code 221 :request r :multi-line 'yes
:data (xhdr-format-line 0 (get-header header (fetch-headers g n)))))
(error (c)
(declare (ignore c))
(make-response :code 430 :request r :data "no such article"))))))
(defun xhdr-format-line (i value)
(fmt "~a ~a" i value))
(defun cmd-group (r) (defun cmd-group (r)
(with-auth (with-auth
(with-n-args 1 r (with-n-args 1 r
@ -752,12 +871,39 @@
(defstruct group name high low) (defstruct group name high low)
(defun cmd-list (r) (defun cmd-list (r)
(let* ((args (request-args r))
(keyw (and (= 1 (length args)) (car args))))
(cond
((or (null args) (member keyw '("NEWSGROUPS" "ACTIVE") :test #'string-equal))
(list-groups-response r))
((and keyw (string-equal keyw "OVERVIEW.FMT"))
(list-overview-fmt-response r))
(t (make-response :code 503 :request r
:data (fmt "Unsupported LIST variant ``~a''." (str:join " " args)))))))
(defun list-groups-response (r)
(prepend-response-with (prepend-response-with
"Get in the loop! Lots to choose from." "Get in the loop! Lots to choose from."
(make-response :code 215 :multi-line 'yes (make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (build-groups-lines (build-groups-structs))) :data (str:join (crlf-string) (build-groups-lines (build-groups-structs)))
:request r))) :request r)))
(defun list-overview-fmt-response (r)
(prepend-response-with
"What to expect in XOVER? These."
(make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (overview-fmt-lines))
:request r)))
(defun overview-fmt-lines ()
'("From:"
"Date:"
"Message-ID:"
"References:"
"Bytes:"
"Lines:"
"Xref:full"))
(defun build-groups-lines (ls) (defun build-groups-lines (ls)
(reverse (reverse
(mapcar (mapcar
@ -954,34 +1100,31 @@
:data :data
(nntp-date-command))) (nntp-date-command)))
(defun conforms? (bs)
(catch 'article-syntax-error
(conforms-headers?
(article-head (parse-article bs)))))
(defun conforms-headers? (headers-str) (defun conforms-headers? (headers-str)
"HEADERS-STR is the raw header block (without the body). "HEADERS-STR is the raw header block (without the body).
Returns (values T NIL) on success, or (values NIL error-string)." Returns (values T NIL) on success, or (values NIL error-string)."
(catch 'article-syntax-error (let* ((headers (parse-headers headers-str))
(let ((headers (parse-headers headers-str))) (missing (loop for h in (headers-required-from-clients)
(let ((result (dolist (h (headers-required-from-clients)) unless (lookup h headers)
(when (not (lookup h headers)) collect h)))
(return (fmt "missing the /~a/ header" h)))))) (cond
(cond (missing (article-syntax-error!
((stringp result) (values nil result)) (fmt "missing the ~a ~a"
(t (str:join ", " (mapcar (lambda (h) (fmt "/~a/" h)) missing))
(let ((ngs (newsgroups-header->list (get-header "newsgroups" headers))) (word-plural (length missing) "header"))))
(ngs-dont-exist nil)) (t
(dolist (g ngs) (let ((ngs (newsgroups-header->list (get-header "newsgroups" headers)))
(when (or (not (group-name-conforms? g)) (ngs-dont-exist nil))
(not (group? g))) (dolist (g ngs)
(push g ngs-dont-exist))) (when (or (not (group-name-conforms? g))
(if (not (zerop (length ngs-dont-exist))) (not (group? g)))
(values nil (fmt "the ~a ~a just ~a exist" (push g ngs-dont-exist)))
(word-plural (length ngs-dont-exist) "newsgroup") (if (not (zerop (length ngs-dont-exist)))
(str:join ", " (sort ngs-dont-exist #'string<)) (article-syntax-error! (fmt "the ~a ~a just ~a exist"
(word-plural (length ngs-dont-exist) "doesn't"))) (word-plural (length ngs-dont-exist) "newsgroup")
(values t nil))))))))) (str:join ", " (sort ngs-dont-exist #'string<))
(word-plural (length ngs-dont-exist) "doesn't")))
(values t nil)))))))
(defun text/plain? (header-s) (defun text/plain? (header-s)
;; I say T when S begins with "text/plain" or when S is "". ;; I say T when S begins with "text/plain" or when S is "".
@ -1009,44 +1152,38 @@
(defun cmd-post (r) (defun cmd-post (r)
(with-auth (with-auth
(let ((suggested (suggest-message-id))) (send-response!
(send-response! (make-response :code 340
(make-response :data (fmt "Okay, go ahead. Suggested message-id ~a." (suggest-message-id))))
:code 340 (let ((tmp (fmt "tmp/post-~a.tmp" (random-string 12))))
:data (fmt "Okay, go ahead. Suggested message-id ~a." suggested))) (unwind-protect
(let ((tmp (fmt "tmp/post-~a.tmp" (random-string 12)))) (handler-case
(unwind-protect
(progn (progn
(nntp-read-article-to-disk tmp) (nntp-read-article-to-disk tmp)
(stderr "finished writing article to disk in tmp/~a~%" tmp) (stderr "finished writing article to disk in ~a~%" tmp)
(let* ((headers-raw (read-headers-raw tmp)) (let* ((headers-raw (read-headers-raw tmp))
;; headers-raw includes the terminating CRLF CRLF; strip it
;; so parse-headers does not see an empty final line.
(headers-str (bytes->string headers-raw)) (headers-str (bytes->string headers-raw))
(hds (parse-headers headers-str)) (hds (parse-headers headers-str))
(mid (get-header "message-id" hds)) (mid (get-header "message-id" hds))
(ngs (newsgroups-header->list (get-header "newsgroups" hds)))) (ngs (newsgroups-header->list (get-header "newsgroups" hds))))
(stderr "finished reading the headers of the tmp file~%") (stderr "finished reading the headers of the tmp file~%")
(stderr "headers string: ~a~%" headers-str) (stderr "headers string: ~a~%" headers-str)
(multiple-value-bind (okay? error) (conforms-headers? headers-str) (conforms-headers? headers-str) ;; raises a condition if it doesn't go well
(cond (let* ((hash (store-article-in-index! tmp mid)))
((not okay?) (stderr "stored ~a from ~a~%" hash mid)
(make-response (dolist (ng ngs)
(let ((new-id (link-article-into-group-insist! hash ng (get-next-article-id ng))))
(update-group-meta-incremental! ng new-id)))
(stderr "linking finished~%")
(update-last-post-date! (client-username *client*))
(make-response :code 240 :request r :data "Thank you! Your article has been saved."))))
(article-syntax-error (c)
(make-response
:code 400 :request r :code 400 :request r
:data (fmt "Sorry. Your article doesn't conform: ~a." error))) :data (fmt "Sorry. Your article doesn't conform: ~a." c)))
(t (already-seen-article (c)
(handler-case (make-response :code 441 :request r :data (fmt "~a" c))))
(let* ((hash (store-article-in-index! tmp mid))) (ignore-errors (delete-file tmp))))))
(stderr "stored ~a from ~a~%" hash mid)
(dolist (ng ngs)
(let ((new-id (link-article-into-group-insist! hash ng (get-next-article-id ng))))
(update-group-meta-incremental! ng new-id)))
(stderr "Procedure post has finished.~%")
(update-last-post-date! (client-username *client*))
(make-response :code 240 :request r :data "Thank you! Your article has been saved."))
(already-seen-article (c)
(make-response :code 441 :request r :data (fmt "~a" c)))))))))
(ignore-errors (delete-file tmp)))))))
(defun newsgroups-header->list (s) (defun newsgroups-header->list (s)
(mapcar #'(lambda (n) (str:trim (string-downcase n))) (str:split "," s))) (mapcar #'(lambda (n) (str:trim (string-downcase n))) (str:split "," s)))
@ -1059,40 +1196,6 @@
(rename-file (merge-pathnames old (uiop:getcwd)) (rename-file (merge-pathnames old (uiop:getcwd))
(merge-pathnames (make-pathname :name new :type :unspecific) (uiop:getcwd)))) (merge-pathnames (make-pathname :name new :type :unspecific) (uiop:getcwd))))
(defun save-article-try-old (g name-try tmp-path)
(stderr "going to try to save ~a with name ~a/~a~%" tmp-path g name-try)
(let ((name (fmt "~a" name-try))
(tmp (fmt "~a.tmp" name-try)))
(with-open-file
(s name
:direction :output
:if-exists nil ; atomic O_EXCL allocation
:if-does-not-exist :create)
(when (null s)
(stderr "warning: save-article-try: ~a exists~%" name)
(return-from save-article-try-old 'name-exists)))
(stderr "going to rename in save-article-try~%")
;; We've been called to save tmp-path to its permanent name. We're
;; merely going to rename it there. So the rename call is now
;; tmp/post-tmpfile.tmp to index/bb/bb/hash. To get the hash we
;; need need the message-id.
(stderr "current-directory: ~a~%" (uiop:getcwd))
(stderr "default-pathname: ~a~%" *default-pathname-defaults*)
;(stderr "rename-no-extension ~a ~a~%" (fmt "~a" tmp-path) (fmt "~a" name))
;; The call to rename-no-extension must be at the root, so I can't
;; localize my life using in-group or in-groups here. Leaving it
;; like that.
;;(rename-no-extension (fmt "~a" tmp-path) (fmt "groups/~a/~a" g name))
;; We don't call rename here any longer. The article is now
;; safely written to tmp and it's time to store it at its
;; permanent path, which is computed by store-article-in-index!
;; So go ahead and do that. But we need the MID now! Delegated
;; it to the index itself. (Notice we don't need G here anymore.)
(store-article-in-index! tmp-path)))
(defun save-article-insist-old (g name src-path) (defun save-article-insist-old (g name src-path)
(loop for name from name do (loop for name from name do
(when (not (eql 'name-exists (save-article-try-old g name src-path))) (when (not (eql 'name-exists (save-article-try-old g name src-path)))

421
loop.nw
View file

@ -477,6 +477,10 @@ commands, which is essentially what the user sees when asks for
("NEXT" ,#'cmd-next "increments the article pointer") ("NEXT" ,#'cmd-next "increments the article pointer")
("HELP" ,#'cmd-help "displays this menu") ("HELP" ,#'cmd-help "displays this menu")
("LIST" ,#'cmd-list "lists all groups") ("LIST" ,#'cmd-list "lists all groups")
("LIST NEWSGROUPS" ,#'cmd-list "same as LIST")
("LIST ACTIVE" ,#'cmd-list "same as LIST")
("LIST OVERVIEW.FMT" ,#'cmd-list "lists xover fields")
("CAPABILITIES" ,#'cmd-capabilities "displays our capabilities")
("AUTHINFO" ,#'cmd-authinfo "makes me trust you") ("AUTHINFO" ,#'cmd-authinfo "makes me trust you")
("LOGIN" ,#'cmd-login "shorter interface to AUTHINFO") ("LOGIN" ,#'cmd-login "shorter interface to AUTHINFO")
("HEAD" ,#'cmd-head "fetches article headers") ("HEAD" ,#'cmd-head "fetches article headers")
@ -485,6 +489,7 @@ commands, which is essentially what the user sees when asks for
("POST" ,#'cmd-post "posts your article") ("POST" ,#'cmd-post "posts your article")
("ARTICLE" ,#'cmd-article "fetches full articles") ("ARTICLE" ,#'cmd-article "fetches full articles")
("XOVER" ,#'cmd-xover "fetches the overview database of a group") ("XOVER" ,#'cmd-xover "fetches the overview database of a group")
("XHDR" ,#'cmd-xhdr "fetches a specific header from articles")
("CREATE-GROUP" ,#'cmd-create-group ("CREATE-GROUP" ,#'cmd-create-group
"creates a new group so you can discuss your favorite topic") "creates a new group so you can discuss your favorite topic")
("CREATE-ACCOUNT",#'cmd-create-account ("CREATE-ACCOUNT",#'cmd-create-account
@ -1057,15 +1062,22 @@ foreign articles for whatever reason.
(defun hs-lines (lines) (str:split (crlf-string) lines)) (defun hs-lines (lines) (str:split (crlf-string) lines))
(define-condition article-syntax-error (error)
((message :initarg :message :reader article-syntax-error-message))
(:report (lambda (condition stream)
(format stream "~a" (article-syntax-error-message condition)))))
(defun article-syntax-error! (fmt-str &rest args)
(error 'article-syntax-error
:message (apply #'fmt fmt-str args)))
(defun parse-header (header) (defun parse-header (header)
(let* ((h (str:collapse-whitespaces header)) (let* ((h (str:collapse-whitespaces header))
(pos (search ":" h))) (pos (search ":" h)))
(when (null pos) (when (null pos)
(throw 'article-syntax-error (article-syntax-error! (fmt "missing colon in header |~a|" h)))
(values nil (fmt "missing colon in header |~a|" h))))
(when (<= (length h) (+ 2 pos)) (when (<= (length h) (+ 2 pos))
(throw 'article-syntax-error (article-syntax-error! (fmt "empty header ~a" h)))
(values nil (fmt "empty header ~a" h))))
(multiple-value-bind (key val) (multiple-value-bind (key val)
(values (subseq h 0 pos) (subseq h (+ 2 pos))) (values (subseq h 0 pos) (subseq h (+ 2 pos)))
(cons (str:downcase key) val)))) (cons (str:downcase key) val))))
@ -1482,12 +1494,39 @@ the future for a moment.
(defstruct group name high low) (defstruct group name high low)
(defun cmd-list (r) (defun cmd-list (r)
(let* ((args (request-args r))
(keyw (and (= 1 (length args)) (car args))))
(cond
((or (null args) (member keyw '("NEWSGROUPS" "ACTIVE") :test #'string-equal))
(list-groups-response r))
((and keyw (string-equal keyw "OVERVIEW.FMT"))
(list-overview-fmt-response r))
(t (make-response :code 503 :request r
:data (fmt "Unsupported LIST variant ``~a''." (str:join " " args)))))))
(defun list-groups-response (r)
(prepend-response-with (prepend-response-with
"Get in the loop! Lots to choose from." "Get in the loop! Lots to choose from."
(make-response :code 215 :multi-line 'yes (make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (build-groups-lines (build-groups-structs))) :data (str:join (crlf-string) (build-groups-lines (build-groups-structs)))
:request r))) :request r)))
(defun list-overview-fmt-response (r)
(prepend-response-with
"What to expect in XOVER? These."
(make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (overview-fmt-lines))
:request r)))
(defun overview-fmt-lines ()
'("From:"
"Date:"
"Message-ID:"
"References:"
"Bytes:"
"Lines:"
"Xref:full"))
(defun build-groups-lines (ls) (defun build-groups-lines (ls)
(reverse (reverse
(mapcar (mapcar
@ -1604,6 +1643,20 @@ the future for a moment.
(defun last-char (s) (char s (1- (length s)))) (defun last-char (s) (char s (1- (length s))))
@ @
\begin{crossedblock}
<<Dead Command list.lisp>>=
(defstruct group name high low)
(defun cmd-list (r)
(prepend-response-with
"Get in the loop! Lots to choose from."
(make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (build-groups-lines (build-groups-structs)))
:request r)))
@
\end{crossedblock}
In [[basename]], what we want to do is---if the path ends with a In [[basename]], what we want to do is---if the path ends with a
slash, we invoke [[pathname]]. Otherwise, we invoke {\tt slash, we invoke [[pathname]]. Otherwise, we invoke {\tt
file-namestring}. file-namestring}.
@ -1672,6 +1725,37 @@ To list directories and files, I have been using
collect (pathname-utils:force-directory path))))) collect (pathname-utils:force-directory path)))))
@ %def loop-list-directories loop-list-files @ %def loop-list-directories loop-list-files
\subsection{{\tt LIST OVERVIEW.FMT}}
This weirdly-named command is let news readers know what to expect in
the answer to \verb|XOVER| commands.
\begin{verbatim}
215 What to expect in XOVER? These.
Subject:
From:
Date:
Message-ID:
References:
Bytes:
Lines:
Xref:full
.
\end{verbatim}
Do we have support for commands with space? I doubt it. We don't.
Our idea is that everything-LIST is to be handled by [[cmd-list]].
This isn't such a bad idea, but it will make [[cmd-list]] handle
<<Command list overview.fmt.lisp>>=
(defun cmd-list-overview-fmt (r)
(prepend-response-with
"Get in the loop! Lots to choose from."
(make-response :code 215 :multi-line 'yes
:data (str:join (crlf-string) (build-groups-lines (build-groups-structs)))
:request r)))
@
\subsection{{\tt GROUP}}\label{sec:group} \subsection{{\tt GROUP}}\label{sec:group}
We just need to verify if the group exists and modify [[*client*]]. We just need to verify if the group exists and modify [[*client*]].
@ -1709,8 +1793,101 @@ specifies the article number. In case (3), the NNTP client specifies
the exact {\tt message-id}, which forces us to query the index---see the exact {\tt message-id}, which forces us to query the index---see
Section~\ref{sec:index} for the implementation of the index. Section~\ref{sec:index} for the implementation of the index.
We're using the nonexistent procedure [[lookup-index]] below. We must
update that now. The argument [[n-or-mid]] is a message-id string for
sure. We can immediately return that article by reading /index/
<<Commands head, body, article.lisp>>= <<Commands head, body, article.lisp>>=
(defun typical-cmd-head-body-article (r fn-name) (defun typical-cmd-head-body-article (r fn-by-number fn-by-mid)
(with-auth
(let ((args (request-args r)))
(cond ((null args)
(with-group-set
(funcall fn-by-number r (client-group *client*) (client-article *client*))))
((= 1 (length args))
(let* ((n-or-mid (car args)))
(cond ((string-integer? n-or-mid)
(with-group-set
(funcall fn-by-number r (client-group *client*) n-or-mid)))
(t (funcall fn-by-mid r n-or-mid)))))
(t (bad-input r "No, no: it takes at most two arguments."))))))
(defun cmd-head (r)
(typical-cmd-head-body-article r #'head-response #'head-response-by-mid))
(defun cmd-body (r)
(typical-cmd-head-body-article r #'body-response #'body-response-by-mid))
(defun cmd-article (r)
(typical-cmd-head-body-article r #'article-response #'article-response-by-mid))
(defun article-response (r g i)
(typical-cmd-response 220 r g i :article))
(defun head-response (r g i)
(typical-cmd-response 221 r g i :head))
(defun body-response (r g i)
(typical-cmd-response 222 r g i :body))
(defun article-response-by-mid (r mid)
(typical-cmd-response-by-mid 220 r mid :article))
(defun head-response-by-mid (r mid)
(typical-cmd-response-by-mid 221 r mid :head))
(defun body-response-by-mid (r mid)
(typical-cmd-response-by-mid 222 r mid :body))
(defun typical-cmd-response-at-path (code r path error-label response-number requested-part
&optional (not-found-code 400))
(handler-case
(let* ((headers-raw (read-headers-raw path)))
(if (null headers-raw)
(make-response :code 400 :request r :data (fmt "article ~a does not exist" error-label))
(let* ((a (parse-article headers-raw))
(mid (extract-mid headers-raw))
(head-length (length headers-raw))
(body-start (+ head-length 4)) ;; 4 bytes due to \r\n\r\n
(payload
(ecase requested-part
(:head (article-head a))
(:body (make-file-stream-payload :path path :start-pos body-start))
(:article (list (article-head a)
(crlf) (crlf)
(make-file-stream-payload :path path
:start-pos body-start))))))
(prepend-response-with (fmt "~a ~a" response-number mid)
(make-response :multi-line 'yes :code code
:request r :data payload)))))
(file-error (c)
(declare (ignore c))
(make-response :code not-found-code :request r
:data (fmt "Sorry, no such article ~a." error-label)))
(sb-posix:syscall-error (c)
(let ((msg (fmt "article ~a: ~a" error-label c)))
(stderr msg)
(make-response :code 400 :request r :data msg)))
(error (c)
(let ((msg (fmt "sorry ~a: ~a" error-label c)))
(stderr msg)
(make-response :code 400 :request r :data msg)))))
(defun typical-cmd-response (code r g i requested-part)
(typical-cmd-response-at-path
code r
(fmt "groups/~a/~a" g i)
(fmt "~a/~a" g i) i requested-part 423))
(defun typical-cmd-response-by-mid (code r mid requested-part)
(typical-cmd-response-at-path
code r
(index-path (index-hash mid))
mid
0 requested-part 400))
@
\begin{crossedblock}
<<Dead.lisp>>=
;; dead @ d17.M08.y2026.h16-0300.m36.s56
;; --8<-------------------------------------------------------->8---
;; old stuff below
;; --8<-------------------------------------------------------->8---
(defun typical-cmd-head-body-article-old (r fn-name)
(with-auth (with-auth
(with-group-set (with-group-set
(let ((args (request-args r))) (let ((args (request-args r)))
@ -1726,20 +1903,21 @@ Section~\ref{sec:index} for the implementation of the index.
(bad-input r (fmt "Unknown article ~a." n-or-mid)))))))) (bad-input r (fmt "Unknown article ~a." n-or-mid))))))))
(t (bad-input r "No, no: it takes at most two arguments."))))))) (t (bad-input r "No, no: it takes at most two arguments.")))))))
(defun cmd-head (r) (defun cmd-head-old (r)
(typical-cmd-head-body-article r #'head-response)) (typical-cmd-head-body-article r #'head-response))
(defun cmd-body (r) (defun cmd-body-old (r)
(typical-cmd-head-body-article r #'body-response)) (typical-cmd-head-body-article r #'body-response))
(defun cmd-article (r) (defun cmd-article-old (r)
(typical-cmd-head-body-article r #'article-response)) (typical-cmd-head-body-article r #'article-response))
(defun article-response (r g i) (defun article-response-old (r g i)
(typical-cmd-response 220 r g i :article)) (typical-cmd-response 220 r g i :article))
(defun head-response (r g i) (defun head-response-old (r g i)
(typical-cmd-response 221 r g i :head)) (typical-cmd-response 221 r g i :head))
(defun body-response (r g i) (defun body-response-old (r g i)
(typical-cmd-response 222 r g i :body)) (typical-cmd-response 222 r g i :body))
@ @
\end{crossedblock}
\begin{crossedblock} \begin{crossedblock}
In processing such commands, we need to verify the existence of files In processing such commands, we need to verify the existence of files
@ -1762,7 +1940,8 @@ want to do is to log it. What's important is that we seem to have
enough code to treat any future case we might want to add and we're enough code to treat any future case we might want to add and we're
not ignoring any errors at all. not ignoring any errors at all.
<<Commands head, body, article.lisp>>= \begin{crossedblock}
<<Dead Commands head, body, article.lisp>>=
(defun typical-cmd-response (code r g i requested-part) (defun typical-cmd-response (code r g i requested-part)
(handler-case (handler-case
(let* ((path (fmt "groups/~a/~a" g i)) (let* ((path (fmt "groups/~a/~a" g i))
@ -1794,6 +1973,7 @@ not ignoring any errors at all.
(stderr msg) (stderr msg)
(make-response :code 400 :request r :data msg))))) (make-response :code 400 :request r :data msg)))))
@ %def typical-cmd-response @ %def typical-cmd-response
\end{crossedblock}
The command \verb|NEXT| has a slight different semantics. The command \verb|NEXT| has a slight different semantics.
@ -1878,6 +2058,95 @@ to the last one.
'("subject" "from" "date" "message-id" "references" "line-count" "byte-count")) '("subject" "from" "date" "message-id" "references" "line-count" "byte-count"))
@ @
\subsection{{\tt XHDR}}
The {\tt XHDR} command is defined by RFC 2980 section 2.6. The
purpose of this command is to let clients quickly grab specific
information they're looking for. Suppose a client is trying to build
the thread of a certain message. It wants to grab all articles whose
message-id is listed by the \verb|references| header of the article.
Then it says {\tt XHDR references <some.message.id>}. It gets an
answer such as
%
\begin{verbatim}
xhdr references <some.message.id>
221 Header information for references follows (from the article)
<another.message.id> <yet.another.message.id> <a.third.one>
.
\end{verbatim}
%
The client can now issue a {\tt HEAD} for each of these ones.
<<Command xhdr.lisp>>=
(defun cmd-xhdr (r)
(with-auth
(with-group-set
(let ((args (request-args r)))
(cond
((or (null args) (> (length args) 2))
(make-response :code 502 :request r
:data "bad syntax: usage is XHDR header [range|message-id]"))
(t
(let ((header (string-downcase (car args)))
(range-arg (cadr args)))
(cond
((null range-arg)
(xhdr r header (client-article *client*) (client-article *client*)))
((message-id-syntax? range-arg)
(xhdr-by-message-id r header range-arg))
(t
(multiple-value-bind (fr to) (parse-range range-arg)
(if (not fr)
(make-response :code 502 :request r :data "bad syntax")
(xhdr r header fr to))))))))))))
(defun message-id-syntax? (s)
(and (> (length s) 0) (char= (char s 0) #\<)))
(defun parse-range (s)
"Parses S of the form N, N-, or N-M. Returns (values from to), or
(values nil nil) when S doesn't conform."
(multiple-value-bind (matched v) (cl-ppcre:scan-to-strings "([0-9]+)([-]?)([0-9]*)" s)
(if (not matched)
(values nil nil)
(let ((fr (parse-integer (aref v 0)))
(hifen (aref v 1))
(to (ignore-errors (parse-integer (aref v 2)))))
(values fr (if (string= hifen "-") to fr))))))
(defun xhdr (r header from to)
(assert (client-group *client*))
(let* ((g (client-group *client*))
(ls (get-articles g from to)))
(cond ((= 0 (length ls))
(make-response :code 420 :request r :data "no articles in the range"))
(t
(prepend-response-with
(fmt "~a header follows" header)
(make-response
:code 221 :request r :multi-line 'yes
:data (str:join
(crlf-string)
(loop for i in ls
collect (xhdr-format-line i (get-header header (fetch-headers g i)))))))))))
(defun xhdr-by-message-id (r header mid)
(multiple-value-bind (g n) (lookup-by-message-id mid)
(if (not (and g n))
(make-response :code 430 :request r :data "no such article")
(handler-case
(prepend-response-with
(fmt "~a header follows" header)
(make-response :code 221 :request r :multi-line 'yes
:data (xhdr-format-line 0 (get-header header (fetch-headers g n)))))
(error (c)
(declare (ignore c))
(make-response :code 430 :request r :data "no such article"))))))
(defun xhdr-format-line (i value)
(fmt "~a ~a" i value))
@ %def cmd-xhdr
\subsection{{\tt MODE READER}}\label{sec:mode-reader} \subsection{{\tt MODE READER}}\label{sec:mode-reader}
So, we're always in reader mode, so we just ignore this command. So, we're always in reader mode, so we just ignore this command.
@ -1978,44 +2247,38 @@ of trying to stop them.
<<Command post.lisp>>= <<Command post.lisp>>=
(defun cmd-post (r) (defun cmd-post (r)
(with-auth (with-auth
(let ((suggested (suggest-message-id))) (send-response!
(send-response! (make-response :code 340
(make-response :data (fmt "Okay, go ahead. Suggested message-id ~a." (suggest-message-id))))
:code 340 (let ((tmp (fmt "tmp/post-~a.tmp" (random-string 12))))
:data (fmt "Okay, go ahead. Suggested message-id ~a." suggested))) (unwind-protect
(let ((tmp (fmt "tmp/post-~a.tmp" (random-string 12)))) (handler-case
(unwind-protect
(progn (progn
(nntp-read-article-to-disk tmp) (nntp-read-article-to-disk tmp)
(stderr "finished writing article to disk in tmp/~a~%" tmp) (stderr "finished writing article to disk in ~a~%" tmp)
(let* ((headers-raw (read-headers-raw tmp)) (let* ((headers-raw (read-headers-raw tmp))
;; headers-raw includes the terminating CRLF CRLF; strip it
;; so parse-headers does not see an empty final line.
(headers-str (bytes->string headers-raw)) (headers-str (bytes->string headers-raw))
(hds (parse-headers headers-str)) (hds (parse-headers headers-str))
(mid (get-header "message-id" hds)) (mid (get-header "message-id" hds))
(ngs (newsgroups-header->list (get-header "newsgroups" hds)))) (ngs (newsgroups-header->list (get-header "newsgroups" hds))))
(stderr "finished reading the headers of the tmp file~%") (stderr "finished reading the headers of the tmp file~%")
(stderr "headers string: ~a~%" headers-str) (stderr "headers string: ~a~%" headers-str)
(multiple-value-bind (okay? error) (conforms-headers? headers-str) (conforms-headers? headers-str) ;; raises a condition if it doesn't go well
(cond (let* ((hash (store-article-in-index! tmp mid)))
((not okay?) (stderr "stored ~a from ~a~%" hash mid)
(make-response (dolist (ng ngs)
(let ((new-id (link-article-into-group-insist! hash ng (get-next-article-id ng))))
(update-group-meta-incremental! ng new-id)))
(stderr "linking finished~%")
(update-last-post-date! (client-username *client*))
(make-response :code 240 :request r :data "Thank you! Your article has been saved."))))
(article-syntax-error (c)
(make-response
:code 400 :request r :code 400 :request r
:data (fmt "Sorry. Your article doesn't conform: ~a." error))) :data (fmt "Sorry. Your article doesn't conform: ~a." c)))
(t (already-seen-article (c)
(handler-case (make-response :code 441 :request r :data (fmt "~a" c))))
(let* ((hash (store-article-in-index! tmp mid))) (ignore-errors (delete-file tmp))))))
(stderr "stored ~a from ~a~%" hash mid)
(dolist (ng ngs)
(let ((new-id (link-article-into-group-insist! hash ng (get-next-article-id ng))))
(update-group-meta-incremental! ng new-id)))
(stderr "Procedure post has finished.~%")
(update-last-post-date! (client-username *client*))
(make-response :code 240 :request r :data "Thank you! Your article has been saved."))
(already-seen-article (c)
(make-response :code 441 :request r :data (fmt "~a" c)))))))))
(ignore-errors (delete-file tmp)))))))
@ @
<<Command post.lisp>>= <<Command post.lisp>>=
@ -2046,7 +2309,7 @@ file next to it and atomically rename it into place.
@ @
\begin{crossedblock} \begin{crossedblock}
<<Command post.lisp>>= <<Dead Command post.lisp>>=
(defun save-article-try-old (g name-try tmp-path) (defun save-article-try-old (g name-try tmp-path)
(stderr "going to try to save ~a with name ~a/~a~%" tmp-path g name-try) (stderr "going to try to save ~a with name ~a/~a~%" tmp-path g name-try)
(let ((name (fmt "~a" name-try)) (let ((name (fmt "~a" name-try))
@ -2343,39 +2606,43 @@ for any reason); if we find any such group, then we refuse posting and
return a 400 code with a message describing which group names failed. return a 400 code with a message describing which group names failed.
Otherwise the article is susceptible to be saved. Otherwise the article is susceptible to be saved.
\begin{crossedblock}
So the message here is that if an article is vetted by [[conforms?]], So the message here is that if an article is vetted by [[conforms?]],
then it can be saved. Any problem with the article must be reported then it can be saved. Any problem with the article must be reported
by [[conforms?]]. by [[conforms?]].
<<Does an article conform?.lisp>>= <<Dead Does and article conform?.lisp>>=
(defun conforms? (bs) (defun conforms?-old (bs)
(catch 'article-syntax-error (conforms-headers? (article-head (parse-article bs))))
(conforms-headers? @
(article-head (parse-article bs))))) \end{crossedblock}
<<Does an article conform?.lisp>>=
(defun conforms-headers? (headers-str) (defun conforms-headers? (headers-str)
"HEADERS-STR is the raw header block (without the body). "HEADERS-STR is the raw header block (without the body).
Returns (values T NIL) on success, or (values NIL error-string)." Returns (values T NIL) on success, or (values NIL error-string)."
(catch 'article-syntax-error (let* ((headers (parse-headers headers-str))
(let ((headers (parse-headers headers-str))) (missing (loop for h in (headers-required-from-clients)
(let ((result (dolist (h (headers-required-from-clients)) unless (lookup h headers)
(when (not (lookup h headers)) collect h)))
(return (fmt "missing the /~a/ header" h)))))) (cond
(cond (missing (article-syntax-error!
((stringp result) (values nil result)) (fmt "missing the ~a ~a"
(t (str:join ", " (mapcar (lambda (h) (fmt "/~a/" h)) missing))
(let ((ngs (newsgroups-header->list (get-header "newsgroups" headers))) (word-plural (length missing) "header"))))
(ngs-dont-exist nil)) (t
(dolist (g ngs) (let ((ngs (newsgroups-header->list (get-header "newsgroups" headers)))
(when (or (not (group-name-conforms? g)) (ngs-dont-exist nil))
(not (group? g))) (dolist (g ngs)
(push g ngs-dont-exist))) (when (or (not (group-name-conforms? g))
(if (not (zerop (length ngs-dont-exist))) (not (group? g)))
(values nil (fmt "the ~a ~a just ~a exist" (push g ngs-dont-exist)))
(word-plural (length ngs-dont-exist) "newsgroup") (if (not (zerop (length ngs-dont-exist)))
(str:join ", " (sort ngs-dont-exist #'string<)) (article-syntax-error! (fmt "the ~a ~a just ~a exist"
(word-plural (length ngs-dont-exist) "doesn't"))) (word-plural (length ngs-dont-exist) "newsgroup")
(values t nil))))))))) (str:join ", " (sort ngs-dont-exist #'string<))
(word-plural (length ngs-dont-exist) "doesn't")))
(values t nil)))))))
(defun text/plain? (header-s) (defun text/plain? (header-s)
;; I say T when S begins with "text/plain" or when S is "". ;; I say T when S begins with "text/plain" or when S is "".
@ -2464,6 +2731,26 @@ they're skilled enough.
(defparameter *enable-nntp-repl* t) (defparameter *enable-nntp-repl* t)
@ @
\subsection{CAPABILITIES}
<<Command capabilities.lisp>>=
(defun cmd-capabilities (r)
(prepend-response-with
"We are very capable! Please, take note:"
(make-response
:code 101 :multi-line 'yes :request r
:data (str:join (crlf-string) (capabilities-lines)))))
(defun capabilities-lines ()
(append
(list "VERSION 2"
"READER")
(when (auth?) (list "POST"))
(list "AUTHINFO USER"
"LIST ACTIVE NEWSGROUPS OVERVIEW.FMT"
(fmt "IMPLEMENTATION LOOP ~a" *git-hash*))))
@
\section{Publication of news} \section{Publication of news}
If you're interested in being notified about what's going on in the If you're interested in being notified about what's going on in the
@ -3143,10 +3430,12 @@ something to think about.
<<How to stream off articles.lisp>> <<How to stream off articles.lisp>>
<<How to index articles.lisp>> <<How to index articles.lisp>>
<<Dispatching of commands.lisp>> <<Dispatching of commands.lisp>>
<<Command capabilities.lisp>>
<<Command authinfo.lisp>> <<Command authinfo.lisp>>
<<Command mode reader.lisp>> <<Command mode reader.lisp>>
<<Commands head, body, article.lisp>> <<Commands head, body, article.lisp>>
<<Command xover.lisp>> <<Command xover.lisp>>
<<Command xhdr.lisp>>
<<Command group.lisp>> <<Command group.lisp>>
<<Command list.lisp>> <<Command list.lisp>>
<<Command help.lisp>> <<Command help.lisp>>

View file

@ -1,2 +1,2 @@
(in-package :loop) (in-package :loop)
(defparameter *git-hash* "d44771c") (defparameter *git-hash* "05be4aa")