Compare commits

..

1 commit

Author SHA1 Message Date
f0a54bf1f9 Identifies each binary with a version tag.
It also includes scripts for formating Lisp code for people who will
read Lisp code directly.  It doesn't cost us much to organize the Lisp
output a bit.  In a similar spirit, I'm not including NOWEB as part of
the build process.  (Most users will not have NOWEB around to use it.)
So while the Makefile is pretty short because there's little to do,
Anyfile is the ANYWEB makefile.  ANYWEB is my modified, personal
version of NOWEB for Windows.  (The way I use this second makefile is
to make a shell alias called /amake/ that automatically invokes ``make
-f Anyfile'' for me.)
2024-12-19 10:10:45 -03:00
8 changed files with 337 additions and 626 deletions

22
Anyfile
View file

@ -8,32 +8,18 @@ scripts/build-index-from-fs.lisp \
scripts/cron-remove-inactive-users.lisp \ scripts/cron-remove-inactive-users.lisp \
scripts/migrate-add-creation-date.lisp scripts/migrate-add-creation-date.lisp
loop.exe: scripts/build-exe.lisp loop.lisp loop.asd loop.nw loop.exe: scripts/build-exe.lisp loop.lisp loop.asd
sbcl --script scripts/build-exe.lisp sbcl --script scripts/build-exe.lisp
(test -f loop.exe && cmp loop loop.exe) || cp loop loop.exe (test -f loop.exe && cmp loop loop.exe) || cp loop loop.exe
loop.lisp: loop.nw format-def loop.lisp: loop.nw
./make-release $$(git log --oneline | head -1 | awk '{print $$1}') \
loop.nw > loop.tmp && mv loop.tmp loop.nw
(any tangle -Rloop.lisp < loop.nw | sh format-def | \ (any tangle -Rloop.lisp < loop.nw | sh format-def | \
dos2unix > loop.tmp || \ dos2unix > loop.tmp || \
(rm loop.tmp && exit 1)) && \ (rm loop.tmp && exit 1)) && \
mv loop.tmp loop.lisp mv loop.tmp loop.lisp
format-def: loop.nw
(any tangle -Rformat-def < loop.nw | \
dos2unix > format-def.tmp || \
(rm format-def.tmp && exit 1)) && \
mv format-def.tmp format-def
make-release: loop.nw
(any tangle -Rmake-release < loop.nw | \
dos2unix > make-release.tmp || \
(rm make-release.tmp && exit 1)) && \
mv make-release.tmp make-release
release: make-release
./make-release $$(git log --oneline | head -1 | awk '{print $$1}') \
loop.nw > loop.tmp && mv loop.tmp loop.nw
loop.asd: loop.nw loop.asd: loop.nw
(any tangle -Rloop.asd < loop.nw | dos2unix > loop-asd.tmp || \ (any tangle -Rloop.asd < loop.nw | dos2unix > loop-asd.tmp || \
(rm loop-asd.tmp && exit 1)) && \ (rm loop-asd.tmp && exit 1)) && \

76
README
View file

@ -6,10 +6,10 @@ LOOP is an NNTP server written in Common Lisp.
We assume We assume
- you have SBCL, Quicklisp and git installed - you run SBCL, Quicklisp and git installed
- you know how to use a TCP server such as - you know how to use a TCP server such as
https://cr.yp.to/ucspi-tcp.html https://cr.yp.to/ucspi-tcp.html
- you know how to manage a daemon with a package such as - you know how to manage a daemon witha package such as
https://cr.yp.to/daemontools.html https://cr.yp.to/daemontools.html
(*) How to install it (*) How to install it
@ -26,13 +26,37 @@ and say
$ echo /path/to/loop/home > conf-home $ echo /path/to/loop/home > conf-home
$ make install $ make install
(*) Systems with no installation issues
We installed LOOP just fine on
FreeBSD 14.1, 14.2 with SBCL 2.4.9.
Debian GNU/Linux 8.11 codename jessie with SBCL 1.2.4.debian.
(*) Systems with installation issues
We installed LOOP on Ubuntu 24.04 (24.01.1 LTS) codename noble with
SBCL 2.2.9.debian. We found that CLSQL could not load the shared
object libsqlite3.so because ``apt install libsqlite3'' installs the
library at
/usr/lib/x86_64-linux-gnu/libsqlite3.so.0.8.6
with a symbolic link to libsqlite3.so.0, but not to libsqlite3.so.
SBCL is trying to load libsqlite3.so, so a solution is to just tell
your system to
ln -s libsqlite3.so.0 libsqlite3.so
at /usr/lib/x86_64-linux-gnu.
(*) Running LOOP (*) Running LOOP
First, try it out. First, try it out.
$ cd /path/to/loop/home $ cd /path/to/loop/home
$ ./loop.exe $ ./loop
200 Welcome! I am LOOP 9575ac2. Say ``help'' for a menu. 200 Welcome! Say ``help'' for a menu.
quit quit
205 Good-bye. 205 Good-bye.
@ -48,23 +72,19 @@ LOOP requires authentication for most things, so you should create an
account for you right away. Accounts are kept in accounts.lisp in account for you right away. Accounts are kept in accounts.lisp in
your installation directory. Every time you create an account, you your installation directory. Every time you create an account, you
must specify who is inviting this new account into the loop---because must specify who is inviting this new account into the loop---because
we keep a tree of accounts. So say we keep a tree of accounts. The root account is called anonymous, so
your first account must be invited by the anonymous account. So you
can say
./loop --create-account you root ./loop --create-account you anonymous
to create YOU, your account. The root account has no special power; The anonymous account has no special power; it exists solely because
it exists solely because a tree of accounts needs a root. It's an the graph of accounts needs a root.
account like any other, so you could use it yourself. In that case,
change its password:
$ ./loop --change-passwd root <secret>
Okay, account root now has password ``<secret>''.
(*) How to expose LOOP to the network (*) How to expose LOOP to the network
Just run your TCP server of choice. For instance, if you're using Run your TCP server of choice. For instance, if you're using djb's
djb's tcpserver and would like LOOP to listen on port 1024, tell your tcpserver and would like LOOP to listen on port 1024, tell your shell
shell
--8<-------------------------------------------------------->8--- --8<-------------------------------------------------------->8---
$ tcpserver -v -HR 0.0.0.0 1024 ./loop -s $ tcpserver -v -HR 0.0.0.0 1024 ./loop -s
@ -117,27 +137,3 @@ scripts/cron-remove-inactive-users.lisp. Here's our crontab:
$ crontab -l $ crontab -l
@daily cd /path/to/loop/home && sbcl --script scripts/cron-remove-inactive-users.lisp @daily cd /path/to/loop/home && sbcl --script scripts/cron-remove-inactive-users.lisp
(*) Systems with no installation issues
We installed LOOP just fine on
FreeBSD 14.1, 14.2 with SBCL 2.4.9.
Debian GNU/Linux 8.11 codename jessie with SBCL 1.2.4.debian.
(*) Systems with installation issues
We installed LOOP on Ubuntu 24.04 (24.01.1 LTS) codename noble with
SBCL 2.2.9.debian. We found that CLSQL could not load the shared
object libsqlite3.so because ``apt install libsqlite3'' installs the
library at
/usr/lib/x86_64-linux-gnu/libsqlite3.so.0.8.6
with a symbolic link to libsqlite3.so.0, but not to libsqlite3.so.
SBCL is trying to load libsqlite3.so, so a solution is to just tell
your system to
ln -s libsqlite3.so.0 libsqlite3.so
at /usr/lib/x86_64-linux-gnu.

View file

@ -1,10 +1,9 @@
(#S(LOOP::ACCOUNT (#S(LOOP::ACCOUNT
:USERNAME "ROOT" :USERNAME "ANONYMOUS"
:SEEN 3943778307 :SEEN 3935609919
:LAST-POST NIL :LAST-POST NIL
:FRIENDS NIL :FRIENDS NIL
:PASS #(166 101 164 89 32 66 47 157 65 126 72 103 239 220 79 184 160 74 31 :PASS NIL
63 255 31 160 126 153 142 134 247 247 162 122 227)
:PASS-LOCKED NIL :PASS-LOCKED NIL
:PASS-LOCKED-WHY NIL :PASS-LOCKED-WHY NIL
:CREATION 3913066800)) :CREATION 3913066800))

View file

