;;;; -*- coding: utf-8; mode: lisp -*-
;;;; Copyright 2007, Matthias Andreas Benkard.
;;;------------------------------------------------------------------------
;;; This file is part of The Mulkblog Project.
;;;
;;; The Mulkblog Project is free software. You can redistribute it and/or
;;; modify it under the terms of the Affero General Public License as
;;; published by Affero, Inc.; either version 1 of the License, or
;;; (at your option) any later version.
;;;
;;; The Mulkblog Project is distributed in the hope that it will be
;;; useful, but WITHOUT ANY WARRANTY; without even the implied warranty
;;; of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; Affero General Public License for more details.
;;;
;;; You should have received a copy of the Affero General Public
;;; License in the COPYING file that comes with The Mulkblog Project; if
;;; not, write to Affero, Inc., 510 Third Street, Suite 225, San
;;; Francisco, CA 94107 USA.
;;;------------------------------------------------------------------------
(in-package #:mulk.journal)
(defun debug-log (thing)
(format *error-output* "~S " thing)
thing)
(defun keywordify (thing)
(if (null thing)
thing
(intern (etypecase thing
(string (string-upcase thing))
(symbol (symbol-name thing)))
'#:keyword)))
(defun make-uuid ()
"Generate a version 4 UUID according to RFC 4122, section 4.4."
(format nil "~(~8,'0x-~4,'0x-~4,'0x-~2,'0x~2,'0x-~12,'0x~)"
(random #x100000000) ;; time_low
(random #x10000) ;; time_mid
(logior #b0100000000000000
(logand #b0000111111111111
(random #x10000))) ;; time_hi_and_version
(logior #b10000000
(logand #b00111111
(random #x100))) ;; clock_seq_hi_and_reserved
(random #x100) ;; clock_seq_low
(random #x1000000000000))) ;; node
(defun fixup-markdown-output (markup)
;; No, cl-markdown is certainly not perfect.
(reduce #'(lambda (string thing)
(destructuring-bind (regex . replacement)
thing
(ppcre:regex-replace-all regex
string
replacement)))
(load-time-value
(mapcar #'(lambda (thing)
(destructuring-bind (regex . replacement)
thing
(cons (ppcre:create-scanner regex) replacement)))
'(;; "... ." -> "...."
("([^>]*?>) \\." . "\\1.")
;; " bla" -> " bla"
("(]*?>) " . " \\1"))))
:initial-value markup))
(defun name-of-day (day-of-week)
(case day-of-week
(0 "Montag")
(1 "Dienstag")
(2 "Mittwoch")
(3 "Donnerstag")
(4 "Freitag")
(5 "Samstag")
(6 "Sonntag")))
(defmacro literal-casequal (thing &body clauses)
(let ((thing-sym (gensym "case-object"))
(map-sym (gensym "dispatch-table"))
(map-sym2 (gensym "load-time-dispatch-table")))
`(let ((,thing-sym ,thing)
(,map-sym (load-time-value
(let ((,map-sym2 (make-hash-table :test 'equal)))
,@(mapcar (lambda (clause)
`(setf (gethash ,(first clause) ,map-sym2)
,(position clause clauses)))
clauses)
,map-sym2))))
(case (gethash ,thing-sym ,map-sym)
,@(mapcar (lambda (clause)
`(,(position clause clauses) ,(second clause)))
clauses)))))
(defun %real-format-date (destination date-control-string universal-time
&optional (time-zone nil time-zone-supplied-p))
"Format DATE according to the description given by DATE-FORMAT-STRING.
Recognised format directives are: %day, %mon, %yr, %day-of-week, %zone,
%@day-of-week (name of day), %sec, %min, %hr, %daylight-p.
Note that you can mix FORMAT and FORMAT-DATE painlessly by calling them
after another in any arbitrary order."
(format
destination "~A"
(with-output-to-string (out)
(multiple-value-bind (sec min hr day mon yr day-of-week daylight-p zone)
(if time-zone-supplied-p
(decode-universal-time universal-time time-zone)
(decode-universal-time universal-time))
(let ((last-match-end 0))
(ppcre:do-matches (start end "%[^%]*%" date-control-string)
(let ((substring (subseq date-control-string (1+ start) (1- end))))
(format out (subseq date-control-string last-match-end start))
(multiple-value-bind (control value)
(literal-casequal substring
("day-of-week" (values "~D" day-of-week))
("@day-of-week" (values "~A"
(name-of-day day-of-week)))
("daylight-p" (values "~A" daylight-p))
("zone" (values "~D" zone))
("day" (values "~D" day))
("mon" (values "~D" mon))
("yr" (values "~D" yr))
("sec" (values "~D" sec))
("min" (values "~D" min))
("hr" (values "~D" hr))
("2day" (values "~2,'0D" day))
("2mon" (values "~2,'0D" mon))
("4yr" (values "~4,'0D" yr))
("2sec" (values "~2,'0D" sec))
("2min" (values "~2,'0D" min))
("2hr" (values "~2,'0D" hr)))
(if control
(format out control value)
(format out "%~A%" substring))
(setq last-match-end end))))
(format out (subseq date-control-string last-match-end)))))))
(defun compute-script-last-modified-date ()
#-clisp (get-universal-time)
#+clisp
(loop for file in (list* *script-filename* ;; journal.cgi
(remove-if-not #'(lambda (p)
(equal "lisp"
(pathname-type p)))
(list-directory *script-dir*)))
maximize (posix:file-stat-mtime (posix:file-stat file))))
(defun read-to-array (stream &key (initial-length 128))
"Read elements from a stream repeatedly until the end of file is
reached and write the results into a newly created array of the same
ELEMENT-TYPE as the stream's."
(do* ((buffer (make-array (list initial-length)
:element-type (stream-element-type stream)
:adjustable t
:fill-pointer t))
(last-unmodified (read-sequence buffer stream)
(read-sequence buffer stream :start old-length))
(read-elements last-unmodified
(- last-unmodified old-length))
(old-length initial-length length)
(length (ceiling (* 1.5 old-length))
(ceiling (* 1.5 old-length))))
((< last-unmodified old-length)
(setf (fill-pointer buffer) last-unmodified)
buffer)
(setf buffer (adjust-array buffer (list length)
:fill-pointer length))))
(defun call-with-result-cache (cache-id fun &key (younger-than nil younger-p))
(let ((cache-file (merge-pathnames (make-pathname :name (format nil
"CACHE-~A"
cache-id))
*cache-dir*)))
(if (and (directory-exists-p *cache-dir*)
(file-exists-p cache-file)
#-clisp nil
#+clisp (or (not younger-p)
(> (posix:file-stat-mtime (posix:file-stat cache-file))
younger-than)))
(with-open-file (in cache-file
:direction :input
:external-format #+clisp charset:utf-8
#+sbcl :utf-8)
(read-to-array in))
(with-open-file (out (if (directory-exists-p *cache-dir*)
cache-file
#p"/dev/null")
:direction :output
:if-exists :supersede
:external-format #+clisp charset:utf-8
#+sbcl :utf-8)
(let ((string (funcall fun)))
(princ string out)
string)))))
(defun format-date (destination date-control-string universal-time
&optional (time-zone nil time-zone-supplied-p))
(with-result-cache ((format nil "date-format-~D-~A-~A"
universal-time date-control-string time-zone)
:younger-than (compute-script-last-modified-date))
(apply #'%real-format-date
destination date-control-string universal-time
(and time-zone-supplied-p (list time-zone)))))
(defun single-object (list &optional (errorp t))
(assert (null (cdr list)))
(when errorp
(assert (not (null list))))
(first list))
(defun akismet-login ()
(drakma:http-request "http://rest.akismet.com/1.1/verify-key"
:protocol :http/1.0
:method :post
:user-agent "Mulk Journal/0.0.1"
:parameters `(("key" . ,*wordpress-key*)
("blog" . "http://matthias.benkard.de/journal"))))
(defun akismet-check-comment (comment referrer)
#.(locally-enable-sql-reader-syntax)
(prog1
(with-slots (submitter-user-agent submitter-ip body author website entry-id)
comment
(drakma:http-request (format nil "http://~A.rest.akismet.com/1.1/comment-check" *wordpress-key*)
:protocol :http/1.0
:method :post
:user-agent "Mulk Journal/0.0.1"
:parameters `(("blog" . "http://matthias.benkard.de/journal")
("user_ip" . ,submitter-ip)
("user_agent" . ,submitter-user-agent)
,@(when referrer
`(("referrer" . ,referrer)))
("permalink" . ,(link-to :view
:post-id (first
(select [id]
:from [journal-entry]
:where [= [id] entry-id]
:flatp t))))
("comment_type" . "comment")
("comment_author" . ,author)
("comment_author_url" . ,website)
("comment_content" . ,body))))
#.(restore-sql-reader-syntax-state)))
(defun detect-spam (comment &key referrer)
(ignore-errors
(akismet-login)
(string= "true" (akismet-check-comment comment referrer))))
(defun mail (address subject body)
#-clisp
(cerror "Can't send e-mail on this Lisp implementation.")
#+clisp
(let ((sendmail-stdin (ext:run-program "sendmail"
:arguments (list address)
:wait t
:output nil
:input :stream)))
(format sendmail-stdin "~&To: ~A~
~&MIME-Version: 1.0~
~&Content-type: text/plain; charset=utf-8~
~&Content-transfer-encoding: quoted-printable~
~&Subject: =?utf-8?Q?~A?=~
~&~%~
~&~A"
address
(quote-printable subject nil)
(quote-printable body t))
(close sendmail-stdin)))
(defun char-octets (string-designator)
#-clisp
(error "Can't convert strings to byte vectors on this Lisp implementation.")
#+clisp
(ext:convert-string-to-bytes (string string-designator)
custom:*default-file-encoding*))
(let ((printable-chars
;; This list is incomplete, which shouldn't hurt.
;;
;; Note that the list is designed to be compatible with both the
;; Quoted-Printable and Q encodings. Even though
;; Quoted-Printable itself specifies #\_, #\?, #\= and #\Space as
;; representing themselves, they may not be left unencoded here
;; because of this. On the other hand, #\_ cannot be assumed to
;; encode #\Space, either, because this is only specified by Q,
;; not by Quoted-Printable.
(cons #\Newline
(coerce "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,.-!~"
'list))))
(defun quote-printable (text line-breaks-p)
(with-output-to-string (out)
(let ((i 0))
(loop for char across text
do (if (member char printable-chars)
(princ char out)
(loop for byte across (char-octets char)
do (format out "=~2,'0X" byte)))
when (and line-breaks-p (= i 73))
do (progn
(princ #\= out)
(terpri out)
(setq i 0))
else
do (incf i))))))
(defun mail-comment (address comment entry)
(mail address
(format nil "[Kommentar] ~A" (title-of entry))
(format nil "~&Kommentar von: ~A~
~&Nummer: ~A~
~&E-Mail: ~A~
~&Website: ~A~
~&IP-Adresse: ~A~
~&Webbrowser: ~A~
~&Als Spam erkannt: ~A~
~&~%~
~&~A"
(author-of comment)
(id-of comment)
(email-of comment)
(website-of comment)
(submitter-ip comment)
(submitter-user-agent comment)
(spamp comment)
(body-of comment))))
(defun mail-trackback (address comment entry)
(mail address
(format nil "[Trackback] ~A" (title-of entry))
(format nil "~&Trackback von: ~A~
~&Nummer: ~A~
~&Titel: ~A~
~&Web-Adresse: ~A~
~&IP-Adresse: ~A~
~&Webbrowser: ~A~
~&Als Spam erkannt: ~A~
~&~%~
~&~A"
(blog-name-of comment)
(id-of comment)
(title-of comment)
(url-of comment)
(submitter-ip comment)
(submitter-user-agent comment)
(spamp comment)
(excerpt-of comment))))
(defun mail-pingback (address pingback entry)
(declare (type journal-pingback pingback))
(mail address
(format nil "[Pingback] ~A" (title-of entry))
(format nil "~&Pingback von: ~A~
~&Nummer: ~A~
~&IP-Adresse: ~A~
~&Webbrowser: ~A~
~&Als Spam erkannt: ~A"
(url-of pingback)
(id-of pingback)
(submitter-ip pingback)
(submitter-user-agent pingback)
(spamp pingback))))
(defun revalidate-cache-or-die (content-type)
(when (eq *mode* :http)
#+clisp
(when *if-modified-since*
(let* ((date-recognisers (mapcar #'cybertiggyr-time::make-fmt-recognizer '("%A, %d-%B-%y %H:%M:%S GMT" "%A, %d %B %Y %H:%M:%S GMT" "%A %B %d %H:%M:%S %Y")))
(requested-time (cybertiggyr-time:parse-time *if-modified-since* date-recognisers))
(modified-time (compute-journal-last-modified-date)))
(when (and (integerp requested-time)
(integerp modified-time)
(>= requested-time modified-time))
(http-add-header "Status" "304 Not Modified")
(http-send-headers content-type)
(ext:quit 0))))
#-clisp
nil))
(defun call-with-wsse-authentication (thunk)
(let ((params (list)))
(ppcre:do-scans (start end regstarts regends "(\\w+)=\"(.*?)\"" *wsse*)
(setf params (acons (subseq *wsse* (elt regstarts 0) (elt regends 0))
(subseq *wsse* (elt regstarts 1) (elt regends 1))
params)))
(let* ((timestamp (cdr (assoc "created" params :test 'equalp)))
(time (and (stringp timestamp)
(cybertiggyr-time:parse-time timestamp
(list (cybertiggyr-time::make-fmt-recognizer "%Y-%m-%dT%H:%M:%SZ")))))
(encoded-nonce (cdr (assoc "nonce" params :test 'equalp)))
(nonce (and encoded-nonce
(cl-base64:base64-string-to-string encoded-nonce)))
(user (cdr (assoc "username" params :test 'equalp)))
(their-digest (cdr (assoc "passworddigest" params :test 'equalp)))
(our-digest (and (stringp nonce)
(stringp timestamp)
(stringp *wsse-key*)
(cl-base64:usb8-array-to-base64-string
(ironclad:digest-sequence
:sha1
(map '(simple-array (unsigned-byte 8)) #'char-code (format nil "~A~A~A" nonce timestamp *wsse-key*)))))))
(declare (ignore user))
(if (and (stringp our-digest)
(stringp their-digest)
(numberp time)
(string= their-digest our-digest)
(<= (abs (- (get-universal-time) time)) (* 5 60)))
(funcall thunk)
(progn
(http-add-header "Status" "401 Unauthorized")
(http-add-header "WWW-Authenticate" "WSSE realm=\"Mulk Journal\", profile=\"UsernameToken\"")
(http-add-header "X-Authentication-Message"
(if (and (stringp their-digest)
(stringp our-digest)
(string= their-digest our-digest))
"Time stamp too old."
"Wrong user name or password."))
(debug-log "Authentication failed. Their WSSE info was: ")
(debug-log *wsse*)
(http-send-headers)
#+clisp (ext:quit 0))))))
(defmacro with-wsse-authentication (() &body body)
`(call-with-wsse-authentication (lambda () ,@body)))