about summary refs log tree commit diff
path: root/web/panettone
diff options
context:
space:
mode:
Diffstat (limited to 'web/panettone')
-rw-r--r--web/panettone/.envrc1
-rw-r--r--web/panettone/.gitignore1
-rw-r--r--web/panettone/OWNERS3
-rw-r--r--web/panettone/default.nix55
-rw-r--r--web/panettone/docker-compose.yml11
-rw-r--r--web/panettone/panettone.asd6
-rw-r--r--web/panettone/shell.nix15
-rw-r--r--web/panettone/src/.gitignore2
-rw-r--r--web/panettone/src/authentication.lisp115
-rw-r--r--web/panettone/src/css.lisp223
-rw-r--r--web/panettone/src/email.lisp49
-rw-r--r--web/panettone/src/inline-markdown.lisp127
-rw-r--r--web/panettone/src/irc.lisp35
-rw-r--r--web/panettone/src/model.lisp420
-rw-r--r--web/panettone/src/packages.lisp87
-rw-r--r--web/panettone/src/panettone.lisp650
-rw-r--r--web/panettone/src/util.lisp15
-rw-r--r--web/panettone/test/inline-markdown_test.lisp54
-rw-r--r--web/panettone/test/irc_test.lisp5
-rw-r--r--web/panettone/test/model_test.lisp13
-rw-r--r--web/panettone/test/package.lisp3
-rw-r--r--web/panettone/test/util_test.lisp9
22 files changed, 1899 insertions, 0 deletions
diff --git a/web/panettone/.envrc b/web/panettone/.envrc
new file mode 100644
index 000000000000..be81feddb1a5
--- /dev/null
+++ b/web/panettone/.envrc
@@ -0,0 +1 @@
+eval "$(lorri direnv)"
\ No newline at end of file
diff --git a/web/panettone/.gitignore b/web/panettone/.gitignore
new file mode 100644
index 000000000000..be303db03207
--- /dev/null
+++ b/web/panettone/.gitignore
@@ -0,0 +1 @@
+*.fasl
diff --git a/web/panettone/OWNERS b/web/panettone/OWNERS
new file mode 100644
index 000000000000..5ad475b1c7e3
--- /dev/null
+++ b/web/panettone/OWNERS
@@ -0,0 +1,3 @@
+aspen
+tazjin
+sterni
diff --git a/web/panettone/default.nix b/web/panettone/default.nix
new file mode 100644
index 000000000000..283f83499487
--- /dev/null
+++ b/web/panettone/default.nix
@@ -0,0 +1,55 @@
+{ depot, ... }:
+
+depot.nix.buildLisp.program {
+  name = "panettone";
+
+  deps = with depot.third_party.lisp; [
+    bordeaux-threads
+    cl-json
+    cl-ppcre
+    cl-smtp
+    cl-who
+    defclass-std
+    drakma
+    easy-routes
+    hunchentoot
+    lass
+    local-time
+    postmodern
+
+    depot.lisp.klatre
+  ];
+
+  srcs = [
+    ./panettone.asd
+    ./src/packages.lisp
+    ./src/util.lisp
+    ./src/css.lisp
+    ./src/email.lisp
+    ./src/inline-markdown.lisp
+    ./src/authentication.lisp
+    ./src/model.lisp
+    ./src/irc.lisp
+    ./src/panettone.lisp
+  ];
+
+  tests = {
+    deps = with depot.third_party.lisp; [
+      fiveam
+    ];
+
+    srcs = [
+      ./test/package.lisp
+      ./test/model_test.lisp
+      ./test/inline-markdown_test.lisp
+      ./test/util_test.lisp
+    ];
+
+    expression = "(fiveam:run!)";
+  };
+
+  brokenOn = [
+    "ecl" # dependencies use dynamic cffi
+    "ccl" # The value NIL is not of the expected type STRING. when loading model.lisp
+  ];
+}
diff --git a/web/panettone/docker-compose.yml b/web/panettone/docker-compose.yml
new file mode 100644
index 000000000000..84723667e6b9
--- /dev/null
+++ b/web/panettone/docker-compose.yml
@@ -0,0 +1,11 @@
+version: '3.4'
+services:
+  postgres:
+    image: postgres:11
+    restart: always
+    environment:
+      POSTGRES_USER: panettone
+      POSTGRES_PASSWORD: password
+      POSTGRES_DB: panettone
+    ports:
+      - 127.0.0.1:5432:5432
diff --git a/web/panettone/panettone.asd b/web/panettone/panettone.asd
new file mode 100644
index 000000000000..4d44e50fd3e8
--- /dev/null
+++ b/web/panettone/panettone.asd
@@ -0,0 +1,6 @@
+(asdf:defsystem "panettone"
+  :description "A simple issue tracker"
+  :serial t
+  :components ((:file "packages")
+               (:file "css")
+               (:file "pannetone")))
diff --git a/web/panettone/shell.nix b/web/panettone/shell.nix
new file mode 100644
index 000000000000..483481ca9aa1
--- /dev/null
+++ b/web/panettone/shell.nix
@@ -0,0 +1,15 @@
+{ depot ? import ../.. { } }:
+
+with depot.third_party.nixpkgs;
+
+mkShell {
+  buildInputs = [
+    docker-compose
+    postgresql
+  ];
+
+  PGPASSWORD = "password";
+  PGHOST = "localhost";
+  PGUSER = "panettone";
+  PGDATABASE = "panettone";
+}
diff --git a/web/panettone/src/.gitignore b/web/panettone/src/.gitignore
new file mode 100644
index 000000000000..10aa5440d832
--- /dev/null
+++ b/web/panettone/src/.gitignore
@@ -0,0 +1,2 @@
+# I use this as the out-link for my local lisp dev env
+sbcl
diff --git a/web/panettone/src/authentication.lisp b/web/panettone/src/authentication.lisp
new file mode 100644
index 000000000000..3ce07aa8d78d
--- /dev/null
+++ b/web/panettone/src/authentication.lisp
@@ -0,0 +1,115 @@
+(in-package :panettone.authentication)
+
+(defvar *user* nil
+  "The currently logged-in user")
+
+(defclass/std user ()
+  ((cn dn mail displayname :type string)))
+
+;; Migrating user authentication to OAuth2 necessitates some temporary
+;; workarounds while other parts of the panettone code are being
+;; amended appropriately.
+
+(defun fake-dn (username)
+  "Users are no longer read directly from LDAP, but everything in
+panettone is keyed on the DNs. This function constructs matching
+'fake' DNs."
+  (format nil "cn=~A,ou=users,dc=tvl,dc=fyi" username))
+
+(defun find-user-by-dn (dn)
+  "Previously this function looked up users in LDAP based on their DN,
+however panettone now does not have direct access to a user database.
+
+For most cases only the username is needed, which can be parsed out of
+the user, however email addresses are temporarily not available."
+  (let ((username
+          (car (uiop:split-string (subseq dn 3) :separator '(#\,)))))
+    (make-instance
+     'user
+     :dn dn
+     :cn username
+     :displayname username
+     :mail nil)))
+
+;; Implementation of standard OAuth2 authorisation flow.
+
+(defvar *oauth2-auth-endpoint* nil)
+(defvar *oauth2-token-endpoint* nil)
+(defvar *oauth2-client-id* nil)
+(defvar *oauth2-client-secret* nil)
+
+(defvar *oauth2-redirect-uri*
+  (or (uiop:getenv "OAUTH2_REDIRECT_URI")
+      "https://b.tvl.fyi/auth"))
+
+(defun initialise-oauth2 ()
+  "Initialise all settings needed for OAuth2"
+
+  (setq *oauth2-auth-endpoint*
+        (or (uiop:getenv "OAUTH2_AUTH_ENDPOINT")
+            "https://auth.tvl.fyi/auth/realms/TVL/protocol/openid-connect/auth"))
+
+  (setq *oauth2-token-endpoint*
+        (or (uiop:getenv "OAUTH2_TOKEN_ENDPOINT")
+            "https://auth.tvl.fyi/auth/realms/TVL/protocol/openid-connect/token"))
+
+  (setq *oauth2-client-id*
+        (or (uiop:getenv "OAUTH2_CLIENT_ID")
+            "panettone"))
+
+  (setq *oauth2-client-secret*
+        (or (uiop:getenv "OAUTH2_CLIENT_SECRET")
+            (error "OAUTH2_CLIENT_SECRET must be set!"))))
+
+(defun auth-url ()
+  (format nil "~A?response_type=code&client_id=~A&redirect_uri=~A"
+          *oauth2-auth-endpoint*
+          (drakma:url-encode *oauth2-client-id* :utf-8)
+          (drakma:url-encode *oauth2-redirect-uri* :utf-8)))
+
+(defun claims-to-user (claims)
+  (let ((username (cdr (assoc :preferred--username claims)))
+        (email (cdr (assoc :email claims))))
+    (make-instance
+     'user
+     :dn (fake-dn username)
+     :cn username
+     :mail email
+     ;; TODO(tazjin): Figure out actual displayName mapping in tokens.
+     :displayname username)))
+
+(defun fetch-token (code)
+  "Fetches the access token on completion of user authentication through
+the OAuth2 endpoint and returns the resulting user object."
+
+  (multiple-value-bind (body status)
+      (drakma:http-request *oauth2-token-endpoint*
+                           :method :post
+                           :parameters `(("grant_type" . "authorization_code")
+                                         ("client_id" . ,*oauth2-client-id*)
+                                         ("client_secret" . ,*oauth2-client-secret*)
+                                         ("redirect_uri" . ,*oauth2-redirect-uri*)
+                                         ("code" . ,code))
+                           :external-format-out :utf-8
+                           :want-stream t)
+    (if (/= status 200)
+        (error "Authentication failed: ~A (~A)~%"
+               (alexandria:read-stream-content-into-string body)
+               status)
+
+        ;; Returned JWT contains username and email, we can populate
+        ;; all fields from that.
+        (progn
+          (setf (flexi-streams:flexi-stream-external-format body) :utf-8)
+          (let* ((response (cl-json:decode-json body))
+                 (access-token (cdr (assoc :access--token response)))
+                 (payload (cadr (uiop:split-string access-token :separator '(#\.))))
+                 (claims (cl-json:decode-json-from-string
+                          (base64:base64-string-to-string
+                           ;; The JWT spec specifies that base64 strings
+                           ;; embedded in jwts are *not* padded, but the common
+                           ;; lisp base64 library doesn't know how to deal with
+                           ;; that - we need to add those extra padding
+                           ;; characters here.
+                           (panettone.util:add-missing-base64-padding payload)))))
+            (claims-to-user claims))))))
diff --git a/web/panettone/src/css.lisp b/web/panettone/src/css.lisp
new file mode 100644
index 000000000000..aa753cb50fc5
--- /dev/null
+++ b/web/panettone/src/css.lisp
@@ -0,0 +1,223 @@
+(in-package :panettone.css)
+(declaim (optimize (safety 3)))
+
+(defun button (selector)
+  `((,selector
+     :background-color "var(--success)"
+     :padding "0.5rem"
+     :text-decoration "none"
+     :transition "box-shadow" "0.15s" "ease-in-out")
+
+    ((:and ,selector :hover)
+     :box-shadow "0.25rem" "0.25rem" "0" "0" "rgba(0,0,0,0.08)")
+
+    ((:and ,selector (:or :active :focus))
+     :box-shadow "0.1rem" "0.1rem" "0" "0" "rgba(0,0,0,0.05)"
+     :outline "none"
+     :border "none")))
+
+(defparameter markdown-styles
+  `((blockquote
+     :border-left "5px" "solid" "var(--light)"-gray
+     :padding-left "1rem"
+     :margin-left "0rem")
+    (pre
+     :overflow-x "auto")))
+
+(defparameter issue-list-styles
+  `((.issue-list
+     :list-style-type "none"
+     :padding-left 0
+
+     (.issue-subject
+      :font-weight "bold")
+
+     (li
+      :padding-bottom "1rem")
+
+     ((li + li)
+      :border-top "1px" "solid" "var(--gray)")
+
+     (a
+      :text-decoration "none"
+      :display "block")
+
+     ((:and a :hover)
+      :outline "none"
+
+      (.issue-subject
+       :color "var(--primary)")))
+
+    (.comment-count
+     :color "var(--gray)")))
+
+(defparameter issue-history-styles
+  `((.issue-history
+     :list-style "none"
+     :border-top "1px" "solid" "var(--gray)"
+     :padding-top "1rem"
+     :padding-left "2rem"
+
+     (.comment-info
+      :color "var(--gray)"
+      :margin 0
+      :padding-top "1rem"
+
+      (a :text-decoration "none")
+      ((:and a :hover)
+       :text-decoration "underline"))
+
+     ((:or .comment .event)
+      :padding-top "1rem"
+      :padding-bottom "1rem"
+      :border-bottom "1px" "solid" "var(--gray)"
+
+      (p :margin 0))
+
+     ((:and (:or .comment .event) :target)
+      :border-color "var(--primary)"
+      :border-bottom-width "3px")
+
+     (.event
+      :color "var(--gray)"))))
+
+(defparameter form-styles
+  `(((:or (:and input (:or (:= type "text")
+                           (:= type "password")))
+          textarea)
+     :width "100%"
+     :padding "0.5rem"
+     :outline "none"
+     :border-top "none"
+     :border-left "none"
+     :border-right "none"
+     :border-bottom "1px" "solid" "var(--gray)"
+     :margin-bottom "1rem")
+
+    (textarea
+     :resize "vertical")
+
+    ((:and input (:= type "submit"))
+     :-webkit-appearance "none"
+     :border "none"
+     :cursor "pointer"
+     :font-size "1rem")
+
+    ,@(button '(:and input (:= type "submit")))
+
+    (.form-link
+     ((:and input (:= type "submit"))
+      :background-color "initial"
+      :color "inherit"
+      :padding 0
+      :text-decoration "underline")
+
+     ((:and input (:= type "submit")
+            (:or :hover :active :focus))
+      :box-shadow 0 0 0 0))
+
+    (.form-group
+     :margin-top "1rem")
+
+    (label.checkbox
+     :cursor "pointer")))
+
+(defparameter issue-styles
+  `((.issue-info
+     :display "flex"
+     :justify-content "space-between"
+     :align-items "center"
+
+     ,@(button '.edit-issue)
+
+     (.created-by-at
+      :flex 1)
+
+     (.edit-issue
+      :background-color "var(--light)"-gray
+      :flex 0
+      :margin-right "0.5rem")
+
+     (.close-issue
+      :background-color "var(--failure)"))))
+
+(defparameter styles
+  `(,@form-styles
+    ,@issue-list-styles
+    ,@issue-styles
+    ,@issue-history-styles
+    ,@markdown-styles
+
+    (body
+     :font-family "sans-serif"
+     :color "var(--text)"
+     :background "var(--bg)"
+     :--text "rgb(24, 24, 24)"
+     :--bg "white"
+     :--gray "#8D8D8D"
+     :--primary "rgb(106, 154, 255)"
+     :--primary-light "rgb(150, 166, 200)"
+     :--success "rgb(168, 249, 166)"
+     :--failure "rgb(247, 167, 167)"
+     :--light-gray "#EEE")
+
+    (:media "(prefers-color-scheme: dark)"
+      (body
+        :--text "rgb(240, 240, 240)"
+        :--bg "black"
+        :--gray "#8D8D8D"
+        :--primary "rgb(106, 154, 255)"
+        :--primary-light "rgb(150, 166, 200)"
+        :--success "rgb(14, 130, 11)"
+        :--failure "rgb(124, 14, 14)"
+        :--light-gray "#222"))
+
+    (a :color "inherit")
+
+    (.content
+     :max-width "800px"
+     :margin "0 auto")
+
+    (header
+     :display "flex"
+     :align-items "center"
+     :border-bottom "1px" "solid" "var(--text)"
+     :margin-bottom "1rem"
+
+     (h1
+      :padding 0
+      :flex 1)
+
+     (.issue-number
+      :color "var(--gray)"
+      :font-size "1.5rem"))
+
+    (nav
+     :display "flex"
+     :color "var(--gray)"
+     :justify-content "space-between"
+
+     (.nav-group
+      :display "flex"
+      (>*
+       :margin-left "0.5rem")))
+
+    (footer
+     :border-top "1px" "solid" "var(--gray)"
+     :padding-top "1rem"
+     :margin-top "1rem"
+     :color "var(--gray)")
+
+    ,@(button '.new-issue)
+
+    (.alert
+     :padding "0.5rem"
+     :margin-bottom "1rem"
+     :background-color "var(--failure)")
+
+    (.login-form
+     :max-width "300px"
+     :margin "0 auto")
+
+    (.created-by-at
+     :color "var(--gray)")))
diff --git a/web/panettone/src/email.lisp b/web/panettone/src/email.lisp
new file mode 100644
index 000000000000..66ea299858d3
--- /dev/null
+++ b/web/panettone/src/email.lisp
@@ -0,0 +1,49 @@
+(in-package :panettone.email)
+(declaim (optimize (safety 3)))
+
+(defvar *smtp-server* "localhost"
+  "The host for SMTP connections")
+
+(defvar *smtp-server-port* 2525
+  "The port for SMTP connections")
+
+(defvar *notification-from* "tvlbot@tazj.in"
+  "The email address to send email notifications from")
+
+(defvar *notification-from-display-name* "Panettone"
+  "The Display Name to use when sending email notifications")
+
+(defvar *notification-subject-prefix* "[panettone]"
+  "String to prefix all email subjects with")
+
+(defun send-email-notification (&key to subject message)
+  "Sends an email to TO with the given SUBJECT and MESSAGE, using the current
+values of `*smtp-server*', `*smtp-server-port*' and `*email-notification-from*'"
+  (let ((subject (if *notification-subject-prefix*
+                     (format nil "~A ~A"
+                             *notification-subject-prefix*
+                             subject)
+                     subject)))
+    (cl-smtp:send-email
+     *smtp-server*
+     *notification-from*
+     to
+     subject
+     message
+     :port *smtp-server-port*
+     :display-name *notification-from-display-name*)))
+
+(defun user-has-email-notifications-enabled-p (dn)
+  "Returns T if the user with the given DN has enabled email notifications"
+  (enable-email-notifications-p (settings-for-user dn)))
+
+(defun notify-user (dn &key subject message)
+  "Sends an email notification to the user with DN with the given SUBJECT and
+  MESSAGE, iff that user has not disabled email notifications"
+  (when (user-has-email-notifications-enabled-p dn)
+    (when-let* ((user (find-user-by-dn dn))
+                (user-mail (mail user)))
+      (send-email-notification
+       :to user-mail
+       :subject subject
+       :message message))))
diff --git a/web/panettone/src/inline-markdown.lisp b/web/panettone/src/inline-markdown.lisp
new file mode 100644
index 000000000000..e49293519bf4
--- /dev/null
+++ b/web/panettone/src/inline-markdown.lisp
@@ -0,0 +1,127 @@
+(in-package :panettone.inline-markdown)
+(declaim (optimize (safety 3)))
+
+(define-constant +inline-markup-types+
+  '(("~~" :del)
+    ("*"  :em)
+    ("`"  :code))
+  :test #'equal)
+
+(defun next-token (mkdn &optional (escaped nil))
+  "Parses and returns the next token from the beginning of
+  an inline markdown string which is not altered. The resulting
+  tokens are either :normal (normal text), :special (syntactically
+  significant) or :escaped (escaped using \\). If the string is
+  empty, a pseudo-token named :endofinput is returned. Return value
+  is a list where the first element is the token type, the second
+  the token content and optionally the third the markup type."
+  ; special tokens are syntactically significant characters
+  ; or strings for our inline markdown subset. “normal” tokens
+  ; the strings in between
+  (let* ((special-toks #.'(cons (list "\\" :escape) +inline-markup-types+))
+         (toks (loop
+                 for tok in special-toks
+                 for pos = (search (car tok) mkdn)
+                 when pos collect (cons tok pos)))
+         (next-tok
+           (unless (null toks)
+             (reduce (lambda (a b) (if (< (cdr a) (cdr b)) a b)) toks))))
+    (cond
+      ; end of input
+      ((= (length mkdn) 0) (list :endofinput ""))
+      ; no special tokens, just return entire string
+      ((null next-tok) (list :normal mkdn))
+      ; special token, but not at the beginning of the string
+      ; so we return everything until the special token as
+      ; a string
+      ((> (cdr next-tok) 0) (list :normal (subseq mkdn 0 (cdr next-tok))))
+      ; \ at the beginning of the string: we get the next
+      ; token and mark it as escaped unless we are already
+      ; escaping in which case we just return the backslash
+      ; as a special token
+      ((eq (cadr (car next-tok)) :escape)
+       (if escaped
+         (list :special "\\")
+         (list :escaped
+               (next-token (subseq mkdn 1) t))))
+      ; any other special token at the beginning of the string
+      ; here we also pass the markup type as a third list element
+      ; to prevent unnecessesary lookups
+      (t (list :special
+               (subseq mkdn 0 (length (car (car next-tok))))
+               (cadr (car next-tok)))))))
+
+(defun token-length (tok-type tok-str)
+  "Returns the string length consumed by a call
+  to next-token returning the given token type and string."
+  (check-type tok-type symbol)
+  (if (eq tok-type :escaped)
+    ; backslash + length of escaped token
+    (progn
+      (check-type tok-str list)
+      (1+ (token-length (car tok-str) (cadr tok-str))))
+    (progn
+      (check-type tok-str string)
+      (length tok-str))))
+
+(defun write-tag (tag pos &optional (target *standard-output*))
+  "Wrapper around who:convert-tag-to-string-list to
+  only output a single :opening or :closing tag."
+  (check-type tag symbol)
+  (check-type pos symbol)
+  (let
+    ((index
+       (cond
+         ((eq pos :opening) 0)
+         ((eq pos :closing) 3)
+         (t (error 'simple-type-error)))))
+    (dolist
+      (tag-part (subseq
+                  (who:convert-tag-to-string-list tag nil nil nil)
+                  index (+ index 3)))
+      (write-string tag-part target))))
+
+(defun render-inline-markdown (s &optional (target *standard-output*) (in :normal))
+  "Render inline markdown, a subset of markdown safe to render
+  inside inline elements. The resulting html is directly written
+  to a specified stream or *standard-output* to integrate well
+  with cl-who."
+  (check-type s string)
+  (check-type target stream)
+  (loop
+    for (tok-type tok-str tok-markup) = (next-token s)
+    do (setq s (subseq s (token-length tok-type tok-str)))
+    when (eq tok-type :endofinput)
+    return ""
+    when (eq tok-type :normal)
+    do (write-string (who:escape-string tok-str) target)
+    when (eq tok-type :escaped)
+    do (progn
+         ; if normal tokens are escaped we treat the \ as if it were \\
+         ;
+         ; TODO(sterni): maybe also use the :normal behavior in :code except for #\`.
+         (when (eq (car tok-str) :normal)
+           (write-char #\\ target))
+         (write-string (who:escape-string (cadr tok-str)) target))
+    when (eq tok-type :special)
+    do (cond
+         ; we are on the outer level and encounter a special token:
+         ; render surrounding tags and call ourselves to render
+         ; inner content.
+         ((eq in :normal)
+          (progn
+            (write-tag tok-markup :opening target)
+            (setq s (render-inline-markdown s target tok-markup))
+            (write-tag tok-markup :closing target)))
+         ; we are on the inner level and encounter the token that initiated
+         ; our markup again, meaning we need to return to the outer level.
+         ; we return the remaining string to be consumed.
+         ((eq in tok-markup) (return s))
+         ; remaining case: we are on the inner level and encounter different markup.
+
+         ; we don't support nested markup for simplicity reasons, so instead we
+         ; just render any nested markdown tokens as if they were escaped. This
+         ; only eliminates the slight use case for nesting :em inside :del, but
+         ; shouldn't be too bad. As a side effect this is the precise behavior
+         ; we want for :code.
+         (t (write-string (who:escape-string tok-str) target)))))
diff --git a/web/panettone/src/irc.lisp b/web/panettone/src/irc.lisp
new file mode 100644
index 000000000000..2ab72a2e391e
--- /dev/null
+++ b/web/panettone/src/irc.lisp
@@ -0,0 +1,35 @@
+;;;; Using irccat to send IRC notifications
+
+(in-package :panettone.irc)
+
+(defun noping (s)
+  (format nil "~A~A~A"
+          (char s 0)
+          #\ZERO_WIDTH_SPACE
+          (subseq s 1)))
+
+(defun get-irccat-config ()
+  "Reads the IRCCATHOST and IRCCATPORT environment variables, and returns them
+as two values"
+  (destructuring-bind (host port)
+      (mapcar #'uiop:getenvp '("IRCCATHOST" "IRCCATPORT"))
+    (if (and host port)
+        (values host (parse-integer port))
+        (values "localhost" 4722))))
+
+(defun send-irc-notification (body &key channel)
+  "Sends BODY to the IRC channel CHANNEL (starting with #),
+if an IRCCat server is configured (using the IRCCATHOST and IRCCATPORT
+environment variables).
+May signal a condition if sending fails."
+  (multiple-value-bind (irchost ircport) (get-irccat-config)
+    (when irchost
+      (let ((socket (socket-connect irchost ircport)))
+        (unwind-protect
+             (progn
+               (format (socket-stream socket) "~@[~A ~]~A~A~%"
+                       channel
+                       #\ZERO_WIDTH_SPACE
+                       body)
+               (finish-output (socket-stream socket)))
+          (ignore-errors (socket-close socket)))))))
diff --git a/web/panettone/src/model.lisp b/web/panettone/src/model.lisp
new file mode 100644
index 000000000000..c54a0ae474bf
--- /dev/null
+++ b/web/panettone/src/model.lisp
@@ -0,0 +1,420 @@
+(in-package :panettone.model)
+(declaim (optimize (safety 3)))
+
+(defvar *pg-spec* nil
+  "Connection spec for use with the with-connection macro. Needs to be
+initialised at launch time.")
+
+(defun make-pg-spec ()
+  "Construct the Postgres connection spec from the environment."
+  (list (or (uiop:getenvp "PGDATABASE") "panettone")
+        (or (uiop:getenvp "PGUSER") "panettone")
+        (or (uiop:getenvp "PGPASSWORD") "password")
+        (or (uiop:getenvp "PGHOST") "localhost")
+
+        :port (or (integer-env "PGPORT") 5432)
+        :application-name "panettone"
+        :pooled-p t))
+
+(defun prepare-db-connections ()
+  "Initialises the connection spec used for all Postgres connections."
+  (setq *pg-spec* (make-pg-spec)))
+
+;;;
+;;; Schema
+;;;
+
+(defclass user-settings ()
+  ((user-dn :col-type string :initarg :user-dn :accessor user-dn)
+   (enable-email-notifications
+    :col-type boolean
+    :initarg :enable-email-notifications
+    :accessor enable-email-notifications-p
+    :initform t
+    :col-default t))
+  (:metaclass dao-class)
+  (:keys user-dn)
+  (:table-name user_settings)
+  (:documentation
+   "Panettone settings for an individual user DN"))
+
+(deftable (user-settings "user_settings")
+  (!dao-def))
+
+(defun settings-for-user (dn)
+  "Retrieve the settings for the user with the given DN, creating a new row in
+  the database if not yet present"
+  (or
+   (car
+    (query-dao
+     'user-settings
+     (:select '* :from 'user-settings :where (:= 'user-dn dn))))
+   (insert-dao (make-instance 'user-settings :user-dn dn))))
+
+(defun update-user-settings (settings &rest attrs)
+  "Update the fields of the settings for USER with the given ATTRS, which is a
+  plist of slot and value"
+  (check-type settings user-settings)
+  (when-let ((set-fields
+              (iter
+                (for slot in '(enable-email-notifications))
+                (for new-value = (getf attrs slot))
+                (appending
+                 (progn
+                   (setf (slot-value settings slot) new-value)
+                   (list slot new-value))))))
+    (execute
+     (sql-compile
+      `(:update user-settings
+        :set ,@set-fields
+        :where (:= user-dn ,(user-dn settings)))))))
+
+
+(define-constant +issue-statuses+ '(:open :closed)
+  :test #'equal)
+
+(deftype issue-status ()
+  "Type specifier for the status of an `issue'"
+  (cons 'member +issue-statuses+))
+
+(defun ddl/create-issue-status ()
+  "Issue DDL to create the `issue-status' type, if it doesn't exist"
+  (unless (query (:select (:exists (:select 1
+                                    :from 'pg_type
+                                    :where (:= 'typname "issue_status"))))
+                 :single)
+    (query (sql-compile
+            `(:create-enum issue-status ,+issue-statuses+)))))
+
+(defclass has-created-at ()
+  ((created-at :col-type timestamp
+               :col-default (local-time:now)
+               :initarg :created-at
+               :accessor created-at))
+  (:metaclass dao-class))
+
+(defun created-at->timestamp (object)
+  (assert (slot-exists-p object 'created-at))
+  (unless (or (not (slot-boundp object 'created-at))
+              (typep (slot-value object 'created-at) 'local-time:timestamp))
+    (setf (slot-value object 'created-at)
+          (local-time:universal-to-timestamp (created-at object)))))
+
+(defmethod initialize-instance :after
+    ((obj has-created-at) &rest initargs &key &allow-other-keys)
+  (declare (ignore initargs))
+  (created-at->timestamp obj))
+
+(defun keyword->str (kw) (string-downcase (symbol-name kw)))
+(defun str->keyword (st) (alexandria:make-keyword (string-upcase st)))
+
+(defclass issue (has-created-at)
+  ((id :col-type serial :initarg :id :accessor id)
+   (subject :col-type string :initarg :subject :accessor subject)
+   (body :col-type string :initarg :body :accessor body :col-default "")
+   (author-dn :col-type string :initarg :author-dn :accessor author-dn)
+   (comments :type list :accessor issue-comments)
+   (events :type list :accessor issue-events)
+   (num-comments :type integer :accessor num-comments)
+   (status :col-type issue_status
+           :initarg :status
+           :accessor status
+           :initform :open
+           :col-default "open"
+           :col-export keyword->str
+           :col-import str->keyword))
+  (:metaclass dao-class)
+  (:keys id)
+  (:table-name issues)
+  (:documentation
+   "Issues are the primary entity in the Panettone database. An issue is
+   reported by a user, has a subject and an optional body, and can be either
+   open or closed"))
+
+(defmethod cl-postgres:to-sql-string ((kw (eql :open)))
+  (cl-postgres:to-sql-string "open"))
+(defmethod cl-postgres:to-sql-string ((kw (eql :closed)))
+  (cl-postgres:to-sql-string "closed"))
+(defmethod cl-postgres:to-sql-string ((ts local-time:timestamp))
+  (cl-postgres:to-sql-string
+   (local-time:timestamp-to-unix ts)))
+
+(defmethod initialize-instance :after
+    ((issue issue) &rest initargs &key &allow-other-keys)
+  (declare (ignore initargs))
+  (unless (symbolp (status issue))
+    (setf (status issue)
+          (intern (string-upcase (status issue))
+                  "KEYWORD"))))
+
+(deftable issue (!dao-def))
+
+(defclass issue-comment (has-created-at)
+  ((id :col-type integer :col-identity t :initarg :id :accessor id)
+   (body :col-type string :initarg :body :accessor body)
+   (author-dn :col-type string :initarg :author-dn :accessor author-dn)
+   (issue-id :col-type integer :initarg :issue-id :accessor :user-id))
+  (:metaclass dao-class)
+  (:keys id)
+  (:table-name issue_comments)
+  (:documentation "Comments on an `issue'"))
+(deftable (issue-comment "issue_comments")
+  (!dao-def)
+  (!foreign 'issues 'issue-id 'id :on-delete :cascade :on-update :cascade))
+
+(defclass issue-event (has-created-at)
+  ((id :col-type integer :col-identity t :initarg :id :accessor id)
+   (issue-id :col-type integer
+             :initarg :issue-id
+             :accessor issue-id)
+   (acting-user-dn :col-type string
+                   :initarg :acting-user-dn
+                   :accessor acting-user-dn)
+   (field :col-type (or string db-null)
+          :initarg :field
+          :accessor field)
+   (previous-value :col-type (or string db-null)
+                   :initarg :previous-value
+                   :accessor previous-value)
+   (new-value :col-type (or string db-null)
+              :initarg :new-value
+              :accessor new-value))
+  (:metaclass dao-class)
+  (:keys id)
+  (:table-name issue_events)
+  (:documentation "Events that have occurred for an issue.
+
+If a field has been changed on an issue, the SYMBOL-NAME of that slot will be in
+FIELD, its previous value will be formatted using ~A into PREVIOUS-VALUE, and
+its new value will be formatted using ~A into NEW-VALUE"))
+
+(deftable (issue-event "issue_events")
+  (!dao-def)
+  (!foreign 'issues 'issue-id 'id :on-delete :cascade :on-update :cascade))
+
+(define-constant +all-tables+
+    '(issue
+      issue-comment
+      issue-event
+      user-settings)
+  :test #'equal)
+
+(defun ddl/create-tables ()
+  "Issue DDL to create all tables, if they don't already exist."
+  (dolist (table +all-tables+)
+    (unless (table-exists-p (dao-table-name table))
+      (create-table table))))
+
+(defun ddl/init ()
+  "Idempotently initialize the full database schema for Panettone"
+  (ddl/create-issue-status)
+  (ddl/create-tables))
+
+;;;
+;;; Querying
+;;;
+
+(define-condition issue-not-found (error)
+  ((id :type integer
+       :initarg :id
+       :reader not-found-id
+       :documentation "ID of the issue that was not found"))
+  (:documentation
+   "Error condition for when an issue requested by ID is not found"))
+
+(defun get-issue (id)
+  "Look up the 'issue with the given ID and return it, or signal a condition of
+type `ISSUE-NOT-FOUND'."
+  (restart-case
+      (or (get-dao 'issue id)
+          (error 'issue-not-found :id id))
+    (different-id (new-id)
+      :report "Use a different issue ID"
+      :interactive (lambda ()
+                     (format t "Enter a new ID: ")
+                     (multiple-value-list (eval (read))))
+      (get-issue new-id))))
+
+(defun issue-exists-p (id)
+  "Returns `T' if an issue with the given ID exists"
+  (query
+   (:select (:exists (:select 1
+                      :from 'issues
+                      :where (:= 'id id))))
+   :single))
+
+(defun list-issues (&key status (with '(:num-comments)))
+  "Return a list of all issues with the given STATUS (or all if nil), ordered by
+  ID descending. If WITH contains `:NUM-COMMENTS' (the default) each issue will
+  have the `num-comments' slot filled with the number of comments on that issue
+  (to avoid N+1 queries)."
+  (let* ((condition (unless (null status)
+                      `(:where (:= status $1))))
+         (select (if (find :num-comments with)
+                     `(:select issues.* (:as (:count issue-comments.id)
+                                             num-comments)
+                               :from issues
+                               :left-join issue-comments
+                               :on (:= issues.id issue-comments.issue-id)
+                               ,@condition
+                               :group-by issues.id)
+                     `(:select * :from issues ,@condition)))
+         (query (sql-compile
+                 `(:order-by ,select (:desc id)))))
+    (with-column-writers ('num_comments 'num-comments)
+      (query-dao 'issue query status))))
+
+(defmethod count-comments ((issue-id integer))
+  "Return the number of comments for the given ISSUE-ID."
+  (query
+   (:select (:count '*)
+    :from 'issue-comments
+    :where (:= 'issue-id issue-id))
+   :single))
+
+(defmethod slot-unbound (cls (issue issue) (slot (eql 'comments)))
+  (declare (ignore cls) (ignore slot))
+  (setf (issue-comments issue) (issue-comments (id issue))))
+
+(defmethod issue-comments ((issue-id integer))
+  "Return a list of all comments with the given ISSUE-ID, sorted oldest first.
+NOTE: This makes a database query, so be wary of N+1 queries"
+  (query-dao
+   'issue-comment
+   (:order-by
+    (:select '*
+     :from 'issue-comments
+     :where (:= 'issue-id issue-id))
+    (:asc 'created-at))))
+
+(defmethod slot-unbound (cls (issue issue) (slot (eql 'events)))
+  (declare (ignore cls) (ignore slot))
+  (setf (issue-events issue) (issue-events (id issue))))
+
+(defmethod issue-events ((issue-id integer))
+  "Return a list of all events with the given ISSUE-ID, sorted oldest first.
+NOTE: This makes a database query, so be wary of N+1 queries"
+  (query-dao
+   'issue-event
+   (:order-by
+    (:select '*
+     :from 'issue-events
+     :where (:= 'issue-id issue-id))
+    (:asc 'created-at))))
+
+;;;
+;;; Writing
+;;;
+
+(defun record-issue-event
+    (issue-id &key
+                field
+                previous-value
+                new-value)
+  "Record in the database that the user identified by `AUTHN:*USER*' updated
+ISSUE-ID, and return the resulting `ISSUE-EVENT'. If no user is currently
+authenticated, warn and no-op"
+  (check-type issue-id (integer))
+  (check-type field (or null symbol))
+  (if authn:*user*
+      (insert-dao
+       (make-instance 'issue-event
+                      :issue-id issue-id
+                      :acting-user-dn (authn:dn authn:*user*)
+                      :field (symbol-name field)
+                      :previous-value (when previous-value
+                                        (format nil "~A" previous-value))
+                      :new-value (when new-value
+                                   (format nil "~A" new-value))))
+      (warn "Performing operation as unauthenticated user")))
+
+(defun create-issue (&rest attrs)
+  "Insert a new issue into the database with the given ATTRS, which should be
+a plist of initforms, and return an instance of `issue'"
+  (insert-dao (apply #'make-instance 'issue attrs)))
+
+(defun delete-issue (issue)
+  (delete-dao issue))
+
+(defun set-issue-status (issue-id status)
+  "Set the status of the issue with the given ISSUE-ID to STATUS in the db. If
+the issue doesn't exist, signals `issue-not-found'"
+  (check-type issue-id integer)
+  (check-type status issue-status)
+  (let ((original-status (query (:select 'status
+                                 :from 'issues
+                                 :where (:= 'id issue-id))
+                                :single)))
+    (when (zerop (execute (:update 'issues
+                           :set 'status (cl-postgres:to-sql-string status)
+                           :where (:= 'id issue-id))))
+      (error 'issue-not-found :id issue-id))
+    (record-issue-event
+     issue-id
+     :field 'status
+     :previous-value (string-upcase original-status)
+     :new-value status)
+    (values)))
+
+(defun update-issue (issue &rest attrs)
+  "Update the fields of ISSUE with the given ATTRS, which is a plist of slot and
+value, and record events for the updates"
+  (let ((set-fields
+          (iter (for slot in '(subject body))
+            (for new-value = (getf attrs slot))
+            (appending
+             (let ((previous-value (slot-value issue slot)))
+               (when (and new-value (not (equalp
+                                          new-value
+                                          previous-value)))
+                 (record-issue-event (id issue)
+                                     :field slot
+                                     :previous-value previous-value
+                                     :new-value new-value)
+                 (setf (slot-value issue slot) new-value)
+                 (list slot new-value)))))))
+    (execute
+     (sql-compile
+      `(:update issues
+        :set ,@set-fields
+        :where (:= id ,(id issue)))))))
+
+(defun create-issue-comment (&rest attrs &key issue-id &allow-other-keys)
+  "Insert a new issue comment into the database with the given ATTRS and
+ISSUE-ID, which should be a plist of initforms, and return an instance of
+`issue-comment'. If no issue exists with `ID' ISSUE-ID, signals
+`issue-not-found'."
+  (unless (issue-exists-p issue-id)
+    (error 'issue-not-found :id issue-id))
+  (insert-dao (apply #'make-instance 'issue-comment :issue-id issue-id attrs)))
+
+(defun issue-commenter-dns (issue-id)
+  "Returns a list of all the dns of users who have commented on ISSUE-ID"
+  (query (:select 'author-dn :distinct
+          :from 'issue-comments
+          :where (:= 'issue-id issue-id))
+         :column))
+
+(defun issue-subscribers (issue-id)
+  "Returns a list of user DNs who should receive notifications for actions taken
+  on ISSUE-ID.
+
+Currently this is implemented as the author of issue plus all the users who have
+commented on the issue, but in the future we likely want to also allow
+explicitly subscribing to / unsubscribing from individual issues."
+  (let ((issue (get-issue issue-id)))
+    (adjoin (author-dn issue)
+            (issue-commenter-dns issue-id)
+            :test #'equal)))
+
+
+(comment
+ (ddl/init)
+ (make-instance 'issue :subject "test")
+ (create-issue :subject "test"
+               :author-dn "cn=grfn,ou=users,dc=tvl,dc=fyi")
+
+ (issue-commenter-dns 1)
+ (issue-subscribers 1)
+
+ )
diff --git a/web/panettone/src/packages.lisp b/web/panettone/src/packages.lisp
new file mode 100644
index 000000000000..4ff4c070f087
--- /dev/null
+++ b/web/panettone/src/packages.lisp
@@ -0,0 +1,87 @@
+(defpackage panettone.util
+  (:use :cl :klatre)
+  (:import-from :alexandria :when-let)
+  (:export :integer-env :add-missing-base64-padding))
+
+(defpackage panettone.css
+  (:use :cl :lass)
+  (:export :styles))
+
+(defpackage panettone.inline-markdown
+  (:use :cl)
+  (:import-from :alexandria :define-constant)
+  (:export :render-inline-markdown))
+
+(defpackage panettone.irc
+  (:nicknames :irc)
+  (:use :cl :usocket)
+  (:export :noping :send-irc-notification))
+
+(defpackage :panettone.authentication
+  (:nicknames :authn)
+  (:use :cl :panettone.util :klatre)
+  (:import-from :defclass-std :defclass/std)
+  (:import-from :alexandria :when-let :with-gensyms)
+  (:export
+   :*user*
+   :auth-url
+   :fetch-token
+   :user :cn :dn :mail :displayname
+   :find-user-by-dn
+   :initialise-oauth2))
+
+(defpackage panettone.model
+  (:nicknames :model)
+  (:use :cl :panettone.util :klatre :postmodern :iterate)
+  (:import-from :alexandria :if-let :when-let :define-constant)
+  (:export
+   :prepare-db-connections
+   :ddl/init
+   :*pg-spec*
+
+   :user-settings
+   :user-dn :enable-email-notifications-p :settings-for-user
+   :update-user-settings :enable-email-notifications
+
+   :issue :issue-comment :issue-event
+   :id :subject :body :author-dn :issue-id :status :created-at :acting-user-dn
+   :field :previous-value :new-value
+
+   :get-issue :issue-exists-p :list-issues :create-issue :set-issue-status
+   :update-issue :delete-issue :issue-not-found :not-found-id
+
+   :issue-events
+
+   :issue-comments :num-comments :create-issue-comment
+   :issue-commenter-dns :issue-subscribers))
+
+(defpackage panettone.email
+  (:nicknames :email)
+  (:use :cl)
+  (:import-from :alexandria :when-let :when-let*)
+  (:import-from :panettone.model
+   :settings-for-user :enable-email-notifications-p)
+  (:import-from :panettone.authentication
+   :find-user-by-dn :mail :displayname)
+  (:export
+   :*smtp-server* :*smtp-server-port* :*notification-from*
+   :*notification-from-display-name* :*notification-subject-prefix*
+   :notify-user :send-email-notification))
+
+(defpackage panettone
+  (:use :cl :klatre :easy-routes :iterate
+        :panettone.util
+        :panettone.authentication
+        :panettone.inline-markdown)
+  (:import-from :defclass-std :defclass/std)
+  (:import-from :alexandria :if-let :when-let :switch :alist-hash-table)
+  (:import-from :cl-ppcre :split)
+  (:import-from :bordeaux-threads :make-thread)
+  (:import-from
+   :panettone.model
+   :id :subject :body :author-dn :issue-id :status :created-at
+   :field :previous-value :new-value :acting-user-dn
+   :*pg-spec*)
+  (:import-from :panettone.irc :send-irc-notification)
+  (:shadow :next)
+  (:export :start-panettone :config :main))
diff --git a/web/panettone/src/panettone.lisp b/web/panettone/src/panettone.lisp
new file mode 100644
index 000000000000..d87ac5ed4653
--- /dev/null
+++ b/web/panettone/src/panettone.lisp
@@ -0,0 +1,650 @@
+(in-package :panettone)
+(declaim (optimize (safety 3)))
+
+(defvar *cheddar-url* "http://localhost:4238")
+
+(defgeneric render-markdown (markdown)
+  (:documentation
+   "Render the argument, or the elements of the argument, as markdown, and return
+   the same structure"))
+
+(defun request-markdown-from-cheddar (input)
+  "Send the CL value INPUT encoded as JSON to cheddar's
+  markdown endpoint and return the decoded response."
+  (let ((s (drakma:http-request
+            (concatenate 'string
+                         *cheddar-url*
+                         "/markdown")
+            :accept "application/json"
+            :method :post
+            :content-type "application/json"
+            :external-format-out :utf-8
+            :content (json:encode-json-to-string input)
+            :want-stream t)))
+    (setf (flexi-streams:flexi-stream-external-format s) :utf-8)
+    (cl-json:decode-json s)))
+
+(defmethod render-markdown ((markdown string))
+  (cdr (assoc :markdown
+              (request-markdown-from-cheddar
+               `((markdown . ,markdown))))))
+
+(defmethod render-markdown ((markdown hash-table))
+  (alist-hash-table
+   (request-markdown-from-cheddar markdown)))
+
+(defun markdownify-comment-bodies (comments)
+  "Convert the bodies of the given list of comments to markdown in-place using
+  Cheddar, and return nothing"
+  (let ((in (make-hash-table))
+        (comment-table (make-hash-table)))
+    (dolist (comment comments)
+      (when (typep comment 'model:issue-comment)
+        (setf (gethash (id comment) in) (body comment))
+        (setf (gethash (id comment) comment-table) comment)))
+    (let ((res (render-markdown in)))
+      (iter (for (comment-id markdown-body) in-hashtable res)
+        (let ((comment-id (parse-integer (symbol-name comment-id))))
+          (setf (slot-value (gethash comment-id comment-table)
+                            'model:body)
+                markdown-body)))))
+  (values))
+
+;;;
+;;; Views
+;;;
+
+(defvar *title* "Panettone")
+
+(eval-when (:compile-toplevel :load-toplevel)
+  (setf (who:html-mode) :html5))
+
+(defun render/nav ()
+  (who:with-html-output (*standard-output*)
+    (:nav
+     (if (find (car (split "\\?" (hunchentoot:request-uri*) :limit 2))
+               (list "/" "/issues/closed")
+               :test #'string=)
+         (who:htm (:span :class "placeholder"))
+         (who:htm (:a :href "/" "All Issues")))
+     (if *user*
+         (who:htm
+          (:div :class "nav-group"
+           (:a :href "/settings" "Settings")
+           (:form :class "form-link log-out"
+                  :method "post"
+                  :action "/logout"
+                  (:input :type "submit" :value "Log Out"))))
+         (who:htm
+          (:a :href
+              (format nil
+                      "/auth?original-uri=~A"
+                      (drakma:url-encode (hunchentoot:request-uri*)
+                                         :utf-8))
+              "Log In"))))))
+
+(defun author (object)
+  (find-user-by-dn (author-dn object)))
+
+(defun displayname-if-known (user)
+  (or (when user (displayname user))
+      "unknown"))
+
+(defmacro render ((&key
+                     (footer t)
+                     (header t))
+                  &body body)
+  `(who:with-html-output-to-string (*standard-output* nil :prologue t)
+     (:html
+      :lang "en"
+      (:head
+       (:title (who:esc *title*))
+       (:link :rel "stylesheet" :type "text/css" :href "/main.css")
+       (:meta :name "viewport"
+              :content "width=device-width,initial-scale=1"))
+      (:body
+       (:div
+        :class "content"
+        (when ,header
+          (who:htm
+           (render/nav)))
+        ,@body
+        (when ,footer
+          (who:htm
+           (:footer
+            (render/nav)))))))))
+
+(defun form-button (&key
+                      class
+                      input-class
+                      href
+                      label
+                      (method "post"))
+  (who:with-html-output (*standard-output*)
+    (:form :class class
+           :method method
+           :action href
+           (:input :type "submit"
+                   :class input-class
+                   :value label))))
+
+(defun render/alert (message)
+  "Render an alert box for MESSAGE, if non-null"
+  (check-type message (or null string))
+  (who:with-html-output (*standard-output*)
+    (when message
+      (who:htm (:div :class "alert" (who:esc message))))))
+
+(defun render/settings ()
+  (let ((settings (model:settings-for-user (dn *user*))))
+    (render ()
+      (:div
+       :class "settings-page"
+       (:header
+        (:h1 "Settings"))
+       (:form
+        :method :post :action "/settings"
+        (:div
+         (:label :class "checkbox"
+          (:input :type "checkbox"
+                  :name "enable-email-notifications"
+                  :id "enable-email-notifications"
+                  :checked (model:enable-email-notifications-p
+                            settings))
+          "Enable Email Notifications"))
+        (:div :class "form-group"
+         (:input :type "submit"
+                 :value "Save Settings")))))))
+
+(defun created-by-at (issue)
+  (check-type issue model:issue)
+  (who:with-html-output (*standard-output*)
+    (:span :class "created-by-at"
+           "Opened by "
+           (:span :class "username"
+                  (who:esc (displayname-if-known
+                             (author issue))))
+           " at "
+           (:span :class "timestamp"
+                  (who:esc
+                   (format-dottime (created-at issue)))))))
+
+(defun render/issue-list (&key issues)
+  (who:with-html-output (*standard-output*)
+    (:ol
+     :class "issue-list"
+     (dolist (issue issues)
+       (let ((issue-id (model:id issue)))
+         (who:htm
+          (:li
+           (:a :href (format nil "/issues/~A" issue-id)
+               (:p
+                (:span :class "issue-subject"
+                       (render-inline-markdown (subject issue))))
+               (:span :class "issue-number"
+                      (who:esc (format nil "#~A" issue-id)))
+               " - "
+               (created-by-at issue)
+               (let ((num-comments (length (model:issue-comments issue))))
+                 (unless (zerop num-comments)
+                   (who:htm
+                    (:span :class "comment-count"
+                           " - "
+                           (who:esc
+                            (format nil "~A comment~:p" num-comments))))))))))))))
+
+(defun render/index (&key issues)
+  (render ()
+    (:header
+     (:h1 "Issues")
+     (when *user*
+       (who:htm
+        (:a
+         :class "new-issue"
+         :href "/issues/new" "New Issue"))))
+    (:main
+     (:div
+      :class "issue-links"
+      (:a :href "/issues/closed" "View closed issues"))
+     (render/issue-list :issues issues))))
+
+(defun render/closed-issues (&key issues)
+  (render ()
+    (:header
+     (:h1 "Closed issues"))
+    (:main
+     (:div
+      :class "issue-links"
+      (:a :href "/" "View open isues"))
+     (render/issue-list :issues issues))))
+
+(defun render/issue-form (&optional issue message)
+  (let ((editing (and issue (id issue))))
+    (render ()
+      (:header
+       (:h1
+        (who:esc
+         (if editing "Edit Issue" "New Issue"))))
+      (:main
+       (render/alert message)
+       (:form :method "post"
+              :action (if editing
+                          (format nil "/issues/~A"
+                                  (id issue))
+                          "/issues")
+              :class "issue-form"
+              (:div
+               (:input :type "text"
+                       :id "subject"
+                       :name "subject"
+                       :placeholder "Subject"
+                       :value (when editing
+                                (who:escape-string
+                                  (subject issue)))))
+
+              (:div
+               (:textarea :name "body"
+                          :placeholder "Description"
+                          :rows 10
+                          (who:esc
+                           (when editing
+                             (body issue)))))
+
+              (:input :type "submit"
+                      :value
+                      (if editing
+                          "Save Issue"
+                          "Create Issue")))))))
+
+(defun render/new-comment (issue-id)
+  (who:with-html-output (*standard-output*)
+    (:form
+     :class "new-comment"
+     :method "post"
+     :action (format nil "/issues/~A/comments" issue-id)
+     (:div
+      (:textarea :name "body"
+                 :placeholder "Leave a comment"
+                 :rows 5))
+     (:input :type "submit"
+             :value "Comment"))))
+
+(defgeneric render/issue-history-item (item))
+
+(defmethod render/issue-history-item ((comment model:issue-comment))
+  (let ((fragment (format nil "comment-~A" (id comment))))
+    (who:with-html-output (*standard-output*)
+      (:li
+       :class "comment"
+       :id fragment
+       (:p (who:str (body comment)))
+       (:p
+        :class "comment-info"
+        (:span :class "username"
+               (who:esc
+                 (displayname-if-known (author comment)))
+               " at "
+               (:a :href (concatenate 'string "#" fragment)
+                   (who:esc (format-dottime (created-at comment))))))))))
+
+(defmethod render/issue-history-item ((event model:issue-event))
+  (let ((user (find-user-by-dn (acting-user-dn event)))
+        (fragment (format nil "event-~A" (id event))))
+    (who:with-html-output (*standard-output*)
+      (:li
+       :class "event"
+       :id fragment
+       (who:esc (displayname-if-known user))
+       (switch ((field event) :test #'string=)
+         ("STATUS"
+          (who:htm
+           (who:esc
+            (switch ((new-value event) :test #'string=)
+              ("OPEN" " reopened ")
+              ("CLOSED" " closed ")))
+           " this issue "))
+         ("BODY" (who:htm " updated the body of this issue"))
+         (t
+          (who:htm
+           " changed the "
+           (who:esc (string-downcase (field event)))
+           " of this issue from \""
+           (who:esc (previous-value event))
+           "\" to \""
+           (who:esc (new-value event))
+           "\"")))
+       " at "
+       (who:esc (format-dottime (created-at event)))))))
+
+(defun render/issue (issue)
+  (check-type issue model:issue)
+  (let ((issue-id (id issue))
+        (issue-status (status issue)))
+    (render ()
+      (:header
+       (:h1 (render-inline-markdown (subject issue)))
+       (:div :class "issue-number"
+             (who:esc (format nil "#~A" issue-id))))
+      (:main
+       (:div
+        :class "issue-info"
+        (created-by-at issue)
+
+        (when *user*
+          (who:htm
+           (when (string= (author-dn issue)
+                          (dn *user*))
+             (who:htm
+              (:a :class "edit-issue"
+                  :href (format nil "/issues/~A/edit"
+                                issue-id)
+                  "Edit")))
+           (form-button
+            :class "set-issue-status"
+            :href (format nil "/issues/~A/~A"
+                          issue-id
+                          (case issue-status
+                            (:open "close")
+                            (:closed "open")))
+            :input-class (case issue-status
+                           (:open "close-issue")
+                           (:closed "open-issue"))
+            :label (case issue-status
+                     (:open "Close")
+                     (:closed "Reopen"))))))
+       (:p (who:str (render-markdown (body issue))))
+       (let* ((comments (model:issue-comments issue))
+              (events (model:issue-events issue))
+              (history (merge 'list
+                              comments
+                              events
+                              #'local-time:timestamp<
+                              :key #'created-at)))
+         (markdownify-comment-bodies comments)
+         (when (or history *user*)
+           (who:htm
+            (:ol
+             :class "issue-history"
+             (dolist (item history)
+               (render/issue-history-item item))
+             (when *user*
+               (render/new-comment (id issue)))))))))))
+
+(defun render/not-found (entity-type)
+  (render ()
+    (:h1 (who:esc entity-type) " Not Found")))
+
+;;;
+;;; HTTP handlers
+;;;
+
+(defun send-email-for-issue
+    (issue-id &key subject (message ""))
+  "Send an email notification to all subscribers to the given issue with the
+given subject an body (in a thread, to avoid blocking)"
+  (let ((current-user *user*))
+    (bordeaux-threads:make-thread
+     (lambda ()
+       (pomo:with-connection *pg-spec*
+         (dolist (user-dn (model:issue-subscribers issue-id))
+           (when (not (equal (dn current-user) user-dn))
+             (email:notify-user
+              user-dn
+              :subject subject
+              :message message))))))))
+
+(defun link-to-issue (issue-id)
+  (format nil "https://b.tvl.fyi/issues/~A" issue-id))
+
+(defun @auth-optional (next)
+  (let ((*user* (hunchentoot:session-value 'user)))
+    (funcall next)))
+
+(defun @auth (next)
+  (if-let ((*user* (hunchentoot:session-value 'user)))
+    (funcall next)
+    (hunchentoot:redirect
+     (format nil "/auth?original-uri=~A"
+             (drakma:url-encode
+              (hunchentoot:request-uri*)
+              :utf-8)))))
+
+(defun @db (next)
+  "Decorator for handlers that use the database, wrapped in a transaction."
+  (pomo:with-connection *pg-spec*
+    (pomo:with-transaction ()
+      (catch
+          ;; 'hunchentoot:handler-done is unexported, but is used by functions
+          ;; like hunchentoot:redirect to nonlocally abort the request handler -
+          ;; this doesn't mean an error occurred, so we need to catch it here to
+          ;; make the transaction still get committed
+          (intern "HANDLER-DONE" "HUNCHENTOOT")
+        (funcall next)))))
+
+(defun @handle-issue-not-found (next)
+  (handler-case (funcall next)
+    (model:issue-not-found (err)
+      (render/not-found
+       (format nil "Issue #~A" (model:not-found-id err))))))
+
+(defroute auth-handler ("/auth" :method :get :decorators (@auth-optional)) ()
+  (if-let ((code (hunchentoot:get-parameter "code")))
+    (let ((user (fetch-token code)))
+      (setf (hunchentoot:session-value 'user) user)
+      (hunchentoot:redirect (or (hunchentoot:session-value 'original-uri) "/")))
+
+    (progn
+      (when-let ((original-uri (hunchentoot:get-parameter "original-uri")))
+        (setf (hunchentoot:session-value 'original-uri) original-uri))
+      (hunchentoot:redirect (authn:auth-url)))))
+
+(defroute logout ("/logout" :method :post) ()
+  (hunchentoot:delete-session-value 'user)
+  (hunchentoot:redirect "/"))
+
+(defroute index ("/" :decorators (@auth-optional @db)) ()
+  (let ((issues (model:list-issues :status :open)))
+    (render/index :issues issues)))
+
+(defroute settings ("/settings" :method :get :decorators (@auth @db)) ()
+  (render/settings))
+
+(defroute save-settings ("/settings" :method :post :decorators (@auth @db))
+    (&post enable-email-notifications)
+  (let ((settings (model:settings-for-user (dn *user*))))
+    (model:update-user-settings
+     settings
+     'model:enable-email-notifications enable-email-notifications)
+    (render/settings)))
+
+(defroute handle-closed-issues
+    ("/issues/closed" :decorators (@auth-optional @db)) ()
+  (let ((issues (model:list-issues :status :closed)))
+    (render/closed-issues :issues issues)))
+
+(defroute new-issue ("/issues/new" :decorators (@auth)) ()
+  (render/issue-form))
+
+(defroute handle-create-issue
+    ("/issues" :method :post :decorators (@auth @db))
+    (&post subject body)
+  (if (string= subject "")
+      (render/issue-form
+       (make-instance 'model:issue :subject subject :body body)
+       "Subject is required")
+      (let ((issue
+              (model:create-issue :subject subject
+                                  :body body
+                                  :author-dn (dn *user*))))
+        (send-irc-notification
+         (format nil
+                 "b/~A: \"~A\" opened by ~A - https://b.tvl.fyi/issues/~A"
+                 (id issue)
+                 subject
+                 (irc:noping (cn *user*))
+                 (id issue))
+         :channel (or (uiop:getenvp "ISSUECHANNEL")
+                      "#tvl"))
+        (hunchentoot:redirect
+         (format nil "/issues/~A" (id issue))))))
+
+(defroute show-issue
+    ("/issues/:id" :decorators (@auth-optional @handle-issue-not-found @db))
+    (&path (id 'integer))
+  (let* ((issue (model:get-issue id))
+         (*title* (format nil "~A | Panettone"
+                          (subject issue))))
+    (render/issue issue)))
+
+(defroute edit-issue
+    ("/issues/:id/edit" :decorators (@auth @handle-issue-not-found @db))
+    (&path (id 'integer))
+  (let* ((issue (model:get-issue id))
+         (*title* "Edit Issue | Panettone"))
+    (render/issue-form issue)))
+
+(defroute update-issue
+    ("/issues/:id" :decorators (@auth @handle-issue-not-found @db)
+                   ;; NOTE: this should be a put, but we're all HTML forms
+                   ;; right now and those don't support PUT
+                   :method :post)
+    (&path (id 'integer) &post subject body)
+  (let ((issue (model:get-issue id)))
+    ;; only the original author can edit an issue
+    (if (string-equal (author-dn issue)
+                      (dn *user*))
+        (progn
+          (model:update-issue issue
+                              'model:subject subject
+                              'model:body body)
+          (hunchentoot:redirect (format nil "/issues/~A" id)))
+        (render/not-found "Issue"))))
+
+(defroute handle-create-comment
+    ("/issues/:id/comments"
+     :decorators (@auth @handle-issue-not-found @db)
+     :method :post)
+    (&path (id 'integer) &post body)
+  (flet ((redirect-to-issue ()
+           (hunchentoot:redirect (format nil "/issues/~A" id))))
+    (cond
+      ((string= body "")
+       (redirect-to-issue))
+      (:else
+       (model:create-issue-comment
+        :issue-id id
+        :body body
+        :author-dn (dn *user*))
+
+       (let ((issue (model:get-issue id)))
+         (send-email-for-issue
+          id
+          :subject (format nil "~A commented on b/~A: \"~A\""
+                           (displayname *user*)
+                           id
+                           (subject issue))
+          :message (format nil "~A~%~%~A"
+                           body
+                           (link-to-issue id))))
+       (redirect-to-issue)))))
+
+(defroute close-issue
+    ("/issues/:id/close" :decorators (@auth @handle-issue-not-found @db)
+                         :method :post)
+    (&path (id 'integer))
+  (model:set-issue-status id :closed)
+  (let ((issue (model:get-issue id)))
+    (send-irc-notification
+     (format nil
+             "b/~A: \"~A\" closed by ~A - ~A"
+             id
+             (subject issue)
+             (irc:noping (cn *user*))
+             (link-to-issue id))
+     :channel (or (uiop:getenvp "ISSUECHANNEL")
+                  "#tvl"))
+    (send-email-for-issue
+     id
+     :subject (format nil "b/~A: \"~A\" closed by ~A"
+                      id
+                      (subject issue)
+                      (displayname *user*))
+     :message (link-to-issue id)))
+  (hunchentoot:redirect (format nil "/issues/~A" id)))
+
+(defroute open-issue
+    ("/issues/:id/open" :decorators (@auth @db)
+                        :method :post)
+    (&path (id 'integer))
+  (model:set-issue-status id :open)
+  (let ((issue (model:get-issue id)))
+    (send-irc-notification
+     (format nil
+             "b/~A: \"~A\" reopened by ~A - ~A"
+             id
+             (subject issue)
+             (irc:noping (cn *user*))
+             (link-to-issue id))
+     :channel (or (uiop:getenvp "ISSUECHANNEL")
+                  "#tvl"))
+    (send-email-for-issue
+     id
+     :subject (format nil "b/~A: \"~A\" reopened by ~A"
+                      id
+                      (subject issue)
+                      (displayname *user*))
+     :message (link-to-issue id)))
+  (hunchentoot:redirect (format nil "/issues/~A" id)))
+
+(defroute styles ("/main.css") ()
+  (setf (hunchentoot:content-type*) "text/css")
+  (apply #'lass:compile-and-write panettone.css:styles))
+
+(defvar *acceptor* nil
+  "Hunchentoot acceptor for Panettone's web server.")
+
+(defun migrate-db ()
+  "Migrate the database to the latest version of the schema"
+  (pomo:with-connection *pg-spec*
+    (model:ddl/init)))
+
+(defun start-panettone (&key port session-secret)
+  (authn:initialise-oauth2)
+  (model:prepare-db-connections)
+  (migrate-db)
+
+  (when session-secret
+    (setq hunchentoot:*session-secret* session-secret))
+
+  (setq hunchentoot:*session-max-time* (* 60 60 24 90))
+
+  (setq *acceptor*
+        (make-instance 'easy-routes:routes-acceptor :port port))
+  (hunchentoot:start *acceptor*))
+
+(defun main ()
+  (let ((port (integer-env "PANETTONE_PORT" :default 6161))
+        (cheddar-url (uiop:getenvp "CHEDDAR_URL"))
+        (session-secret (uiop:getenvp "SESSION_SECRET")))
+    (when cheddar-url (setq *cheddar-url* cheddar-url))
+    (setq hunchentoot:*show-lisp-backtraces-p* nil)
+    (setq hunchentoot:*log-lisp-backtraces-p* nil)
+
+    (start-panettone :port port
+                     :session-secret session-secret)
+
+    (format t "launched panettone on port ~A~%" port)
+
+    (sb-thread:join-thread
+     (find-if (lambda (th)
+                (string= (sb-thread:thread-name th)
+                         (format nil "hunchentoot-listener-*:~A" port)))
+              (sb-thread:list-all-threads)))))
+
+(comment
+ (setq hunchentoot:*catch-errors-p* nil)
+ ;; to setup an ssh tunnel to cheddar+irccat for development:
+ ;; ssh -N -L 4238:localhost:4238 -L 4722:localhost:4722 whitby.tvl.fyi
+ (start-panettone :port 6161
+                  :session-secret "session-secret")
+ )
diff --git a/web/panettone/src/util.lisp b/web/panettone/src/util.lisp
new file mode 100644
index 000000000000..2abedf7b8fbb
--- /dev/null
+++ b/web/panettone/src/util.lisp
@@ -0,0 +1,15 @@
+(in-package :panettone.util)
+
+(defun integer-env (var &key default)
+  (or
+   (when-let ((str (uiop:getenvp var)))
+     (try-parse-integer str))
+   default))
+
+(defun add-missing-base64-padding (s)
+  "Add any missing padding characters to the (un-padded) base64 string `S', such
+that it can be successfully decoded by the `BASE64' package"
+  ;; I apologize
+  (let* ((needed-padding (mod (length s) 4))
+         (pad-chars (if (zerop needed-padding) 0 (- 4 needed-padding))))
+    (format nil "~A~v@{~A~:*~}" s pad-chars "=")))
diff --git a/web/panettone/test/inline-markdown_test.lisp b/web/panettone/test/inline-markdown_test.lisp
new file mode 100644
index 000000000000..bb907504368b
--- /dev/null
+++ b/web/panettone/test/inline-markdown_test.lisp
@@ -0,0 +1,54 @@
+(in-package :panettone.tests)
+(declaim (optimize (safety 3)))
+
+(defmacro inline-markdown-unit-test (name input expected)
+  `(test ,name
+     (is (equal
+           ,expected
+           (with-output-to-string (*standard-output*)
+             (render-inline-markdown ,input))))))
+
+(inline-markdown-unit-test
+  inline-markdown-typical-test
+  "hello *world*, here is ~~no~~ `code`!"
+  "hello <em>world</em>, here is <del>no</del> <code>code</code>!")
+
+(inline-markdown-unit-test
+  inline-markdown-two-emphasize-types-test
+  "*stress* *this*"
+  "<em>stress</em> <em>this</em>")
+
+(inline-markdown-unit-test
+  inline-markdown-html-escaping-test
+  "<tag>öäü"
+  "&lt;tag&gt;&#xF6;&#xE4;&#xFC;")
+
+(inline-markdown-unit-test
+  inline-markdown-nesting-test
+  "`inside code *anything* goes`, but also ~~*here*~~"
+  "<code>inside code *anything* goes</code>, but also <del>*here*</del>")
+
+(inline-markdown-unit-test
+  inline-markdown-escaping-test
+  "A backslash \\\\ shows: \\*, \\` and \\~~"
+  "A backslash \\ shows: *, ` and ~~")
+
+(inline-markdown-unit-test
+  inline-markdown-nested-escaping-test
+  "`prevent \\`code\\` from ending, but never stand alone \\\\`"
+  "<code>prevent `code` from ending, but never stand alone \\</code>")
+
+(inline-markdown-unit-test
+  inline-markdown-escape-normal-tokens-test
+  "\\Normal tokens \\escaped?"
+  "\\Normal tokens \\escaped?")
+
+(inline-markdown-unit-test
+  inline-markdown-no-unclosed-tags-test
+  "A tag, once opened, *must be closed"
+  "A tag, once opened, <em>must be closed</em>")
+
+(inline-markdown-unit-test
+  inline-markdown-unicode-safe
+  "Does Unicode 👨‍👨‍👧‍👦 break \\👩🏾‍🦰 tokenization?"
+  "Does Unicode &#x1F468;&#x200D;&#x1F468;&#x200D;&#x1F467;&#x200D;&#x1F466; break \\&#x1F469;&#x1F3FE;&#x200D;&#x1F9B0; tokenization?")
diff --git a/web/panettone/test/irc_test.lisp b/web/panettone/test/irc_test.lisp
new file mode 100644
index 000000000000..0224836cbc32
--- /dev/null
+++ b/web/panettone/test/irc_test.lisp
@@ -0,0 +1,5 @@
+(in-package :panettone.tests)
+(declaim (optimize (safety 3)))
+
+(test noping-test
+  (is (not (equal "grfn" (panettone.irc:noping "grfn")))))
diff --git a/web/panettone/test/model_test.lisp b/web/panettone/test/model_test.lisp
new file mode 100644
index 000000000000..e4cd78a65a43
--- /dev/null
+++ b/web/panettone/test/model_test.lisp
@@ -0,0 +1,13 @@
+(in-package :panettone.tests)
+(declaim (optimize (safety 3)))
+
+(test initialize-issue-status-test
+  (let ((issue (make-instance 'model:issue :status "open")))
+    (is (eq :open (model:status issue)))))
+
+(test initialize-issue-created-at-test
+  (let* ((time (get-universal-time))
+         (issue (make-instance 'model:issue :created-at time)))
+    (is (local-time:timestamp=
+         (local-time:universal-to-timestamp time)
+         (model:created-at issue)))))
diff --git a/web/panettone/test/package.lisp b/web/panettone/test/package.lisp
new file mode 100644
index 000000000000..d2a2f974208e
--- /dev/null
+++ b/web/panettone/test/package.lisp
@@ -0,0 +1,3 @@
+(defpackage :panettone.tests
+  (:use :cl :klatre :fiveam
+        :panettone.inline-markdown))
diff --git a/web/panettone/test/util_test.lisp b/web/panettone/test/util_test.lisp
new file mode 100644
index 000000000000..ff52d916cb3a
--- /dev/null
+++ b/web/panettone/test/util_test.lisp
@@ -0,0 +1,9 @@
+(in-package :panettone.tests)
+(declaim (optimize (safety 3)))
+
+(test add-missing-base64-padding-test
+  (is (string=
+       "abcdef"
+       (base64:base64-string-to-string
+        (panettone.util:add-missing-base64-padding
+         "YWJjZGVm")))))