@ -4,12 +4,24 @@ usage()
printf 'usage: %s [file.lisp]\n' $0 printf 'usage: %s [file.lisp]\n' $0
exit 1 exit 1
} }
## The first program finds certain definitions and inserts a new blank
## line *before* the definition. Such action makes function
## definitions separated by two blank lines in some cases. We then
## remove the excess with the second program. Notice we need the -E
## option because we're using the | metacharacter that is only
## supported by popular sed programs with the -E option. This
## violates POSIX sed, but keep in mind that we only run this when
## releasing the package. This is a building tool, not part of the
## service.
sed -E '/^\(defun |\(defmacro /{ sed -E '/^\(defun |\(defmacro /{
i\ i\
}' "$@" | \ }' $* | sed '/^[ \t]*$/{
sed '/^[ \t]*$/{
N N
/^[ \t]*\n$/D /^[ \t]*\n$/D
}' }'
## We first find a blank line. Then we say N to expand the pattern
## space to include the next line. Then we delete the *first* blank
## line and not the second---that's what the D command does. This
## strategy is explained by Dale Dougherty and Arnold Robbins in ``sed
## & awk'' second edition, pages 112--114.

View file

@ -1,6 +1,6 @@
;;; -*- Mode: LISP; Syntax: COMMON-LISP; -*- ;;; -*- Mode: LISP; Syntax: COMMON-LISP; -*-
(asdf:defsystem :LOOP (asdf:defsystem :LOOP
:version "a89e088" :version "9575ac2"
:description "An NNTP server for a circle of friends." :description "An NNTP server for a circle of friends."
:depends-on (:lisp-unit :str :uiop :cl-ppcre :local-time :clsql-sqlite3 :clingon :depends-on (:lisp-unit :str :uiop :cl-ppcre :local-time :clsql-sqlite3 :clingon
:filesystem-utils :ironclad/digest/sha256) :filesystem-utils :ironclad/digest/sha256)

212
loop.lisp
View file

@ -9,7 +9,7 @@
(defpackage #:loop (defpackage #:loop
(:use :common-lisp :local-time) (:use :common-lisp :local-time)
(:import-from :lisp-unit define-test assert-true) (:import-from :lisp-unit define-test)
(:import-from :org.shirakumo.filesystem-utils (:import-from :org.shirakumo.filesystem-utils
directory-p list-directories list-files) directory-p list-directories list-files)
(:import-from :sb-sys interactive-interrupt) (:import-from :sb-sys interactive-interrupt)
@ -17,20 +17,19 @@
(in-package #:loop) (in-package #:loop)
(defparameter *debug* nil)
(defstruct account username seen last-post friends pass pass-locked pass-locked-why creation) (defstruct account username seen last-post friends pass pass-locked pass-locked-why creation)
(defparameter *accounts* nil) (defparameter *accounts* nil)
(defstruct client group (article 1) (username "ANONYMOUS") (auth? 'no)) (defstruct client group (article 1) (username "ANONYMOUS") (auth? 'no))
(defparameter *client* (make-client)) (defparameter *client* (make-client))
(defstruct request verb args said) (defstruct request verb args said)
(defstruct response code data request multi-line) (defstruct response code data request multi-line)
(defparameter *default-database* nil) (defvar *default-database* nil)
(defstruct command fn verb description) (defstruct command fn verb description)
(defparameter *commands-assoc* nil) (defparameter *commands-assoc* nil)
(defstruct article headers body) (defstruct article headers body)
(defparameter *months-inactive-allowed* 3) (defparameter *months-inactive-allowed* 3)
(defparameter *months-never-logged-in* 1) (defparameter *months-never-logged-in* 1)
(defparameter *enable-nntp-repl* t) (defvar *debug* nil)
(defun table-of-commands () (defun table-of-commands ()
`(("GROUP" ,#'cmd-group "sets the current group") `(("GROUP" ,#'cmd-group "sets the current group")
@ -93,7 +92,7 @@
(defun in-group-lambda (g fn) (in-dir g (funcall fn))) (defun in-group-lambda (g fn) (in-dir g (funcall fn)))
(defmacro in-group (g &rest body) (defmacro in-group (g &rest body)
`(in-group-lambda ,(fmt "groups/~a/" g) #'(lambda () ,@body))) `(in-group-lambda ,(fmt "groups/~a/" g) (lambda () ,@body)))
(defmacro with-group (g r &rest body) (defmacro with-group (g r &rest body)
(let ((g-var (gensym)) (let ((g-var (gensym))
@ -127,24 +126,10 @@
(make-response :code 400 :data "You must authenticate first.") (make-response :code 400 :data "You must authenticate first.")
(progn ,@body))) (progn ,@body)))
(defun conforms-to? (s re &optional error-msg)
"Does string S conform to regular expression RE?"
(let ((okay? (cl-ppcre:scan-to-strings re s)))
(if okay?
(values t nil)
(values nil (or error-msg (fmt "must match ~a" re))))))
(defun print/finish (&rest args) (defun print/finish (&rest args)
(apply #'format (cons t args)) (apply #'format (cons t args))
(finish-output)) (finish-output))
(defun word-plural (n word)
(let ((table '(("doesn't" . "don't")
("newsgroup" . "newsgroups"))))
(let ((w (assoc word table :test #'string=)))
(when (not w) (error "word not found"))
(if (< n 2) (car w) (cdr w)))))
(defun plural (v suffix) (defun plural (v suffix)
(if (> v 1) suffix "")) (if (> v 1) suffix ""))
@ -324,6 +309,7 @@
(defun remove-inactive-users! () (defun remove-inactive-users! ()
(loop for u in *accounts* do (loop for u in *accounts* do
(let ((username (account-username u))) (let ((username (account-username u)))
(format t "Username: ~a~%" username)
(cond ((and (not (locked? username)) (cond ((and (not (locked? username))
(inactive-from-never-logged-in? username)) (inactive-from-never-logged-in? username))
(post-notification (post-notification
@ -708,7 +694,7 @@
(defun group? (g) (defun group? (g)
(in-groups (in-groups
(ignore-errors (directory-p g)))) (directory-p g)))
(defun xgroup? (g) (defun xgroup? (g)
(directory-p g)) (directory-p g))
@ -842,8 +828,9 @@
:data (str:join (crlf-string) lines))))) :data (str:join (crlf-string) lines)))))
(defun menu (ls) (defun menu (ls)
(loop for item in ls (if (null ls)
collect (display-fn item))) nil
(cons (display-fn (car ls)) (menu (cdr ls)))))
(defun display-fn (cmd-pair) (defun display-fn (cmd-pair)
(let ((cmd (cdr cmd-pair))) (let ((cmd (cdr cmd-pair)))
@ -914,8 +901,8 @@
(make-article (make-article
:headers :headers
(str:join (crlf-string) (str:join (crlf-string)
(mapcar #'(lambda (h) (mapcar (lambda (h)
(format nil "~a: ~a" (car h) (cdr h))) (format nil "~a: ~a" (car h) (cdr h)))
(cons (cons h (funcall fn)) headers))) (cons (cons h (funcall fn)) headers)))
:body (article-body (parse-article bs))))))) :body (article-body (parse-article bs)))))))
@ -932,46 +919,44 @@
(defun ensure-date (bs) (defun ensure-date (bs)
(ensure-header "date" #'get-date bs)) (ensure-header "date" #'get-date bs))
(defun newsgroups-header->list (s)
(mapcar (lambda (n) (str:trim (string-downcase n))) (str:split "," s)))
(defun cmd-post (r) (defun cmd-post (r)
(with-auth (with-auth
(send-response! (send-response!
(make-response (make-response :code 340
:code 340 :data (format nil "Okay, go ahead. Suggested message-id ~a."
:data (format nil "Okay, go ahead. Suggested message-id ~a."
(suggest-message-id)))) (suggest-message-id))))
(let* ((bs (nntp-read-article))) (let* ((bs (nntp-read-article)))
(multiple-value-bind (okay? error) (conforms? bs) (multiple-value-bind (okay? error) (conforms? bs)
(cond ((not okay?) (if (not okay?)
(make-response (make-response :code 400 :request r
:code 400 :request r :data (format nil "Sorry. Your article doesn't conform: ~a." error))
:data (format nil "Sorry. Your article doesn't conform: ~a." error))) (multiple-value-bind (code reply) (post bs)
(t (multiple-value-bind (code reply) (post bs) (make-response :code code :request r :data reply)))))))
(make-response :code code :request r :data reply))))))))
(defun post (bs) (defun post (bs)
(let ((ngs (newsgroups-header->list (let ((ngs (newsgroups-header->list
(get-header "newsgroups" (parse-headers (get-header "newsgroups" (parse-headers
(article-headers (article-headers
(parse-article bs)))))) (parse-article bs))))))
(ngs-dont-exist)) ngs-dont-exist)
(dolist (g ngs) (dolist (ng ngs)
(if (or (not (group-name-conforms? g)) (if (and (group-name-conforms? ng)
(not (group? g))) (group? ng))
(push g ngs-dont-exist))) (progn
(if (zerop (length ngs-dont-exist))
(progn
(dolist (ng ngs)
(let ((a (ensure-date (ensure-mid bs)))) (let ((a (ensure-date (ensure-mid bs))))
(save-article-insist ng (get-next-article-id ng) a (extract-mid a)) (save-article-insist ng (get-next-article-id ng) a (extract-mid a))
(update-last-post-date! (client-username *client*)))) (update-last-post-date! (client-username *client*))))
(values 240 (data "Thank you! Your article has been saved."))) (push ng ngs-dont-exist)))
(values 400 (data "Sorry. We did not post your article to any newsgroup because " (if (zerop (- (length ngs) (length ngs-dont-exist)))
"the " (word-plural (length ngs-dont-exist) "newsgroup") " " (values 400 "Sorry. There was not a single valid newsgroup specified.")
(str:join ", " (sort ngs-dont-exist #'string<)) (values 240 (data "Thank you! Your article has been saved."
" just " (word-plural (length ngs-dont-exist) "doesn't") " exist."))))) (when ngs-dont-exist
(data " However, the groups "
(defun newsgroups-header->list (s) (str:join ", " (sort ngs-dont-exist #'string<))
(mapcar #'(lambda (n) (str:trim (string-downcase n))) (str:split "," s))) " just don't exist.")))))))
(defun update-last-post-date! (username) (defun update-last-post-date! (username)
(let ((u (get-account username))) (let ((u (get-account username)))
@ -1044,7 +1029,7 @@
(defun add-crlf-between (ls-of-ls) (defun add-crlf-between (ls-of-ls)
;; Add \r\n to each ``line''. Returns List-of Byte. ;; Add \r\n to each ``line''. Returns List-of Byte.
(mapcar #'(lambda (ls) (append ls (list 13 10))) ls-of-ls)) (mapcar (lambda (ls) (append ls (list 13 10))) ls-of-ls))
(defun string->bytes (s) (defun string->bytes (s)
(map 'list #'char-code s)) (map 'list #'char-code s))
@ -1076,10 +1061,10 @@
:data (format nil "group ~a created" g))))))))))) :data (format nil "group ~a created" g)))))))))))
(defun group-name-conforms? (g) (defun group-name-conforms? (g)
(conforms-to? g "^[^\\s/]+$")) (let ((okay? (cl-ppcre:scan-to-strings "^([a-z0-9]+)" g)))
(if okay?
(defun user-name-conforms? (u) (values t nil)
(conforms-to? u "^[^\\s]+$")) (values nil "must match ^([a-z0-9]+)"))))
(defun cmd-create-account (r) (defun cmd-create-account (r)
(with-auth (with-auth
@ -1118,18 +1103,14 @@
(p (random-string 6)) (p (random-string 6))
(a (make-account :username u (a (make-account :username u
:pass (string->sha256 (str:upcase p)) :pass (string->sha256 (str:upcase p))
:creation (get-universal-time)))) :creation (get-universal-time))))
(multiple-value-bind (okay? reason) (user-name-conforms? u) (if (get-account u)
(declare (ignore reason)) (values nil (fmt "account ~a already exists" u))
(cond ((not okay?) (progn
(values nil (fmt "username must conform to ^[^\\s]+$"))) (push u (account-friends (get-account invited-by)))
((get-account u) (push a *accounts*)
(values nil (fmt "account ~a already exists" u))) (write-accounts!)
(t (values (str:upcase username) p)))))
(push u (account-friends (get-account invited-by)))
(push a *accounts*)
(write-accounts!)
(values (str:upcase username) p))))))
(defun write-accounts! () (defun write-accounts! ()
(let ((name (let ((name
@ -1224,8 +1205,7 @@
(let ((u (get-account username))) (let ((u (get-account username)))
(and (and
u u
(cond ((null (account-pass u)) nil) (cond ((integerp (account-pass u))
((integerp (account-pass u))
(eq (sxhash pass) (account-pass u))) (eq (sxhash pass) (account-pass u)))
((arrayp (account-pass u)) ((arrayp (account-pass u))
(equalp (string->sha256 pass) (account-pass u))) (equalp (string->sha256 pass) (account-pass u)))
@ -1252,7 +1232,7 @@
(defun list-users () (defun list-users ()
(read-accounts!) (read-accounts!)
(mapcar #'(lambda (row) (cadr row)) (mapcar (lambda (row) (cadr row))
(sort (sort
(loop for u in *accounts* (loop for u in *accounts*
collect (list (account-username u) collect (list (account-username u)
@ -1267,7 +1247,7 @@
(fmt "last seen on ~a" (last-time-seen (account-username u))) (fmt "last seen on ~a" (last-time-seen (account-username u)))
"never logged in") "never logged in")
(or (account-friends u) "nobody")))) (or (account-friends u) "nobody"))))
#'string<= :key #'(lambda (row) (car row))))) #'string<= :key (lambda (row) (car row)))))
(defun universal-to-human (s) (defun universal-to-human (s)
(format-timestring (format-timestring
@ -1284,13 +1264,8 @@
(make-response :code 200 :data (format nil "state: ~a" *client*) :request r)) (make-response :code 200 :data (format nil "state: ~a" *client*) :request r))
(defun cmd-repl (r) (defun cmd-repl (r)
(if *enable-nntp-repl* (with-auth
(with-auth (repl r)))
(repl r))
(make-response
:code 400
:data "The REPL has been *explicitly* disabled by the sysadmin. :("
:request r)))
(defun notify-group-created (g) (defun notify-group-created (g)
(post-notification (post-notification
@ -1319,7 +1294,7 @@
:headers (data :headers (data
(add-crlf-between (add-crlf-between
(mapcar (mapcar
#'(lambda (p) (data (format nil "~a: ~a" (car p) (cdr p)))) (lambda (p) (data (format nil "~a: ~a" (car p) (cdr p))))
`(("from" . "Loop") `(("from" . "Loop")
("subject" . ,subject) ("subject" . ,subject)
("newsgroups" . "local.control.news"))))) ("newsgroups" . "local.control.news")))))
@ -1329,12 +1304,12 @@
(list (list
(clingon:make-option (clingon:make-option
:string :string
:description "creates a new account" :description "<username> <invited-by> creates a new account"
:long-name "create-account" :long-name "create-account"
:key :create-account) :key :create-account)
(clingon:make-option (clingon:make-option
:string :string
:description "changes password" :description "<username> <new-password> changes password"
:long-name "change-passwd" :long-name "change-passwd"
:key :change-passwd) :key :change-passwd)
(clingon:make-option (clingon:make-option
@ -1345,15 +1320,10 @@
:key :list-accounts) :key :list-accounts)
(clingon:make-option (clingon:make-option
:flag :flag
:description "runs a REPL right now" :description "runs a REPL"
:short-name #\r :short-name #\r
:long-name "repl" :long-name "repl"
:key :repl) :key :repl)
(clingon:make-option
:flag
:description "disables the NNTP REPL"
:long-name "disable-nntp-repl"
:key :disable-nntp-repl)
(clingon:make-option (clingon:make-option
:flag :flag
:description "logging (on stderr)" :description "logging (on stderr)"
@ -1385,7 +1355,8 @@
(new-passwd (or given-passwd random-passwd))) (new-passwd (or given-passwd random-passwd)))
(if (not (get-account username)) (if (not (get-account username))
(println "No such account ``~a''." username) (println "No such account ``~a''." username)
(multiple-value-bind (okay? problem) (change-passwd! username new-passwd) (if okay? (multiple-value-bind (okay? problem) (change-passwd! username new-passwd)
(if okay?
(println "Okay, account ~a now has password ``~a''." username new-passwd) (println "Okay, account ~a now has password ``~a''." username new-passwd)
(println "Sorry, could not change password: ~a." problem)))))) (println "Sorry, could not change password: ~a." problem))))))
@ -1404,16 +1375,12 @@
(connect-index! "message-id.db") (connect-index! "message-id.db")
(create-index!) (create-index!)
(let ((args (clingon:command-arguments cmd)) (let ((args (clingon:command-arguments cmd))
(run-server t)
(repl (clingon:getopt cmd :repl)) (repl (clingon:getopt cmd :repl))
(ca (clingon:getopt cmd :create-account)) (ca (clingon:getopt cmd :create-account))
(pa (clingon:getopt cmd :change-passwd)) (pa (clingon:getopt cmd :change-passwd))
(la (clingon:getopt cmd :list-accounts)) (la (clingon:getopt cmd :list-accounts))
(logging (clingon:getopt cmd :logging)) (logging (clingon:getopt cmd :logging)))
(disable-nntp-repl (clingon:getopt cmd :disable-nntp-repl)))
(setf *debug* logging) (setf *debug* logging)
(when (or ca pa la)
(setf run-server nil))
(when la (when la
(cli/list-accounts)) (cli/list-accounts))
(when ca (when ca
@ -1422,16 +1389,14 @@
(cli/change-passwd pa args)) (cli/change-passwd pa args))
(when repl (when repl
(repl (make-request :verb "repl" :args '(command-line)))) (repl (make-request :verb "repl" :args '(command-line))))
(when disable-nntp-repl (when (and (not la) (not ca) (not pa) (not repl))
(setq *enable-nntp-repl* nil))
(when run-server
(server-start)))) (server-start))))
(defun cli/command () (defun cli/command ()
(clingon:make-command (clingon:make-command
:name "loop" :name "loop"
:description "An NNTP server for a circle of friends." :description "An NNTP server for a circle of friends."
:version "a89e088" :version "9575ac2"
:license "GPL v3" :license "GPL v3"
:options (cli/options) :options (cli/options)
:handler #'cli/main-with-handlers)) :handler #'cli/main-with-handlers))
@ -1461,42 +1426,23 @@
(send-response! (send-response!
(make-response (make-response
:code 200 :code 200
:data "Welcome! I am LOOP a89e088. Say ``help'' for a menu."))) :data "Welcome! I am LOOP 9575ac2. Say ``help'' for a menu.")))
(setq lisp-unit:*print-failures* t) (setq lisp-unit:*print-failures* t)
(define-test first-test-of-the-west
(assert-equal 0 0))
(define-test requests
(let ((nil-request-1 (make-request))
(nil-request-2 (make-request :said " ")))
(assert-true (request=? nil-request-1 (parse-request nil-request-1)))
(assert-true (request=? nil-request-2 (parse-request nil-request-2)))
(assert-true (request=? nil-request-1 nil-request-2))))
(define-test commands
(let ((ht (make-hash-table))
(c1 (make-command :fn #'cons :verb 'c1 :description "cons cmd"))
(c2 (make-command :fn #'list :verb 'c2 :description "list cmd")))))
(define-test dispatching (define-test dispatching
(assert-true (equalp (empty-response) (dispatch (make-request))))) (assert-true (equalp (empty-response) (dispatch (empty-request)))))
(defun unix->nntp (s)
"I substitute \n for \r\n"
(str:replace-all (fmt "~a" #\linefeed) (crlf-string) s))
(defvar a-post (unix->nntp "From: root
Message-id: <pwtdldytefplntosymvo@loop>
Subject: test
Newsgroups: local.test
Quickest test of the West.
"))
(defvar a-bad-post (unix->nntp "From: root
Message-id: <pwtdldytefplntosymvp@loop>
Subject: a bad test
Newsgroups: local.test, bad.newsgroup
A bad test from the biggest mouth of the south.
"))
(define-test post-wrong-newsgroup
(multiple-value-bind (code msg) (post (string->bytes a-bad-post))
(declare (ignore msg))
(assert-true (equal code 400))))
(define-test post-okay
(read-accounts!)
(connect-index! "test.db")
(create-index!)
(setq *client* (make-client :username "ROOT" :auth? 'yes))
(multiple-value-bind (code msg) (post (string->bytes a-post))
(declare (ignore msg))
(assert-true (equal code 240)))
(clsql:disconnect))

613
loop.nw
View file

@ -47,7 +47,6 @@
{a circle out of fashion}} {a circle out of fashion}}
\date{January 2024} \date{January 2024}
\begin{document} \begin{document}
\pdfbookmark[1]{Introduction}{intro}
\fontfamily{cmr}\selectfont \fontfamily{cmr}\selectfont
\maketitle \maketitle
%\setlength{\parskip}{3pt} %\setlength{\parskip}{3pt}
@ -55,9 +54,9 @@
\Lp\ is an out-of-fashion program, used as medium of communication by \Lp\ is an out-of-fashion program, used as medium of communication by
antiquated people. \Lp\ members insist that technical communication antiquated people. \Lp\ members insist that technical communication
be made in writing and not in a hurry. To give you an idea, they be made in writing and not in a hurry. That's how backwards they are.
write \Lp\ in jurassic technology. You wouldn't pay them any To give you an idea, they write \Lp\ in Lisp---jurassic technology.
attention. We surely wouldn't pay them any attention.
% %
\begin{verbatim} \begin{verbatim}
Drunk and dressed in their best brown baggies and their platform soles Drunk and dressed in their best brown baggies and their platform soles
@ -75,24 +74,6 @@ the list of destinaries. So long as everyone replies to everyone,
John, too, will start getting all the messages. If anyone violates John, too, will start getting all the messages. If anyone violates
this rule of replying to everyone involved, the loop is broken. this rule of replying to everyone involved, the loop is broken.
\begin{figure}[!htb]
\centering \includegraphics[width=0.8\linewidth]{images/gnus-summary.png}
\caption{Gnus, a news reader embedded in the GNU EMACS text editor.}
\label{fg:gnus}
\end{figure}
\begin{figure}[!htb]
\centering \includegraphics[width=0.8\linewidth]{images/tbird-summary.png}
\caption{Thunderbird, a news reader produced by the Mozilla Foundation.}
\label{fg:bird}
\end{figure}
\begin{figure}[!htb]
\centering \includegraphics[width=0.8\linewidth]{images/sylpheed-summary.png}
\caption{Sylpheed, a news reader produced by Hiroyuki Yamamoto.}
\label{fg:sylpheed}
\end{figure}
There are surely inconveniences in using e-mail as conference medium. There are surely inconveniences in using e-mail as conference medium.
For example, after John has been added to the loop, he is not able to For example, after John has been added to the loop, he is not able to
leave by his own account. He needs to ask everyone involved to stop leave by his own account. He needs to ask everyone involved to stop
@ -129,7 +110,7 @@ commit to reading one of these collective mailboxes and no need to
formally notify anyone or any system that you're not interested in formally notify anyone or any system that you're not interested in
that group any longer. These collective mailboxes are called ``news that group any longer. These collective mailboxes are called ``news
groups'' and are often written as ``newsgroups''. And the messages groups'' and are often written as ``newsgroups''. And the messages
posted to these newsgroups are called ``articles''. posted to these news groups are called ``articles''.
Just like e-mail and the web, network news is an open protocol. Just like e-mail and the web, network news is an open protocol.
Anyone could write a program capable of speaking NNTP. There are many Anyone could write a program capable of speaking NNTP. There are many
@ -137,22 +118,41 @@ NNTP-aware programs. You could write your own. Figures
\ref{fg:gnus}--\ref{fg:sylpheed} show a few programs for reading \ref{fg:gnus}--\ref{fg:sylpheed} show a few programs for reading
network news via NNTP. network news via NNTP.
\section*{Principles for a discussion group}\label{principles} \begin{figure}[!htb]
\pdfbookmark[1]{Principles for a discussion group}{principles} We \centering \includegraphics[width=0.8\linewidth]{images/gnus-summary.png}
believe a discussion group should be small and grow slowly. By \caption{Gnus, a news reader embedded in the GNU EMACS text editor.}
``slowly'', we mean that each member comes in through an invitation. \label{fg:gnus}
This way, the group being closed by definition, we keep spam out and \end{figure}
give members a certain sense of privilege. A discussion group should
be formed by interested people. If a participant doesn't log-in for a \begin{figure}[!htb]
certain period of time, \lp\ locks the participant's account---see \centering \includegraphics[width=0.8\linewidth]{images/tbird-summary.png}
Section \ref{sec:inactive-users}. The account can be reactivated, but \caption{Thunderbird, a news reader produced by the Mozilla Foundation.}
it will take asking another participant (with an active account) to do \label{fg:bird}
so. In other words, there's an encouragement for an uninterested \end{figure}
member not to come back to the \lp. The idea is to keep a certain
cohesion in the discussion groups. When an account is locked or \begin{figure}[!htb]
unlocked, an article is posted to the group {\tt local.control.news}, \centering \includegraphics[width=0.8\linewidth]{images/sylpheed-summary.png}
so everyone knows who is leaving and arriving. This way, participants \caption{Sylpheed, a news reader produced by Hiroyuki Yamamoto.}
get to have an idea of who is reading them. \label{fg:sylpheed}
\end{figure}
\noindent{\bf Principles for a discussion group}. We believe a discussion group
should be small and grow slowly. By ``slowly'', we mean that each
member comes in through an invitation. This way, the group being
closed by definition, we keep spam out and give members a certain
sense of privilege.
A discussion group should be formed by interested people. If a
participant doesn't log-in for a certain period of time, \lp locks the
participant's account---see Section \ref{sec:inactive-users}. The
account can be reactivated, but it will take asking another
participant (with an active account) to do so. In other words,
there's an encouragement for an uninterested member not to come back
to the \lp. The idea is to keep a certain cohesion in the discussion
groups. When an account is locked or unlocked, an article is posted
to the group {\tt local.control.news}, so everyone knows who is
leaving and arriving. This way, participants get to have an idea of
who is reading them.
Each invitation comes with a certain responsibility: it's possible to Each invitation comes with a certain responsibility: it's possible to
see who invited who. If {\tt BOB} misbehaves, everyone gets to see see who invited who. If {\tt BOB} misbehaves, everyone gets to see
@ -181,11 +181,9 @@ Hereafter, our conversation continues in Lisp. Understanding how
\lp\ is made is only necessary if you intend to modify it. If you \lp\ is made is only necessary if you intend to modify it. If you
just want to use the system, you probably should stop right here. just want to use the system, you probably should stop right here.
\section*{How to install} \section{How to install}
See See \href{https://git.antartida.xyz/loop/srv/raw/branch/main/README}{[[README]]}.
\href{https://git.antartida.xyz/loop/srv/raw/branch/main/README}{[[README]]}
in \lp's source code.
\section{Implementation strategy}\label{sec:design} \section{Implementation strategy}\label{sec:design}
@ -660,7 +658,7 @@ An NNTP server for a circle of friends.
@ @
<<Version>>= <<Version>>=
a89e088 9575ac2
@ @
\section{Parsing of requests} \section{Parsing of requests}
@ -687,20 +685,19 @@ letters are equivalent in request verbs.
We're using the clingon library as per Vincent Dardel suggestion in We're using the clingon library as per Vincent Dardel suggestion in
``The Common Lisp Cookbook''. We begin with writing a description of ``The Common Lisp Cookbook''. We begin with writing a description of
the program and options it understands. XXX: notice I don't know how the program and options it understands. XXX: notice I don't know how
to support a two-argument option, so I hacked a solution away. What to support a two-argument option, so I hacked a solution away.
we need to is to implement a new option. The library is extensible.
<<Command-line parsing>>= <<Command-line parsing>>=
(defun cli/options () (defun cli/options ()
(list (list
(clingon:make-option (clingon:make-option
:string :string
:description "creates a new account" :description "<username> <invited-by> creates a new account"
:long-name "create-account" :long-name "create-account"
:key :create-account) :key :create-account)
(clingon:make-option (clingon:make-option
:string :string
:description "changes password" :description "<username> <new-password> changes password"
:long-name "change-passwd" :long-name "change-passwd"
:key :change-passwd) :key :change-passwd)
(clingon:make-option (clingon:make-option
@ -711,15 +708,10 @@ we need to is to implement a new option. The library is extensible.
:key :list-accounts) :key :list-accounts)
(clingon:make-option (clingon:make-option
:flag :flag
:description "runs a REPL right now" :description "runs a REPL"
:short-name #\r :short-name #\r
:long-name "repl" :long-name "repl"
:key :repl) :key :repl)
(clingon:make-option
:flag
:description "disables the NNTP REPL"
:long-name "disable-nntp-repl"
:key :disable-nntp-repl)
(clingon:make-option (clingon:make-option
:flag :flag
:description "logging (on stderr)" :description "logging (on stderr)"
@ -727,9 +719,12 @@ we need to is to implement a new option. The library is extensible.
:key :logging))) :key :logging)))
@ @
We implement first the procedures that handle options that represent The command-line options form a language. The user specifies
an entire program. For example, saying [[--list-accounts]] is like everything he wants with flags. If he wants nothing, for instance, he
running a program [[./list-accounts]]. specifies nothing and then nothing happens. XXX: I'd like to have a
default action (which would be running the server) that is invoked by
default if none of the other options would run. But I don't know how
to do that yet.
<<Command-line parsing>>= <<Command-line parsing>>=
(defun cli/list-accounts () (defun cli/list-accounts ()
@ -757,15 +752,13 @@ running a program [[./list-accounts]].
(new-passwd (or given-passwd random-passwd))) (new-passwd (or given-passwd random-passwd)))
(if (not (get-account username)) (if (not (get-account username))
(println "No such account ``~a''." username) (println "No such account ``~a''." username)
(multiple-value-bind (okay? problem) (change-passwd! username new-passwd) (if okay? (multiple-value-bind (okay? problem) (change-passwd! username new-passwd)
(if okay?
(println "Okay, account ~a now has password ``~a''." username new-passwd) (println "Okay, account ~a now has password ``~a''." username new-passwd)
(println "Sorry, could not change password: ~a." problem)))))) (println "Sorry, could not change password: ~a." problem))))))
@ %def cli/change-passwd cli/list-accounts cli/create-account @ %def cli/change-passwd cli/list-accounts cli/create-account
Now let's write the main procedure in command-line parsing. Notice Now let's write the main procedure in command-line parsing.
that because of the design of the [[clingon]] library, command-line
parsing becomes the main procedure of \lp. In other words, \lp's
service starts with [[server-start]].
<<Command-line parsing>>= <<Command-line parsing>>=
(defun cli/main-with-handlers (cmd) (defun cli/main-with-handlers (cmd)
@ -783,16 +776,12 @@ service starts with [[server-start]].
(connect-index! "message-id.db") (connect-index! "message-id.db")
(create-index!) (create-index!)
(let ((args (clingon:command-arguments cmd)) (let ((args (clingon:command-arguments cmd))
(run-server t)
(repl (clingon:getopt cmd :repl)) (repl (clingon:getopt cmd :repl))
(ca (clingon:getopt cmd :create-account)) (ca (clingon:getopt cmd :create-account))
(pa (clingon:getopt cmd :change-passwd)) (pa (clingon:getopt cmd :change-passwd))
(la (clingon:getopt cmd :list-accounts)) (la (clingon:getopt cmd :list-accounts))
(logging (clingon:getopt cmd :logging)) (logging (clingon:getopt cmd :logging)))
(disable-nntp-repl (clingon:getopt cmd :disable-nntp-repl)))
(setf *debug* logging) (setf *debug* logging)
(when (or ca pa la)
(setf run-server nil))
(when la (when la
(cli/list-accounts)) (cli/list-accounts))
(when ca (when ca
@ -801,9 +790,7 @@ service starts with [[server-start]].
(cli/change-passwd pa args)) (cli/change-passwd pa args))
(when repl (when repl
(repl (make-request :verb "repl" :args '(command-line)))) (repl (make-request :verb "repl" :args '(command-line))))
(when disable-nntp-repl (when (and (not la) (not ca) (not pa) (not repl))
(setq *enable-nntp-repl* nil))
(when run-server
(server-start)))) (server-start))))
(defun cli/command () (defun cli/command ()
@ -980,7 +967,8 @@ Lisp offers me [[labels]], but [[labels]] don't seem so helpful when
I'm at the REPL. When I use [[defun]], I'm able to always invoke the I'm at the REPL. When I use [[defun]], I'm able to always invoke the
procedure at the REPL, but that's not so with [[labels]]. I guess the procedure at the REPL, but that's not so with [[labels]]. I guess the
use of [[labels]] is when the procedure is so trivial that we have no use of [[labels]] is when the procedure is so trivial that we have no
reason to think we're doing to debug it.} reason to think we're doing to debug it.} XXX: replace menu with
[[loop]].
<<Command help>>= <<Command help>>=
(defun cmd-help (r) (defun cmd-help (r)
@ -990,10 +978,10 @@ reason to think we're doing to debug it.}
(make-response :code 200 :multi-line 'yes (make-response :code 200 :multi-line 'yes
:request r :request r
:data (str:join (crlf-string) lines))))) :data (str:join (crlf-string) lines)))))
(defun menu (ls) (defun menu (ls)
(loop for item in ls (if (null ls)
collect (display-fn item))) nil
(cons (display-fn (car ls)) (menu (cdr ls)))))
(defun display-fn (cmd-pair) (defun display-fn (cmd-pair)
(let ((cmd (cdr cmd-pair))) (let ((cmd (cdr cmd-pair)))
@ -1055,26 +1043,10 @@ Syntax error. Say ``authinfo USER /your-name/'' then ``authinfo PASS /your-pass/
\subsection{{\tt CREATE-ACCOUNT}} \subsection{{\tt CREATE-ACCOUNT}}
We allow authenticated members to invite their friends, which creates We allow authenticated members to invite their friends. Notice that
a tree of people. ({\em An idea}. We could envision that each tree we're not doing any kind of checking on the username. XXX: take a
trunk manages the rest of the tree underneath it. So I invite you, I look at how we verify group names match a certain regex and apply the
could change your password, say, or handle any problems you might same check here.
have. This decentralizes system administration, easing the support
burden.)
The name of each user must conform to the expression
<<Form of user names>>=
^[^\\s]+$
@
Same as in @<<Form of newsgroup names@>>. We'll let users create
whatever complicated user names they want. If they can type it up,
it's their problem.
<<Command create-account>>=
(defun user-name-conforms? (u)
(conforms-to? u "<<Form of user names>>"))
<<Command create-account>>= <<Command create-account>>=
(defun cmd-create-account (r) (defun cmd-create-account (r)
@ -1121,18 +1093,14 @@ it's their problem.
(p (random-string 6)) (p (random-string 6))
(a (make-account :username u (a (make-account :username u
:pass (string->sha256 (str:upcase p)) :pass (string->sha256 (str:upcase p))
:creation (get-universal-time)))) :creation (get-universal-time))))
(multiple-value-bind (okay? reason) (user-name-conforms? u) (if (get-account u)
(declare (ignore reason)) (values nil (fmt "account ~a already exists" u))
(cond ((not okay?) (progn
(values nil (fmt "username must conform to <<Form of user names>>"))) (push u (account-friends (get-account invited-by)))
((get-account u) (push a *accounts*)
(values nil (fmt "account ~a already exists" u))) (write-accounts!)
(t (values (str:upcase username) p)))))
(push u (account-friends (get-account invited-by)))
(push a *accounts*)
(write-accounts!)
(values (str:upcase username) p))))))
@ %def CREATE-ACCOUNT new-account! @ %def CREATE-ACCOUNT new-account!
Notice that we have a race condition in [[write-accounts]]. What is Notice that we have a race condition in [[write-accounts]]. What is
@ -1262,8 +1230,7 @@ there is a macro emerging here called [[with-upcase-args]]. %% TODO
(let ((u (get-account username))) (let ((u (get-account username)))
(and (and
u u
(cond ((null (account-pass u)) nil) (cond ((integerp (account-pass u))
((integerp (account-pass u))
(eq (sxhash pass) (account-pass u))) (eq (sxhash pass) (account-pass u)))
((arrayp (account-pass u)) ((arrayp (account-pass u))
(equalp (string->sha256 pass) (account-pass u))) (equalp (string->sha256 pass) (account-pass u)))
@ -1297,24 +1264,23 @@ invited who.
maximizing (length (account-username u)))) maximizing (length (account-username u))))
(defun list-users () (defun list-users ()
(mapcar (read-accounts!)
#'(lambda (row) (cadr row)) (mapcar (lambda (row) (cadr row))
(sort (sort
(loop for u in *accounts* (loop for u in *accounts*
collect (list collect (list (account-username u)
(account-username u) (fmt "~v@a~a, ~a, invited ~a"
(fmt "~v@a~a, ~a, invited ~a" (size-of-longest-username)
(size-of-longest-username) (account-username u)
(account-username u) (if (locked? (account-username u))
(if (locked? (account-username u)) (fmt " (account locked: ~a)"
(fmt " (account locked: ~a)" (account-pass-locked-why u))
(account-pass-locked-why u)) "")
"") (if (last-time-seen (account-username u))
(if (last-time-seen (account-username u)) (fmt "last seen on ~a" (last-time-seen (account-username u)))
(fmt "last seen on ~a" (last-time-seen (account-username u))) "never logged in")
"never logged in") (or (account-friends u) "nobody"))))
(or (account-friends u) "nobody")))) #'string<= :key (lambda (row) (car row)))))
#'string<= :key #'(lambda (row) (car row)))))
(defun universal-to-human (s) (defun universal-to-human (s)
(format-timestring (format-timestring
@ -1498,7 +1464,7 @@ We just need to verify if the group exists and modify [[*client*]].
(defun group? (g) (defun group? (g)
(in-groups (in-groups
(ignore-errors (directory-p g)))) (directory-p g)))
(defun xgroup? (g) (defun xgroup? (g)
(directory-p g)) (directory-p g))
@ -1569,15 +1535,7 @@ XXX: instead of only catching [[sb-posix:syscall-error]], we should
catch anything else, reporting the error. Otherwise, we will blow up catch anything else, reporting the error. Otherwise, we will blow up
in case of some unexpected error, which might not be a bad idea---as in case of some unexpected error, which might not be a bad idea---as
long as we can log these errors and get a report later on of what's long as we can log these errors and get a report later on of what's
going on so we can improve the code. I still don't really know what going on so we can improve the code.
to do here. Let's leave it as it is. The original idea is to put a
[[t]]-case in the [[handler-case]] below and just log the error
instead of crashing completely. We can simulate the catching of an
unexpected condition by signaling it from fetch-article as a test.
This type of situation should have a testing routine as well. So,
yeah, first give yourself another read of the [[lisp-unit]]
documentation, then how to handle conditions properly and then come
back to this to-do item.
<<Commands head, body, article>>= <<Commands head, body, article>>=
(defun typical-cmd-response (code r g i get-data) (defun typical-cmd-response (code r g i get-data)
@ -1751,7 +1709,7 @@ must have \verb|message-id|, \verb|subject|, \verb|from|,
@ @
Sometimes we parse an article and sometimes we want to undo that Sometimes we parse an article and sometimes we want to undo that
parsing. Am I doing something wrong? I wonder. parsing. Am I doing something wrong? I wonder. %% TODO
<<Command post>>= <<Command post>>=
(defun unparse-article (parsed) (defun unparse-article (parsed)
@ -1779,8 +1737,8 @@ now, however, we have only these two to worry about.
(make-article (make-article
:headers :headers
(str:join (crlf-string) (str:join (crlf-string)
(mapcar #'(lambda (h) (mapcar (lambda (h)
(format nil "~a: ~a" (car h) (cdr h))) (format nil "~a: ~a" (car h) (cdr h)))
(cons (cons h (funcall fn)) headers))) (cons (cons h (funcall fn)) headers)))
:body (article-body (parse-article bs))))))) :body (article-body (parse-article bs)))))))
@ -1797,99 +1755,69 @@ now, however, we have only these two to worry about.
(ensure-header "date" #'get-date bs)) (ensure-header "date" #'get-date bs))
@ %def ensure-mid ensure-date @ %def ensure-mid ensure-date
Now it's time to look at the header \verb|newsgroups|. XXX: we need Now it's time to look at the header \verb|newsgroups|. (XXX: Our code
to rewrite this because we have that plan of verifying everything here is a bit confusing, but I don't know the best to do here, so I'm
there is to verify up front in [[conforms?]]. So when we invoke going ahead unpretentiously.) If we get approved by [[conforms?]],
[[post]], there's nothing else to verify. We're verifying in two then we verify the list of newsgroups right away.
places at the same time.
The name of each group must conform to the expression The name of each group must conform to the expression
<<Form of newsgroup names>>= <<Form of newsgroup names>>=
^[^\\s/]+$ ^([a-z0-9]+)
@ @ %def the-form-of-newsgroup-names
In other words, let group names go wild. They cannot contain a slash I think people should have total freedom in naming groups. If users
or a space of any kind anywhere on the name---more literally: they create groups that mess up the local organization, then people should
must begin with any character that's not a space and must have at discuss the matter and find a solution. Let's let people mess it up
least one character. The problem wish slashes is that each group will instead of trying to stop them---the way of the hacker.
be a directory on a UNIX file system, so we cannot let slashes appear.
People should have total freedom in naming groups. If users create
groups that mess up the local organization, then people should discuss
the matter and find a solution. Let's let people mess it up instead
of trying to stop them.
<<Command post>>= <<Command post>>=
(defun newsgroups-header->list (s)
(mapcar (lambda (n) (str:trim (string-downcase n))) (str:split "," s)))
(defun cmd-post (r) (defun cmd-post (r)
(with-auth (with-auth
(send-response! (send-response!
(make-response (make-response :code 340
:code 340 :data (format nil "Okay, go ahead. Suggested message-id ~a."
:data (format nil "Okay, go ahead. Suggested message-id ~a."
(suggest-message-id)))) (suggest-message-id))))
(let* ((bs (nntp-read-article))) (let* ((bs (nntp-read-article)))
(multiple-value-bind (okay? error) (conforms? bs) (multiple-value-bind (okay? error) (conforms? bs)
(cond ((not okay?) (if (not okay?)
(make-response (make-response :code 400 :request r
:code 400 :request r :data (format nil "Sorry. Your article doesn't conform: ~a." error))
:data (format nil "Sorry. Your article doesn't conform: ~a." error))) (multiple-value-bind (code reply) (post bs)
(t (multiple-value-bind (code reply) (post bs) (make-response :code code :request r :data reply)))))))
(make-response :code code :request r :data reply))))))))
@
It's time to write the action of posting. One thing to keep in mind
is cross-posting. First, notice that we're---so far---duplicating
articles on the file system. (We will undo that once we reimplement
our index. More to follow.) More importantly, we cannot let the user
post to any group if one of the groups is incorrectly named---for
example, when the group doesn't exist. Why don't we post to the ones
that are correct and warn the user of the ones that are incorrect?
Because that is not prudent. The user could be trying to publish news
to be received at the same time by various groups. We would make such
plans all go down the drain.
We collect a list of newsgroups that don't exist (or whose names do
not conform 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. Otherwise we save the article.
<<Command post>>=
(defun post (bs) (defun post (bs)
(let ((ngs (newsgroups-header->list (let ((ngs (newsgroups-header->list
(get-header "newsgroups" (parse-headers (get-header "newsgroups" (parse-headers
(article-headers (article-headers
(parse-article bs)))))) (parse-article bs))))))
(ngs-dont-exist)) ngs-dont-exist)
(dolist (g ngs) (dolist (ng ngs)
(if (or (not (group-name-conforms? g)) (if (and (group-name-conforms? ng)
(not (group? g))) (group? ng))
(push g ngs-dont-exist))) (progn
(if (zerop (length ngs-dont-exist))
(progn
(dolist (ng ngs)
(let ((a (ensure-date (ensure-mid bs)))) (let ((a (ensure-date (ensure-mid bs))))
(save-article-insist ng (get-next-article-id ng) a (extract-mid a)) (save-article-insist ng (get-next-article-id ng) a (extract-mid a))
(update-last-post-date! (client-username *client*)))) (update-last-post-date! (client-username *client*))))
(values 240 (data "Thank you! Your article has been saved."))) (push ng ngs-dont-exist)))
(values 400 (data "Sorry. We did not post your article to any newsgroup because " (if (zerop (- (length ngs) (length ngs-dont-exist)))
"the " (word-plural (length ngs-dont-exist) "newsgroup") " " (values 400 "Sorry. There was not a single valid newsgroup specified.")
(str:join ", " (sort ngs-dont-exist #'string<)) (values 240 (data "Thank you! Your article has been saved."
" just " (word-plural (length ngs-dont-exist) "doesn't") " exist."))))) (when ngs-dont-exist
(data " However, the groups "
(defun newsgroups-header->list (s) (str:join ", " (sort ngs-dont-exist #'string<))
(mapcar #'(lambda (n) (str:trim (string-downcase n))) (str:split "," s))) " just don't exist.")))))))
@ %def post @ %def post
XXX: notice we parse the article again to extract information from it XXX: Oh, have a look at that. We accept the article even if there are
that we need during [[post]]. That's not only a waste of invalid groups. We should not do that. A user might only want to
time---because we already did that---, but it makes [[post]] a lot post at all if his message is cross-posted to a few groups. A user
less generic. Perhaps [[conforms?]] should return a data structure might easily mistype a group name. The Right Thing here is more
that contains all that [[post]] needs. Then [[post]] consumes that likely to stop posting completely with an error message telling the
and saves the article more easily. That's a better idea. I think user to either remove the invalid group of type it up properly.
[[post]] should not even use variables such as [[*client*]]. The
username to which to update the last-seen date should be included in
the data structure.
<<Command post>>= <<Command post>>=
(defun update-last-post-date! (username) (defun update-last-post-date! (username)
@ -2007,7 +1935,7 @@ never comes from the NNTP protocol because there's is always a {\tt
(defun add-crlf-between (ls-of-ls) (defun add-crlf-between (ls-of-ls)
;; Add \r\n to each ``line''. Returns List-of Byte. ;; Add \r\n to each ``line''. Returns List-of Byte.
(mapcar #'(lambda (ls) (append ls (list 13 10))) ls-of-ls)) (mapcar (lambda (ls) (append ls (list 13 10))) ls-of-ls))
(defun string->bytes (s) (defun string->bytes (s)
(map 'list #'char-code s)) (map 'list #'char-code s))
@ -2115,36 +2043,25 @@ all or it has been discussed with the community beforehand.
:data (format nil "group ~a created" g))))))))))) :data (format nil "group ~a created" g)))))))))))
(defun group-name-conforms? (g) (defun group-name-conforms? (g)
(conforms-to? g "<<Form of newsgroup names>>")) (let ((okay? (cl-ppcre:scan-to-strings "<<Form of newsgroup names>>" g)))
(if okay?
(values t nil)
(values nil "must match <<Form of newsgroup names>>"))))
@ %def CREATE-GROUP group-name-conforms? @ %def CREATE-GROUP group-name-conforms?
\subsection{{\tt REPL}} \subsection{{\tt REPL}}
\lp\ is totally {\em hackable}. Users can say {\tt repl} to have \lp\ is totally {\em hackable}. Users can say {\tt repl} to have
complete control over their \lxxp\ process. complete control over their \lxxp\ process. XXX: we should implement
an option [[--disable-repl]] so that REPL hacking is turned off.
(This would mean your users are not true hackers.)
<<Command repl>>= <<Command repl>>=
(defun cmd-repl (r) (defun cmd-repl (r)
(if *enable-nntp-repl* (with-auth
(with-auth (repl r)))
(repl r))
(make-response
:code 400
:data "The REPL has been *explicitly* disabled by the sysadmin. :("
:request r)))
@ @
If your users are not the hacker-type, you can disable the NNTP REPL
with the command-line option [[--disable-nntp-repl]]. We decide not
to hide the command in the list of commands given by saying {\tt HELP}
to \lp\ because this way users are advertised about the commands that
exist---they could be having fun, but their sysadmin doesn't think
they're skilled enough.
<<Global variable that decides whether to enable the NNTP REPL>>=
(defparameter *enable-nntp-repl* t)
@
\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
@ -2179,7 +2096,7 @@ invitations {\em et cetera} are published there.
:headers (data :headers (data
(add-crlf-between (add-crlf-between
(mapcar (mapcar
#'(lambda (p) (data (format nil "~a: ~a" (car p) (cdr p)))) (lambda (p) (data (format nil "~a: ~a" (car p) (cdr p))))
`(("from" . "Loop") `(("from" . "Loop")
("subject" . ,subject) ("subject" . ,subject)
("newsgroups" . "local.control.news"))))) ("newsgroups" . "local.control.news")))))
@ -2254,7 +2171,7 @@ working. Since we work with only one, we pretty much never need to
specify anything. specify anything.
<<Reference to the database>>= <<Reference to the database>>=
(defparameter *default-database* nil) (defvar *default-database* nil)
@ %def *default-database* @ %def *default-database*
<<How to create and connect to the index>>= <<How to create and connect to the index>>=
@ -2426,15 +2343,16 @@ Index built.
\section{Deletion and locking of inactive accounts}\label{sec:inactive-users} \section{Deletion and locking of inactive accounts}\label{sec:inactive-users}
We now implement some of the \hyperref[principles]{principles} exposed XXX: remove this paragraph from here; present the program first and
earlier on page~\pageref{principles}. The program then talk about it. In [[remove-friend]], note that [[username]] is
@<<cron-remove-inactive-users.lisp@>> would be run by {\tt cron} every the account name and [[friend]] is the name of the account being
day (at midnight, say). It checks all accounts that are inactive and removed. Notice as well that we only know who invited the person
either locks them (to be deleted later) or deletes them {\em for after we can get a hold of the account in [[accounts.lisp]]. This
good}. If you want to keep accounts forever, just don't run the means we must scan each account to delete an account---we can't delete
program. XXX: our idea is to also delete {\em for good} all accounts an account and still leave the account as someone's friend.
that are locked (by the same period of time), but we have not yet done
that. The program [[cron-remove-inactive-users.lisp]] can be executed every
day at midnight, say.
<<cron-remove-inactive-users.lisp>>= <<cron-remove-inactive-users.lisp>>=
<<Quicklisp loading preamble>> <<Quicklisp loading preamble>>
@ -2446,12 +2364,15 @@ that.
(write-accounts!) (write-accounts!)
@ %def cron-remove-inactive-users.lisp @ %def cron-remove-inactive-users.lisp
The entire program is really [[remove-inactive-users!]]. In [[remove-account]], we probably should use [[delete-if]] as well on
the list of friends since it is effectively what we are doing there
with [[setf]]. %% TODO
<<How to remove inactive users>>= <<How to remove inactive users>>=
(defun remove-inactive-users! () (defun remove-inactive-users! ()
(loop for u in *accounts* do (loop for u in *accounts* do
(let ((username (account-username u))) (let ((username (account-username u)))
(format t "Username: ~a~%" username)
(cond ((and (not (locked? username)) (cond ((and (not (locked? username))
(inactive-from-never-logged-in? username)) (inactive-from-never-logged-in? username))
(post-notification (post-notification
@ -2477,10 +2398,10 @@ The entire program is really [[remove-inactive-users!]].
To remove an account, we need to first remove the username (to be To remove an account, we need to first remove the username (to be
removed) from anyone's list of friends. So, this involves scanning removed) from anyone's list of friends. So, this involves scanning
the entire list of accounts. Also, notice that delete ``may modify the entire list of accounts. Also, notice that delete ``may modify
{\em sequence}''. More important is to understand that we really must {\em sequence}''. More importantly is to understand tha we really
{\tt setf} the return, otherwise we might find the deletion did not must {\tt setf} the return, otherwise we might find the deletion did
take effect---for example, when deleting the first element of a list. not take effect---for example, when deleting the first element of a
(XXX: this deserves a better explanation, but if you know how linked list. (This deserves a better explanation, but if you know how linked
lists are implemented in C, say, then you're likely well aware of how lists are implemented in C, say, then you're likely well aware of how
it works.) it works.)
@ -2504,9 +2425,7 @@ Accounts that do not have a creation date up until today---Tue Sep 17
21:37:18 ESAST 2024---will have its creation dates migrated to the 21:37:18 ESAST 2024---will have its creation dates migrated to the
\Lp\ epoch, which is January 1st 2024, the exact month in which \Lp\ epoch, which is January 1st 2024, the exact month in which
\Lp\ was written. But notice that this migration is done only once. \Lp\ was written. But notice that this migration is done only once.
New system administrators of \Lp\ will never need to run this. (We do New system administrators of \Lp\ will never need to run this.
not remove this set of source code chunks because they serve as an
example of how to a migration like that.)
<<How to migrate accounts without a creation date>>= <<How to migrate accounts without a creation date>>=
(defun loop-epoch () (defun loop-epoch ()
@ -2603,22 +2522,13 @@ example, when we need to access the group database, we use
`(let ((*default-pathname-defaults* (truename ,dir))) `(let ((*default-pathname-defaults* (truename ,dir)))
(uiop:with-current-directory (,dir) (uiop:with-current-directory (,dir)
,@body))) ,@body)))
@
Notice that we set [[*default-pathname-defaults*]] and we set the
process' current working directory. That's not necessary because Lisp
always uses [[*default-pathname-defaults*]] and does not care about
the current working directory. We did this out of the fact that we
used to invoke [[renameat2]] through the [[cffi]], but we don't use it
any more.
<<Macros>>=
(defmacro in-groups (&rest body) `(in-dir "groups/" ,@body)) (defmacro in-groups (&rest body) `(in-dir "groups/" ,@body))
(defun in-group-lambda (g fn) (in-dir g (funcall fn))) (defun in-group-lambda (g fn) (in-dir g (funcall fn)))
(defmacro in-group (g &rest body) (defmacro in-group (g &rest body)
`(in-group-lambda ,(fmt "groups/~a/" g) #'(lambda () ,@body))) `(in-group-lambda ,(fmt "groups/~a/" g) (lambda () ,@body)))
(defmacro with-group (g r &rest body) (defmacro with-group (g r &rest body)
(let ((g-var (gensym)) (let ((g-var (gensym))
@ -2665,24 +2575,10 @@ stands for ``Universal Character Set'' and I speculate the number 2
means 2 bytes. So our conversion is just removing the first byte. means 2 bytes. So our conversion is just removing the first byte.
<<Little procedures>>= <<Little procedures>>=
(defun conforms-to? (s re &optional error-msg)
"Does string S conform to regular expression RE?"
(let ((okay? (cl-ppcre:scan-to-strings re s)))
(if okay?
(values t nil)
(values nil (or error-msg (fmt "must match ~a" re))))))
(defun print/finish (&rest args) (defun print/finish (&rest args)
(apply #'format (cons t args)) (apply #'format (cons t args))
(finish-output)) (finish-output))
(defun word-plural (n word)
(let ((table '(("doesn't" . "don't")
("newsgroup" . "newsgroups"))))
(let ((w (assoc word table :test #'string=)))
(when (not w) (error "word not found"))
(if (< n 2) (car w) (cdr w)))))
(defun plural (v suffix) (defun plural (v suffix)
(if (> v 1) suffix "")) (if (> v 1) suffix ""))
@ -2748,76 +2644,30 @@ means 2 bytes. So our conversion is just removing the first byte.
I studied the minimum to be able to add these tests as we comprehend I studied the minimum to be able to add these tests as we comprehend
better the direction in which we're going. A test system is essential better the direction in which we're going. A test system is essential
for us to trust we can move forward without breaking important past for us to trust we can move forward without breaking past decisions in
decisions in the code. XXX: we should not include these tests in the code.
production code as we are doing right now. Divert them to a tests
package or something that makes more sense. To run the tests, you
need to invoke [[lisp-unit:run-tests]]. I believe that invoking it in
a script will make all tests run. Oh, that should be included the
binary, too, so that we can always test an archived project. That's a
good idea. Make it a clingon option on the command line. Of course,
by including tests in the executable, we should isolate all the tests
here. I know how---just define a test package and isolate it all in
it. Packages are for namespace isolation. Lisp's ``systems'' are the
type of packaging meant for loading. Lisp's ``packages'' are merely
namespace isolation. Our test package should use \lp's package, so
that we can use any of the procedures under testing. When we define a
variable, it will be defined in the test package, not in \lp's, but
when we are in \lp's package, we see no names of the test package.
It's simple and a good solution.
When testing, it's important for us not to clutter a production
system---we will want to run tests on production systems. So what we
need to do is to wrap any file system modification to a certain other
directory in which \lp's tests will find the groups directory in
place. Making that happen is as simple as changing
[[*default-pathname-defaults*]].
<<Test procedures>>= <<Test procedures>>=
(setq lisp-unit:*print-failures* t) (setq lisp-unit:*print-failures* t)
(define-test first-test-of-the-west
(assert-equal 0 0))
(define-test requests
(let ((nil-request-1 (make-request))
(nil-request-2 (make-request :said " ")))
(assert-true (request=? nil-request-1 (parse-request nil-request-1)))
(assert-true (request=? nil-request-2 (parse-request nil-request-2)))
(assert-true (request=? nil-request-1 nil-request-2))))
(define-test commands
(let ((ht (make-hash-table))
(c1 (make-command :fn #'cons :verb 'c1 :description "cons cmd"))
(c2 (make-command :fn #'list :verb 'c2 :description "list cmd")))))
(define-test dispatching (define-test dispatching
(assert-true (equalp (empty-response) (dispatch (make-request))))) (assert-true (equalp (empty-response) (dispatch (empty-request)))))
(defun unix->nntp (s)
"I substitute \n for \r\n"
(str:replace-all (fmt "~a" #\linefeed) (crlf-string) s))
(defvar a-post (unix->nntp "From: root
Message-id: <pwtdldytefplntosymvo@loop>
Subject: test
Newsgroups: local.test
Quickest test of the West.
"))
(defvar a-bad-post (unix->nntp "From: root
Message-id: <pwtdldytefplntosymvp@loop>
Subject: a bad test
Newsgroups: local.test, bad.newsgroup
A bad test from the biggest mouth of the south.
"))
(define-test post-wrong-newsgroup
(multiple-value-bind (code msg) (post (string->bytes a-bad-post))
(declare (ignore msg))
(assert-true (equal code 400))))
(define-test post-okay
(read-accounts!)
(connect-index! "test.db")
(create-index!)
(setq *client* (make-client :username "ROOT" :auth? 'yes))
(multiple-value-bind (code msg) (post (string->bytes a-post))
(declare (ignore msg))
(assert-true (equal code 240)))
(clsql:disconnect))
@ %def @ %def
XXX: we got a problem with test [[post-okay]]. We're getting an
execution error, but we can't see any error message. The posting is
taking place---here in the REPL at least.
\section{How to produce the binary executable} \section{How to produce the binary executable}
Just say {\tt make loop} to your shell. Just say {\tt make loop} to your shell.
@ -2902,7 +2752,7 @@ something to think about.
(defpackage #:loop (defpackage #:loop
(:use :common-lisp :local-time) (:use :common-lisp :local-time)
(:import-from :lisp-unit define-test assert-true) (:import-from :lisp-unit define-test)
(:import-from :org.shirakumo.filesystem-utils (:import-from :org.shirakumo.filesystem-utils
directory-p list-directories list-files) directory-p list-directories list-files)
(:import-from :sb-sys interactive-interrupt) (:import-from :sb-sys interactive-interrupt)
@ -2942,14 +2792,17 @@ something to think about.
<<Command users>> <<Command users>>
<<Command dd>> <<Command dd>>
<<Command repl>> <<Command repl>>
<<Broadcasting>> <<Broadcasting>>
<<Command-line parsing>> <<Command-line parsing>>
<<Main loop>> <<Main loop>>
<<Test procedures>> <<Test procedures>>
@ %def @ %def
<<Global variables>>= <<Global variables>>=
(defparameter *debug* nil)
<<Representation of accounts>> <<Representation of accounts>>
<<Representation of a client>> <<Representation of a client>>
<<Representation of requests and responses>> <<Representation of requests and responses>>
@ -2957,7 +2810,7 @@ something to think about.
<<Representation of commands>> <<Representation of commands>>
<<Representation of articles>> <<Representation of articles>>
<<Definition of maximum allowed inactive periods>> <<Definition of maximum allowed inactive periods>>
<<Global variable that decides whether to enable the NNTP REPL>> (defvar *debug* nil)
@ @
On which packages do we depend? On which packages do we depend?
@ -2976,87 +2829,7 @@ The \lp\ system definition:
:description "<<Description>>" :description "<<Description>>"
:depends-on (<<List of packages to be loaded>>) :depends-on (<<List of packages to be loaded>>)
:components ((:file "loop"))) :components ((:file "loop")))
@ @ %def :loop loop.asd
\section{Other source code}
The shell script {\tt format-def} is invoked whenever we build any
lisp source code. That's to format the source code a bit better for
readers that will be reading it directly. It is not what we do. We
read the documentation in PDF format and we work on the NOWEB file
{\tt loop.nw}. But we know that potential readers will not do the
same and will hack {\tt loop.lisp} directly. Paying respect to these
readers, we try to format Lisp source code as best as possible. So we
do two things: first, we produce the final source code in an order
that should produce no warnings during compilation; second, we make
sure there's one and only one blank line between procedure or macro
definition. We don't add a blank line between global variables.
The following shell script does the job. The first {\tt sed} program
finds our definitions of interest and inserts a new blank line before
the definition. Such action makes function definitions separated by
two blank lines in some cases. We then remove the excess with the
second program. Notice we need the {\tt -E} option because we're
using the {\tt |} metacharacter.
The second program find a blank line as its first step. Then we say
{\tt N} to expand the pattern space to include the next line. Then we
delete the {\em first} blank line and not the second---that's what the
{\tt D} command does. This strategy is explained by Dale Dougherty
and Arnold Robbins in ``sed \& awk'' second edition, pages 112--114.
<<format-def>>=
#!/bin/sh
usage()
{
printf 'usage: %s [file.lisp]\n' $0
exit 1
}
sed -E '/^\(defun |\(defmacro /{
i\
}' "$@" | \
sed '/^[ \t]*$/{
N
/^[ \t]*\n$/D
}'
@
When we make a new release of \lp, we like to name its version as the
tip of the source code repository. We get the information usually
with a command line such as
%
\begin{verbatim}
$ git log --oneline | head -1 | awk '{print $1}'
52663d1
\end{verbatim}
%
To include this version string in the executable, we need to make it
part of the source code. We get help from {\tt sed} once again. As
the usage explains, we invoke it as {\tt ./make-release 52663d1
loop.nw}. The script then rewrites {\tt loop.nw} with the string in
the body of the chunk @<<Version@>>. The {\tt sed} program is
straightforward: locate the chunk definition, move down a line, change
that line and that's all.
<<make-release>>=
#!/bin/sh
usage()
{
printf 'usage: %s tag file\n' $0
exit 1
}
test $# -lt 2 && usage
tag="$1"; shift
sed "/<<Version>>=/ {
n;
c\\
$tag
}" "$@"
@
\section*{Index of chunks} \section*{Index of chunks}
\nowebchunks \nowebchunks

View file

@ -4,12 +4,11 @@ usage()
printf 'usage: %s tag file\n' $0 printf 'usage: %s tag file\n' $0
exit 1 exit 1
} }
test $# '<' 2 && usage
test $# -lt 2 && usage tag="$1"
shift
tag="$1"; shift sed "/<<Version>>=/ {
sed "/a89e088=/ {
n; n;
c\\ c\\
$tag $tag
}" "$@" }" $*