From 1aa5acda7cbbfa00a6b4cde56c6963fa684f391b Mon Sep 17 00:00:00 2001 From: Piotr Szarmanski Date: Fri, 4 Aug 2023 19:39:32 +0200 Subject: New HTTP backend. --- extra/http/README | 14 ++++++++ extra/http/client.lisp | 33 +++++++++++++++++ extra/http/eris-http-client.asd | 8 +++++ extra/http/eris-http-server.asd | 27 ++++++++++++++ extra/http/eris-http-tests.asd | 12 +++++++ extra/http/http-tests.lisp | 72 +++++++++++++++++++++++++++++++++++++ extra/http/package-client.lisp | 3 ++ extra/http/package.lisp | 4 +++ extra/http/server.lisp | 78 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 251 insertions(+) create mode 100644 extra/http/README create mode 100644 extra/http/client.lisp create mode 100644 extra/http/eris-http-client.asd create mode 100644 extra/http/eris-http-server.asd create mode 100644 extra/http/eris-http-tests.asd create mode 100644 extra/http/http-tests.lisp create mode 100644 extra/http/package-client.lisp create mode 100644 extra/http/package.lisp create mode 100644 extra/http/server.lisp (limited to 'extra') diff --git a/extra/http/README b/extra/http/README new file mode 100644 index 0000000..8dba5ab --- /dev/null +++ b/extra/http/README @@ -0,0 +1,14 @@ +This is an implementation of ERIS over HTTP [https://eris.codeberg.page/eer/http.xml]. + +In addition to the barebones specification, the following extensions are implemented: + +1. Uploading blocks. Users authorized with the standard basic HTTP authorization +can POST a block in order to upload it to the server, remotely. + +2. When accessing entire URNs, one can also specify the MIMETYPE as an additional query parameter: + +GET /uri-res/N2R?urn:eris:...&type=text/html + +This can be used to directly access the content in a web browser. + + diff --git a/extra/http/client.lisp b/extra/http/client.lisp new file mode 100644 index 0000000..a694612 --- /dev/null +++ b/extra/http/client.lisp @@ -0,0 +1,33 @@ +(in-package #:eris-http-client) + +(defclass http-backend (eris:encoding-backend eris:decoding-backend) + ((endpoint :initarg :endpoint :type quri:uri + :documentation "HTTP endpoint to use.") + (auth :initarg :auth :type cons + :documentation "A login:password pair for HTTP Basic auth."))) + +(defmethod initialize-instance ((http-backend http-backend) &rest initargs + &key (endpoint nil endpoint-p) + (auth nil) + &allow-other-keys) + (declare (ignore initargs)) + (unless endpoint-p + (error 'simple-error "An endpoint must be specified.")) + (let ((uri (quri:uri endpoint))) + ;; To prevent confusion of what part of the endpoint should be included + (unless (string-suffix-p "/uri-res/" (quri:uri-path uri)) + (error 'simple-error "Endpoint must end in /uri-res/.")) + (with-slots (eris:output-function eris:fetch-function) http-backend + (setf (quri:uri-path uri) (concatenate 'string (quri:uri-path uri) "N2R")) + (setf eris:fetch-function + (lambda (reference) + (setf (quri:uri-query uri) (eris:reference-to-block-urn reference)) + (mvlet ((body status (dexador:get uri))) + (if (eql status 404) + (error 'eris:missing-block) + body))) + eris:output-function + (lambda (block reference) + (setf (quri:uri-query uri) (eris:reference-to-block-urn reference)) + (dexador:post uri :force-binary t :content block :basic-auth auth) + block))))) diff --git a/extra/http/eris-http-client.asd b/extra/http/eris-http-client.asd new file mode 100644 index 0000000..7adecbb --- /dev/null +++ b/extra/http/eris-http-client.asd @@ -0,0 +1,8 @@ +(defsystem "eris-http-client" + :name "eris-http-client" + :author "mail@ykonai.net" + :license "LGPLv3 or later" + :depends-on ("ironclad" "alexandria" "serapeum" "dexador" "eris") + :serial t + :components ((:file "package-client") + (:file "client"))) diff --git a/extra/http/eris-http-server.asd b/extra/http/eris-http-server.asd new file mode 100644 index 0000000..f7cafd6 --- /dev/null +++ b/extra/http/eris-http-server.asd @@ -0,0 +1,27 @@ +(defsystem "eris-http-server" + :name "eris-http-server" + :author "mail@ykonai.net" + :license "LGPLv3 or later" + :depends-on ("ironclad" "alexandria" "serapeum" "quri" "clack" "eris") + :serial t + :components ((:file "package") + (:file "server")) +;; :in-order-to ((test-op (test-op :eris/test))) + ) + + +;; (defsystem "eris/test" +;; :name "eris/test" +;; :depends-on ("eris" "fiveam" "ironclad" "alexandria" "serapeum") +;; :perform (test-op (op c) (symbol-call :fiveam :run! +;; (find-symbol* :eris-tests :eris/test))) +;; :components ((:module "tests" +;; :serial t +;; :components ((:file "package") +;; (:file "common") +;; (:file "encode-tests") +;; (:file "decode-tests") +;; (:file "rfc") +;; (:file "autogenerated-tests") +;; (:file "backend-tests") +;; #+unix (:file "parallel-tests"))))) diff --git a/extra/http/eris-http-tests.asd b/extra/http/eris-http-tests.asd new file mode 100644 index 0000000..5e99760 --- /dev/null +++ b/extra/http/eris-http-tests.asd @@ -0,0 +1,12 @@ +(defsystem "eris-http-tests" + :name "eris-http-tests" + :author "mail@ykonai.net" + :license "LGPLv3 or later" + :depends-on ("eris-http-server" "eris-http-client" "alexandria" "serapeum" "eris" "fiveam") + :serial t + :components ((:file "http-tests")) + :perform (test-op (op c) (symbol-call :fiveam :run! (find-symbol* :eris-http-tests :eris-http-tests)))) + + + + diff --git a/extra/http/http-tests.lisp b/extra/http/http-tests.lisp new file mode 100644 index 0000000..38dfac8 --- /dev/null +++ b/extra/http/http-tests.lisp @@ -0,0 +1,72 @@ +(defpackage #:eris-http-tests + (:use #:common-lisp #:serapeum #:fiveam #:eris-http-server #:eris-http-client)) + +(in-package :eris-http-tests) + + +(defun make-temporary-dir () + (let* ((tmpdir (uiop:temporary-directory)) + (tmp-tmpdir (make-pathname + :directory (serapeum:append1 + (pathname-directory tmpdir) + (ironclad:byte-array-to-hex-string (ironclad:random-data 10))) + :defaults tmpdir))) + (ensure-directories-exist tmp-tmpdir) + tmp-tmpdir)) + +(defmacro with-temporary-dir (sym &body expr) + `(let ((,sym (make-temporary-dir))) + (unwind-protect + (progn ,@expr) + (uiop:delete-directory-tree ,sym :validate t)))) + +(defmacro with-clack (handler &body expr) + (alexandria:with-gensyms (v) + `(let ((,v (clack:clackup ,handler))) + (unwind-protect (progn ,@expr) + (progn (format t "STOPPING CLACK ~%") (clack:stop ,v)))))) + +;; (base64:string-to-base64-string "test:test") +(alexandria:define-constant auth '("dGVzdDp0ZXN0") :test #'equal) + +(def-suite* eris-http-tests) + +(test basic-http-rw + (with-temporary-dir tdir + (with-clack (eris-handler tdir :auth auth) + (let ((backend (make-instance 'http-backend :endpoint "http://127.0.0.1:5000/uri-res/" + :auth '("test" . "test")))) + (for-all ((buffer (gen-buffer :length (gen-integer :min 1 :max 80000)))) + (is (equalp (alexandria:read-stream-content-into-byte-vector + (eris:fetch-data + (eris:store-data buffer backend) + backend)) + buffer)) + (is (equalp (alexandria:read-stream-content-into-byte-vector + (eris:fetch-data + (eris:store-data (make-array 40000 :element-type 'octet :initial-element 2) backend) + backend)) + (make-array 40000 :element-type 'octet :initial-element 2)))))))) + +(test bad-inputs + (with-temporary-dir tdir + (with-clack (eris-handler tdir :auth auth) + (signals error + (dexador:get "http://127.0.0.1:5000/lole")) + (signals error + (dexador:get "http://127.0.0.1:5000/../../")) + (signals error + (dexador:get "http://127.0.0.1:5000/uri-res/N2R?urn:blake2b:../../passwd")) + (signals error + (dexador:get "http://127.0.0.1:5000/uri-res/N2R?urn:blake2b:not")) + (signals error + (dexador:get "http://127.0.0.1:5000/uri-res/random-data")) + (signals error + (dexador:get "http://127.0.0.1:5000/../../../shadow")) + (signals error + (dexador:get "http://127.0.0.1:5000/uri-res/../something"))))) + + + + + diff --git a/extra/http/package-client.lisp b/extra/http/package-client.lisp new file mode 100644 index 0000000..e31375b --- /dev/null +++ b/extra/http/package-client.lisp @@ -0,0 +1,3 @@ +(defpackage #:eris-http-client + (:use #:common-lisp #:serapeum) + (:export #:http-backend)) diff --git a/extra/http/package.lisp b/extra/http/package.lisp new file mode 100644 index 0000000..5c713ce --- /dev/null +++ b/extra/http/package.lisp @@ -0,0 +1,4 @@ +(defpackage #:eris-http-server + (:use #:common-lisp #:serapeum) + (:local-nicknames (#:a #:alexandria)) + (:export #:eris-handler)) diff --git a/extra/http/server.lisp b/extra/http/server.lisp new file mode 100644 index 0000000..0ad85e4 --- /dev/null +++ b/extra/http/server.lisp @@ -0,0 +1,78 @@ +(in-package #:eris-http-server) + +(a:define-constant forbid '(403 (:content-type "text/plain") ("FORBIDDEN.")) + :test #'equal) + +(a:define-constant unauth '(401 (:content-type "text/plain") ("Unauthorized.")) + :test #'equal) + +(defmacro clack-write-stream (stream) + "Writes a stream as a response using Clack's responders." + `(lambda (responder) + (block lambda + ;; if errors out during stream creation, 404 + (let ((stream (handler-case ,stream + (error (c) + (declare (ignore c)) + (funcall (funcall responder '(404 (:content-type "text/plain") nil) nil :close t)) + (return-from lambda))))) + (let ((writer (funcall responder '(200 (:content-type "application/octet-stream"))))) + ;; read-sequence -> write + (loop with buffer = (serapeum:make-octet-vector 4096) + for bytes-read = (read-sequence buffer stream) + if (= bytes-read 4096) + do (funcall writer buffer) + else + do (funcall writer (serapeum:nsubseq buffer 0 bytes-read) :close t))))))) + + +(defun request-get (query-string directory file-backend) + (let ((uri (quri:uri query-string))) + (unless (quri:urn-p uri) (return-from request-get forbid)) + (case-using #'equal (quri:urn-nid uri) + ("blake2b" (let ((f (merge-pathnames directory (string-upcase (quri:urn-nss uri))))) + ;; Using merge-pathnames should prevent things like urn:...:/../../etc/passwd + ;; since it'll only fill the filename. + (if (uiop:file-exists-p f) + `(200 (:content-type "application/octet-stream") ,f) + `(404 (:content-type "text/plain") ("Missing block."))))) + ("eris" (clack-write-stream + (eris:fetch-data (eris:urn-to-read-capability query-string) file-backend))) + (otherwise forbid)))) + +(defun request-post (query body directory) + (let* ((uri (quri:uri query))) + ;; Using merge-pathnames should prevent things like urn:...:/../../blah + (unless (quri:urn-p uri) (return-from request-post forbid)) + (let ((path (merge-pathnames directory (string-upcase (quri:urn-nss uri))))) + (unless (uiop:file-exists-p path) + (with-open-file (f path :direction :output :if-does-not-exist :create :element-type 'octet) + (alexandria:copy-stream body f :element-type 'octet :buffer #.(make-octet-vector 4096))))) + '(200 (:content-type "text/plain") ("Unauthorized.")))) + + +(defun eris-handler (directory &key auth) + "Return a Clack handler for a file-backend based ERIS store. + +AUTH should be NIL or a list containing HTTP Basic Auth tokens (ie. base64 +encoded logic:pass strings). If NIL, all POST requests will be denied. Note that +due to the limitations of ERIS, the server absolutely trusts the uploader, +meaning that malicious users could potentially cause harm (as well as upload +arbitrary files)." + (let ((file-backend (make-instance 'eris:file-backend :directory directory))) + (lambda (env) + (destructuring-bind (&key request-method query-string raw-body path-info (headers (make-hash-table)) + content-length &allow-other-keys) env + (if (not (equal path-info "/uri-res/N2R")) + '(404 (:content-type "text/plain") ("No resource.")) + (case request-method + (:get (if (stringp query-string) (request-get query-string directory file-backend) + forbid)) + (:post (if (and + auth ;; if no auth always deny + (member (subseq (href headers "authorization") 6) auth :test #'equal) + (typep content-length '(member 1024 32768)) + (stringp query-string)) + (request-post query-string raw-body directory) + unauth)) + (t '(405 (:content-type "text/plain") ("Method not allowed."))))))))) -- cgit v1.2.3