;;;; -*- mode: lisp -*- ;;;; ;;;; $Id: base64.lisp,v 1.3 2005/02/07 17:45:41 scaekenberghe Exp $ ;;;; ;;;; This is a minimal standalone Common Lisp HTTP Server ;;;; ;;;; Copyright (C) 2005,2006,2007,2008 Sven Van Caekenberghe, Beta Nine BVBA. ;;;; ;;;; You are granted the rights to distribute and use this software ;;;; as governed by the terms of the Lisp Lesser General Public License ;;;; (http://opensource.franz.com/preamble.html), also known as the LLGPL. (in-package :s-http-server) ;; globals (defvar *http-server-identification* (format nil "S-HTTP-SERVER ~a ~a" (lisp-implementation-type) (lisp-implementation-version)) "Identification string sent as value of the 'Server' HTTP Response Header") (defvar *http-server-port* 1701 "Default port used when creating a new S-HTTP-SERVER") (defclass s-http-server () ((port :accessor get-port :initarg :port :initform *http-server-port*) (name :accessor get-name :initarg :name :initform "s-http-server") (debug-mode :accessor get-debug-mode :initarg :debug-mode :initform t) (server-process :accessor get-server-process :initform nil) (http-connections :accessor get-http-connections :initform nil) (log-stream :accessor get-log-stream :initarg :log-stream :initform nil) (access-log-stream :accessor get-access-log-stream :initarg :access-log-stream :initform nil) (log-lock :accessor get-log-lock :initform (s-sysdeps:make-process-lock "s-http-server-log-lock")) (boot-time :accessor get-boot-time :initform nil) (last-periodic-check :accessor get-last-periodic-check :initform (get-universal-time)) (contexts :accessor get-contexts :initarg :contexts :initform '((s-http-server-handler "/s-http-server" :builtin) (favicon-handler "/favicon.ico" :builtin)))) (:documentation "The object representing a minimal standalone HTTP Server")) (setf (documentation 'get-port 'function) "Get the TCP port used by this S-HTTP-SERVER" (documentation 'get-name 'function) "Get the current name of this S-HTTP-SERVER" (documentation 'get-debug-mode 'function) "Get the current mode of debugging of this S-HTTP-SERVER, t is on, nil is off" (documentation 'get-server-process 'function) "Get the current server process used by this S-HTTP-SERVER, nil if not running" (documentation 'get-client-processes 'function) "Get the list of active (including kept-alive) client processes used by this S-HTTP-SERVER" (documentation 'get-boot-time 'function) "Get the universal time when this S-HTTP-SERVER was last started, nil if not running" (documentation 'get-log-stream 'function) "Get the current stream used by this S-HTTP-SERVER for general logging, nil means no logging" (documentation 'get-access-log-stream 'function) "Get the current stream used by this S-HTTP-SERVER for access logging, nil means no logging" (documentation 'get-contexts 'function) "Get the current list of context bindings used by this S-HTTP-SERVER") #-allegro (setf (documentation '(setf get-port) 'function) "Set the port of this S-HTTP-SERVER (before starting the server)" (documentation '(setf get-name) 'function) "Set the name of this S-HTTP-SERVER" (documentation '(setf get-debug-mode) 'function) "Set the current debugging mode of this S-HTTP-SERVER, t is on, nil is off" (documentation '(setf get-log-stream) 'function) "Set the stream this S-HTTP-SERVER uses for general logging, nil means no logging" (documentation '(setf get-access-log-stream) 'function) "Set the stream this S-HTTP-SERVER uses for access logging, nil means no logging") (defun make-s-http-server (&key (port *http-server-port*) (name "s-http-server") (log-stream *standard-output*) (access-log-stream *standard-output*)) "Create a new object representing an S-HTTP-SERVER" (make-instance 's-http-server :port port :name name :log-stream log-stream :access-log-stream access-log-stream)) (defmethod print-object ((s-http-server s-http-server) stream) (print-unreadable-object (s-http-server stream :type t :identity t) (with-slots (name port server-process) s-http-server (format stream "~s port ~d ~a" name port (if server-process "running" "not running"))))) (defclass http-connection () ((id :accessor get-id :initarg :id :initform -1) (stream :accessor get-stream :initarg :stream :initform nil) (process :accessor get-process :initarg :process :initform nil) (buffer :accessor get-buffer :initform (make-string 4096)) (timestamp :accessor get-timestamp :initform (get-universal-time))) (:documentation "The object representing a kept-alive HTTP connection and handling process")) (defmethod get-age ((http-connection http-connection)) (- (get-universal-time) (get-timestamp http-connection))) (defmethod print-object ((http-connection http-connection) output-stream) (print-unreadable-object (http-connection output-stream :type t :identity t) (with-slots (id stream process timestamp) http-connection (format output-stream "~d ~a ~a ~a" id (if process "running" "not running") (if stream "connected" "not connected") (s-utils:format-iso-gmt-time timestamp))))) (defclass http-request () ((method :accessor get-method :initarg :method :initform :GET) (uri :accessor get-uri :initarg :uri :initform (puri:parse-uri "/")) (http-version :accessor get-http-version :initarg :http-version :initform "HTTP/1.1") (headers :accessor get-headers :initarg :headers :initform '()) (keep-alive :accessor get-keep-alive :initarg :keep-alive :initform (s-sysdeps:multiprocessing-capable-p))) (:documentation "The object representing an HTTP request as being handled by the S-HTTP-SERVER")) (setf (documentation 'get-method 'function) "Get the method (keyword :get :put :post :delete ..) of this HTTP request" (documentation 'get-uri 'function) "Get the URI object of this HTTP request" (documentation 'get-http-version 'function) "Get the HTTP version string of this HTTP request" (documentation 'get-headers 'function) "Get the dotted alist (:keyword . 'value') of request headers of this HTTP request" (documentation 'get-keep-alive 'function) "Is this a keep-alive request (either 1.0 or 1.1)") (defgeneric get-path (http-request) (:method ((http-request http-request)) (puri:uri-path (get-uri http-request))) (:documentation "Get the path of this HTTP request")) (defgeneric get-full-path (http-request) (:method ((http-request http-request)) (puri:render-uri (get-uri http-request) nil)) (:documentation "Get the full path of this HTTP request (including the query)")) (defmethod print-object ((http-request http-request) stream) (print-unreadable-object (http-request stream :type t :identity t) (format stream "~a ~s" (get-method http-request) (get-path http-request)))) ;; generics (defgeneric start-server (server) (:documentation "Start the server")) (defgeneric stop-server (server) (:documentation "Stop the server")) (defgeneric logm (server format-string &rest args) (:documentation "Log a formatted message")) (defgeneric handle-http-server-connection (server http-connection) (:documentation "Handle a new connection request in a new process")) (defgeneric find-handler (server http-request) (:documentation "Given http-request select a handler from server")) (defgeneric register-context-handler (server context-prefix handler-function &key arguments at-end-p do-not-replace-p) ;; optional handler arguments can be specified ;; normally, an existing context binding with the same prefix is overwritten ;; normally, new handlers are pushed at the front of the context bindings list ;; if at-end-p is t, a new binding will be added at the end of the context bindings list ;; if do-not-replace-p is t, an existing binding will not be overwritten and a new one will be created (:documentation "Configure server so that every request starting with context-prefix is sent to handler-function")) (defgeneric unregister-context-handler (server context-prefix &key only-first-p only-last-p) ;; normally, all context bindings matching exactly the specified prefix are deleted ;; if only-first-p is t, only the first context binding with prefix is deleted ;; if only-last-p is t , only the last context binding with prefix is deleted ;; if both only-fast-p and only-last-p are t, an error is signalled (:documentation "Remove any configuration of server for context-prefix")) ;; setup (defmethod start-server ((s-http-server s-http-server)) (stop-server s-http-server) (let ((connection-id 0)) (flet ((connection-handler (client-socket-stream) (s-sysdeps:run-process (format nil "connection-handler-~d" connection-id) #'handle-http-server-connection s-http-server (make-instance 'http-connection :id (incf connection-id) :stream client-socket-stream)) (do-periodic-check s-http-server))) (setf (get-boot-time s-http-server) (get-universal-time)) (when (not (s-sysdeps:multiprocessing-capable-p)) (logm s-http-server "Starting a new single threaded server on port ~d and blocking" (get-port s-http-server))) (let ((process (s-sysdeps:start-standard-server :port (get-port s-http-server) :name (get-name s-http-server) :connection-handler #'connection-handler))) (setf (get-server-process s-http-server) process) (logm s-http-server "Started a new server on port ~d" (get-port s-http-server))))) s-http-server) (defmethod stop-server ((s-http-server s-http-server)) (let ((process (get-server-process s-http-server))) (when process (loop :for http-connection :in (get-http-connections s-http-server) :do (with-slots (process stream) http-connection (when stream (ignore-errors (close stream :abort t)) (setf stream nil)) (when process (s-sysdeps:kill-process process) (setf process nil)))) (setf (get-http-connections s-http-server) nil) (s-sysdeps:kill-process process) (setf (get-server-process s-http-server) nil (get-boot-time s-http-server) nil) (logm s-http-server "Stopped server"))) s-http-server) (defmethod register-context-handler ((s-http-server s-http-server) context-prefix handler-function &key arguments at-end-p do-not-replace-p) (let* ((new-handler-binding `(,handler-function ,context-prefix ,@arguments)) (context-bindings (get-contexts s-http-server)) (existing-binding (find context-prefix context-bindings :key #'second :test #'string=))) (if (or do-not-replace-p (null existing-binding)) (if at-end-p (setf (get-contexts s-http-server) (append context-bindings (list new-handler-binding))) (push new-handler-binding (get-contexts s-http-server))) (loop :for binding :in context-bindings :do (destructuring-bind (function prefix &rest args) binding (declare (ignore function args)) (when (string= prefix context-prefix) (setf (car binding) handler-function (cddr binding) arguments) ;; we assume there is only one binding, more doesn't really make sense (return binding))))))) (defmethod unregister-context-handler ((s-http-server s-http-server) context-prefix &key only-first-p only-last-p) (let ((context-bindings (get-contexts s-http-server))) (cond ((and only-first-p only-last-p) (error "You cannot specify both only-first-p and only-last-p")) (only-first-p (setf (get-contexts s-http-server) (delete context-prefix context-bindings :key #'second :test #'string= :count 1))) (only-last-p (setf (get-contexts s-http-server) (delete context-prefix context-prefix :key #'second :test #'string= :count 1 :from-end t))) (t (setf (get-contexts s-http-server) (delete context-prefix context-bindings :key #'second :test #'string= :count 1 :from-end t)))))) (defmethod logm ((s-http-server s-http-server) format-string &rest args) (let ((out (get-log-stream s-http-server))) (when out (s-sysdeps:with-process-lock ((get-log-lock s-http-server)) (let ((server (string-upcase (get-name s-http-server))) (timestamp (s-utils:format-iso-gmt-time (get-universal-time))) (message (apply #'format nil format-string args))) (format out ";; ~a ~a: ~a~%" server timestamp message)))))) (defparameter +common-log-timestamp-format+ '(#\[ :date #\/ :month-name #\/ :year #\: :hour #\: :minute #\: :second " +0000]")) (defparameter +access-log-format+ :common-log-format "Either :common-log-format or :extended-common-log-format") (defmethod log-access ((s-http-server s-http-server) http-connection http-request response bytes) (let ((out (get-access-log-stream s-http-server))) (when out (let ((client-ip (or (s-sysdeps:get-socket-stream-property (get-stream http-connection) :remote-host) "-")) (timestamp (s-utils:format-universal-time (get-universal-time) :format +common-log-timestamp-format+ :decode-in-timezone 0)) (method (get-method http-request)) (resource (get-full-path http-request)) (protocol (get-http-version http-request))) (ecase +access-log-format+ (:common-log-format (s-sysdeps:with-process-lock ((get-log-lock s-http-server)) (format out "~a - - ~a \"~a ~a ~a\" ~d ~d~%" client-ip timestamp method resource protocol response bytes))) (:extended-common-log-format (let ((referer (or (request-header-value http-request "Referer") "-")) (agent (or (request-header-value http-request "User-Agent") "-"))) (s-sysdeps:with-process-lock ((get-log-lock s-http-server)) (format out "~a - - ~a \"~a ~a ~a\" ~d ~d ~s ~s~%" client-ip timestamp method resource protocol response bytes referer agent))))))))) (defparameter +period-check-interval+ 60 "Do some periodic checks every minute") (defmethod do-periodic-check ((s-http-server s-http-server)) (let ((now (get-universal-time))) (when (< +period-check-interval+ (- now (get-last-periodic-check s-http-server))) (logm s-http-server "Running periodic tasks") (cleanup-old-connections s-http-server) (flush-log-streams s-http-server) (setf (get-last-periodic-check s-http-server) now)))) (defparameter +allowed-connection-keepalive-age+ (* 60 60) "Allow kept alive connections to be 1 hour inactive before they are cleaned up") (defmethod cleanup-old-connections ((s-http-server s-http-server)) (let ((now (get-universal-time)) http-connections-to-remove) (loop :for http-connection :in (get-http-connections s-http-server) :do (with-slots (process stream timestamp) http-connection (when (< +allowed-connection-keepalive-age+ (- now timestamp)) (push http-connection http-connections-to-remove) (logm s-http-server "Cleaning up ~s" http-connection) (when stream (ignore-errors (close stream :abort t)) (setf stream nil)) (when process (s-sysdeps:kill-process process) (setf process nil))))) (setf (get-http-connections s-http-server) (set-difference (get-http-connections s-http-server) http-connections-to-remove)))) (defmethod flush-log-streams ((s-http-server s-http-server)) (with-slots (log-stream access-log-stream) s-http-server (when log-stream (force-output log-stream)) (when access-log-stream (force-output access-log-stream)))) ;; low level input/output - we are using a reusable buffer to read lines (defun read-crlf-line (buffer stream &optional (eof-error-p t) eof-value) "Read a CRLF termintated line from a character input stream into buffer. Return length excluding CRLF." (let ((offset 0) (previous-char #\null)) (loop :for char = (read-char stream eof-error-p eof-value) :do (cond ((equal char eof-value) (return eof-value)) ((and (char= char #\linefeed) (char= previous-char #\return)) (return (1- offset))) ;; for the sake of robustness (and to support clisp's eol conversions) ;; we allow for a lone LF to terminate a line as well ((char= char #\linefeed) (return offset)) ((>= offset (length buffer)) (error "Line length exceeds buffer size (~d)" (length buffer))) (t (setf (char buffer offset) char) (setf previous-char char) (incf offset)))))) (defun write-http-response-line (string &optional (stream *standard-output*)) "Write string to stream, ending with the HTTP end of line convention (CR+LF)" (write-string string stream) (write-char #\return stream) (write-char #\linefeed stream)) (defun format-http-response-line (stream format-string &rest args) (write-http-response-line (apply #'format nil format-string args) stream)) ;; parsing requests (define-condition missing-http-request-line (error) ()) (defun parse-http-request-line (stream buffer) (let* ((line-length (read-crlf-line buffer stream nil)) (tokens (when line-length (s-utils:tokens buffer :separators '(#\space) :end line-length)))) (if tokens (values (intern (first tokens) :keyword) (puri:parse-uri (second tokens)) (third tokens)) (error 'missing-http-request-line)))) (defparameter +common-request-headers+ (mapcar #'(lambda (header) (cons header (intern (string-upcase header) :keyword))) '("Host" "User-Agent" "Accept" "Accept-Language" "Accept-Encoding" "Accept-Charset" "Content-Length" "Content-Type" "Authorization" "Cookie" "Connection" "Keep-Alive" "Cache-Control" "Pragma" "If-Modified-Since"))) (defun header-field-name->keyword (string &optional (start 0) end) ;; optimize the case of common request headers and avoid interning/upcasing (let ((common-header (find-if #'(lambda (x) (string-equal (car x) string :start2 start :end2 end)) +common-request-headers+))) (if common-header (cdr common-header) (intern (nstring-upcase (subseq string start end)) :keyword)))) (defun header-field-value->string (string &optional (start 0) end) ;; skip leading whitespace (loop :while (and (< start end) (member (char string start) '(#\space #\tab) :test #'char=)) :do (incf start)) (subseq string start end)) (defun parse-http-request-headers (stream buffer) (loop :for line-length = (read-crlf-line buffer stream nil) :until (or (null line-length) (zerop line-length)) :collect (let ((colon (position #\: buffer :end line-length :test #'char=))) (cons (header-field-name->keyword buffer 0 colon) (header-field-value->string buffer (1+ colon) line-length))))) (defun parse-http-request (stream buffer) (multiple-value-bind (http-method uri http-version) (parse-http-request-line stream buffer) (let* ((request-headers (parse-http-request-headers stream buffer)) (http-request (make-instance 'http-request :method http-method :uri uri :http-version http-version :headers request-headers))) (when (and (string-equal http-version "HTTP/1.0") (string-not-equal (cdr (assoc "Connection" request-headers :test #'string-equal)) "Keep-Alive")) (setf (get-keep-alive http-request) nil)) http-request))) ;; writing/generating responses (defun write-http-response-status-line (stream &optional (status-code 200) (string "OK") (http-version "HTTP/1.1")) "Write an HTTP Response Status line to stream, using status-code string and http-version" (format-http-response-line stream "~a ~d ~a" http-version status-code string)) (defun write-http-response-headers (headers stream) "Write the headers alist as HTTP Response Headers to stream" (loop :for (header-key . header-value) :in headers :do (format-http-response-line stream "~a: ~a" header-key header-value))) (defun response-date (&optional (universal-time (get-universal-time))) "Generate a GMT HTTP Response Date" (s-utils:format-universal-time universal-time :format '(:day-name ", " :date2 #\Space :month-name #\Space :year #\Space :hour #\: :minute #\: :second " GMT") :decode-in-timezone 0)) (defun standard-http-response-headers (http-request &key (content-type "text/plain") content-length) "Generate the standard headers alist given context-type and context-length, managing old-style Keep-Alive" `(("Server" . ,*http-server-identification*) ("Date" . ,(response-date)) ,@(when content-type `(("Content-Type" . ,content-type))) ,@(when content-length `(("Content-Length" . ,content-length))) ,@(when (and http-request (get-keep-alive http-request) (string-equal (get-http-version http-request) "HTTP/1.0")) `(("Connection" . "Keep-Alive"))) ,@(when (and http-request (not (get-keep-alive http-request)) (string-equal (get-http-version http-request) "HTTP/1.1")) `(("Connection" . "Close"))))) (defun escape (string) (with-output-to-string (stream) (loop :for char :across string :do (case char (#\& (write-string "&" stream)) (#\< (write-string "<" stream)) (#\> (write-string ">" stream)) (#\" (write-string """ stream)) ((#\newline #\return #\tab) (write-char char stream)) (t (if (and (<= 32 (char-code char)) (<= (char-code char) 126)) (write-char char stream) (progn (write-string "" stream) (write (char-code char) :stream stream :base 16) (write-char #\; stream)))))))) (defvar *doctype-html-401-transitional* "") (defvar *doctype-html-401-strict* "") (defun standard-http-html-message-response (http-request stream title message &optional (status 200) (string "OK")) "Generate and write a standard HTML message as HTTP Response using title, message, status and string" (let ((content (with-output-to-string (out) (format out "~a
~d - ~a: ~a
" code reason (escape (prin1-to-string extra))) code reason)) ;; core server implementation (http request/repsonse loop and dispatching to handlers) (defmethod find-handler ((s-http-server s-http-server) http-request) (let ((path (get-path http-request))) (loop :for context-binding :in (get-contexts s-http-server) :do (destructuring-bind (handler context &rest rest) context-binding (declare (ignore handler rest)) (if (string= path context :end1 (min (length context) (length path))) (return-from find-handler context-binding)))))) (defmethod handle-one-http-request-response ((s-http-server s-http-server) http-connection) (with-slots (stream buffer id) http-connection (let* ((http-request (handler-case (parse-http-request stream buffer) (puri:uri-parse-error () (logm s-http-server "[~d] Bad Request" id) (standard-http-html-error-response nil stream 400 "Bad Request" "Syntax Error") (finish-output stream) ;; retarget the error (error 'missing-http-request-line)))) (handler (find-handler s-http-server http-request))) (when (get-debug-mode s-http-server) (logm s-http-server "[~d] Handling ~s" id http-request)) (multiple-value-bind (success response bytes) (if handler (if (get-debug-mode s-http-server) (funcall (first handler) s-http-server (rest handler) http-request stream) (multiple-value-bind (result second third) (ignore-errors (funcall (first handler) s-http-server (rest handler) http-request stream)) (if result (values result second third) (progn (logm s-http-server "[~d] Handler ~s failed for ~s" id handler http-request) (values t 500 (standard-http-html-error-response http-request stream 500 "Internal Server Error" second)))))) (progn (logm s-http-server "[~d] No handler found for ~s" id http-request) (values t 404 (standard-http-html-error-response http-request stream 404 "Resource Not Found" (get-path http-request))))) (declare (ignore success)) (log-access s-http-server http-connection http-request response bytes)) (finish-output stream) http-request))) (defmethod handle-http-server-connection ((s-http-server s-http-server) http-connection) (setf (get-process http-connection) (s-sysdeps:current-process)) (push http-connection (get-http-connections s-http-server)) (unwind-protect (loop (if (get-debug-mode s-http-server) (unless (handler-case (get-keep-alive (handle-one-http-request-response s-http-server http-connection)) #+lispworks(comm:socket-error () nil) (missing-http-request-line () nil)) (return)) (unless (ignore-errors (get-keep-alive (handle-one-http-request-response s-http-server http-connection))) (return))) (setf (get-timestamp http-connection) (get-universal-time))) (ignore-errors (close (get-stream http-connection) :abort t)) (setf (get-process http-connection) nil (get-stream http-connection) nil (get-http-connections s-http-server) (remove http-connection (get-http-connections s-http-server))))) ;; an S-HTTP-HANDLER is a function taking 4 arguments: ;; - the s-http-server object representing the server itself ;; - the handler binding (a list whose first element is always the context that matched) ;; - the current http-request object (containing request method, uri, version and the headers alist) ;; - the stream to the client (where the request line and headers are already consumed) ;; the handler should handle the request, outputing 3 things (in order): ;; - a response status line ;; - the response headers ;; - the contents ;; finally, the handler should return the following values: ;; - t if it was succesful in handling the request or nil otherwise ;; - the response code if a response was given, nil otherwise ;; - the number of bytes in the response if there was one, 0 or nil otherwise ;; the builtin handler (defun s-http-server-handler (s-http-server handler http-request stream) "The builtin S-HTTP-SERVER testing/debugging handler returning a simple status/echo/snoop page" (logm s-http-server "Running builtin s-http-server handler") (let ((body (with-output-to-string (out) (format out "Welcome to ~a
" *http-server-identification*) (format out "This is ~s running on port ~d
" (get-name s-http-server) (get-port s-http-server)) (format out "S-HTTP-SERVER-HANDLER handling a ~a request for path ~s and version ~a
" (get-method http-request) (get-path http-request) (get-http-version http-request)) (format out "Server clock is ~a
" (s-utils:format-universal-time (get-universal-time))) (format out "Server uptime is ~a
" (s-utils:format-duration (- (get-universal-time) (get-boot-time s-http-server)))) (format out "Remote host is ~s
" (s-sysdeps:get-socket-stream-property stream :remote-host)) (format out "Current thread is ~a
" (escape (prin1-to-string (s-sysdeps:current-process)))) (format out "Server thread is ~a
" (escape (prin1-to-string (get-server-process s-http-server)))) (format out "Open connections:
Active handler binding is ~s
" handler) (format out "Contexts:
| Handler | Context | Parameters |
|---|---|---|
| ~a | ~a | ~a |
Request Headers:
") (format out "| Key | Value |
|---|---|
| ~a | ~a |