├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── basic.janet ├── csrf-tokens.janet ├── halt.janet ├── headers.janet ├── html.janet ├── json-api.janet ├── layout.janet ├── logging.janet ├── multiple-layouts.janet ├── not-found.janet ├── sessions.janet └── static.janet ├── project.janet ├── public ├── index.html └── test.txt ├── src ├── osprey.janet └── osprey │ ├── csrf.janet │ ├── form.janet │ ├── helpers.janet │ ├── multipart.janet │ ├── router.janet │ └── session.janet └── test ├── osprey-test.janet └── osprey └── multipart-test.janet /.gitattributes: -------------------------------------------------------------------------------- 1 | # Use an approximate language for syntax highlighting (clojure is pretty close) 2 | *.janet linguist-language=clojure 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | /.env 3 | *.sqlite3 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## Unreleased - ??? 5 | - Sinatra like api 6 | - [Upper case http method names](https://github.com/swlkr/osprey/pull/1) Thanks to @pepe 7 | - Add `ok` function (helper for status 200) 8 | - Add `text/html`, `application/json`, `text/plain` content-type functions 9 | - Remove `add-header` function 10 | - Make `app` public 11 | - Add `render` function 12 | - Add `enable` function 13 | - Add `(enable :static-files)` 14 | - Add `(enable :sessions)` 15 | - Add `(enable :csrf-tokens)` 16 | - Add `halt` function 17 | - Add multipart/form-data parsing 18 | - Change `html/encode` to handle strings without tags 19 | - Use `janet-html` to share html rendering with joy 20 | - Automatically parse form encoded bodies and put the results in `params` 21 | - Added flash message support when `(enable :sessions)` is called 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sean Walker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Osprey 2 | 3 | Osprey is a [sinatra](http://sinatrarb.com) inspired framework for writing web applications in [janet](https://github.com/janet-lang/janet) quickly 4 | 5 | ```clojure 6 | (use osprey) 7 | 8 | (GET "/" "osprey") 9 | 10 | (server 9001) 11 | ``` 12 | 13 | Make sure janet and osprey are installed (macOS) 14 | 15 | ```sh 16 | brew install janet 17 | jpm install https://github.com/swlkr/osprey 18 | ``` 19 | 20 | Add the example code above to a `.janet` file: 21 | 22 | ```sh 23 | echo '(use osprey) (GET "/" "osprey") (server 9001)' > myapp.janet 24 | janet myapp.janet 25 | ``` 26 | 27 | Make sure it's working with curl 28 | 29 | ```sh 30 | curl localhost:9001 31 | # => osprey 32 | ``` 33 | 34 | That's it for now, happy hacking! 35 | -------------------------------------------------------------------------------- /examples/basic.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | 3 | (GET "/" "hello world") 4 | 5 | (server 9001) 6 | -------------------------------------------------------------------------------- /examples/csrf-tokens.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | (enable :sessions {:secure false}) 4 | (enable :csrf-tokens) 5 | 6 | 7 | # wrap all html responses with layout 8 | (layout 9 | (doctype :html5) 10 | [:html {:lang "en"} 11 | [:head 12 | [:title (request :uri)]] 13 | [:body response]]) 14 | 15 | 16 | (GET "/" 17 | [:div 18 | [:div "no csrf form. returns 403"] 19 | [:form {:action "/without-csrf-token" :method "POST"} 20 | [:input {:type "submit" :value "Submit"}]] 21 | 22 | [:div "csrf token in form. returns 302"] 23 | (form {:action "/with-csrf-token"} 24 | [:input {:type "submit" :value "Submit"}]) 25 | 26 | [:div "invalid csrf token in form. returns 403"] 27 | [:form {:action "/with-csrf-token" :method "POST"} 28 | [:input {:type "hidden" :name "__csrf-token" :value "im invalid"}] 29 | [:input {:type "submit" :value "Submit"}]]]) 30 | 31 | 32 | (POST "/without-csrf-token" 33 | (redirect "/")) 34 | 35 | 36 | (POST "/with-csrf-token" 37 | (redirect "/")) 38 | 39 | 40 | (server 9001) 41 | -------------------------------------------------------------------------------- /examples/halt.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | 3 | (defn protected? [request] 4 | (or (= (request :path) "/") 5 | (= (request :path) "/protected"))) 6 | 7 | # halt with a 401 8 | (before 9 | (unless (protected? request) 10 | (halt {:status 401 :body "Nope." :headers {"Content-Type" "text/plain"}}))) 11 | 12 | 13 | # wrap all html responses with layout 14 | (layout 15 | (doctype :html5) 16 | 17 | [:html {:lang "en"} 18 | 19 | [:head 20 | [:title (request :path)]] 21 | 22 | [:body response]]) 23 | 24 | 25 | # returns 200 26 | (GET "/" 27 | [:h1 "welcome to osprey!"]) 28 | 29 | 30 | # halt works in handlers as well 31 | # try curl -v 'localhost:9001?bypass=' 32 | (GET "/protected" 33 | (unless (params :bypass) 34 | (halt {:status 401 :body "Nope." :headers {"Content-Type" "text/plain"}})) 35 | 36 | [:h1 "Yep!"]) 37 | 38 | 39 | (server 9001) 40 | -------------------------------------------------------------------------------- /examples/headers.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | 3 | (enable :static-files) 4 | 5 | (before 6 | (header "X-Powered-By" "osprey")) 7 | 8 | (server 9001) 9 | -------------------------------------------------------------------------------- /examples/html.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | 4 | (enable :sessions {:secure false}) 5 | 6 | 7 | # put the todos somewhere 8 | # since there isn't a database 9 | (def todos @{0 {:id 0 :name "Osprey" :done "true"}}) 10 | 11 | 12 | (defn coerce [body] 13 | (if (dictionary? body) 14 | (do 15 | (var output @{}) 16 | 17 | (eachp [k v] body 18 | (put output k 19 | (cond 20 | (= "false" v) false 21 | (= "true" v) true 22 | (peg/match :d+ v) (scan-number v) 23 | :else v))) 24 | 25 | output) 26 | body)) 27 | 28 | 29 | # before all requests use naive coerce fn on params 30 | (before 31 | (update request :params coerce)) 32 | 33 | 34 | # after any request that isn't a redirect, slap a layout and html encode 35 | (layout 36 | (doctype :html5) 37 | 38 | [:html {:lang "en"} 39 | [:head 40 | [:title (request :path)]] 41 | [:body 42 | [:a {:href "/"} "go home"] 43 | [:span " "] 44 | [:a {:href "/todos"} "view todos"] 45 | [:span " "] 46 | [:a {:href "/todo"} "new todo"] 47 | response]]) 48 | 49 | 50 | # checkbox helper 51 | (defn checkbox [attributes] 52 | [[:input {:type "hidden" :name (attributes :name) :value false}] 53 | (let [attrs {:type "checkbox" :name (attributes :name) :value true}] 54 | (if (attributes :checked) 55 | [:input (merge attributes attrs)] 56 | [:input attrs]))]) 57 | 58 | 59 | (GET "/" 60 | [:h1 "welcome to osprey"]) 61 | 62 | 63 | # list of todos 64 | (GET "/todos" 65 | [:div 66 | 67 | (when-let [message (flash :notice)] 68 | [:p message]) 69 | 70 | [:ul 71 | (foreach [todo (->> todos values (sort-by |($ :id)))] 72 | [:li 73 | [:span (todo :name)] 74 | [:span (if (todo :done) " is done!" "")] 75 | [:div 76 | [:a {:href (href "/todos/:id/edit" todo)} "edit"] 77 | [:span " "] 78 | (form {:action (href "/todos/:id/delete" todo)} 79 | [:input {:type "submit" :value "delete"}])]])]]) 80 | 81 | 82 | (GET "/todos/:id" 83 | (def- todo (todos (params :id))) 84 | 85 | [:div 86 | [:span (todo :name)] 87 | [:span (if (todo :done) " is done!" "")]]) 88 | 89 | 90 | (GET "/todo" 91 | (form {:action "/todos"} 92 | [:div 93 | [:label "name"] 94 | [:br] 95 | [:input {:type "text" :name "name"}] 96 | (when-let [err (get-in request [:errors :name])] 97 | [:div err])] 98 | 99 | (checkbox {:name "done" :checked false}) 100 | [:label "Done"] 101 | 102 | [:input {:type "submit" :value "Save"}])) 103 | 104 | 105 | (POST "/todos" 106 | (flash :notice "Todo created successfully!") 107 | 108 | (let [id (-> todos keys length) 109 | todo (put params :id id)] 110 | 111 | (if (empty? (params :name)) 112 | (render "/todo" (merge request {:errors {:name "name is blank"}})) 113 | 114 | (do 115 | (put todos id todo) 116 | (redirect "/todos"))))) 117 | 118 | 119 | (GET "/todos/:id/edit" 120 | (def- todo (todos (params :id))) 121 | 122 | (form {:action (href "/todos/:id/update" todo)} 123 | [:div 124 | [:label "Name"] 125 | [:br] 126 | [:input {:type "text" :name "name" :value (todo :name)}]] 127 | 128 | (checkbox {:name "done" :checked (todo :done)}) 129 | [:label "Done"] 130 | 131 | [:input {:type "submit" :value "Save"}])) 132 | 133 | 134 | # this updates todos in the dictionary 135 | (POST "/todos/:id/update" 136 | (update todos (params :id) merge params) 137 | 138 | (redirect "/todos")) 139 | 140 | 141 | # this deletes todos from the dictionary 142 | (POST "/todos/:id/delete" 143 | (flash :notice "Todo deleted successfully") 144 | 145 | (put todos (params :id) nil) 146 | 147 | (redirect "/todos")) 148 | 149 | 150 | # start the server on port 9001 151 | (server 9001) 152 | -------------------------------------------------------------------------------- /examples/json-api.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | (import json) 3 | 4 | 5 | # put the todos somewhere 6 | # since there isn't a database 7 | (def todos @[]) 8 | 9 | 10 | # before everything try to parse json body 11 | (before 12 | (content-type "application/json") 13 | 14 | (when (and body (not (empty? body))) 15 | (update request :body json/decode)) 16 | 17 | # before urls with :id 18 | (when (params :id) 19 | (update-in request [:params :id] scan-number))) 20 | 21 | 22 | # just a nice status message on root 23 | 24 | # try this 25 | # $ curl -v localhost:9001 26 | (GET "/" 27 | (json/encode {:status "up"})) 28 | 29 | 30 | # here's the meat and potatoes 31 | # return the todos array from earlier 32 | 33 | # try this 34 | # $ curl -v localhost:9001/todos 35 | (GET "/todos" 36 | (json/encode todos)) 37 | 38 | 39 | # this appends todos onto the array 40 | 41 | # try this 42 | # $ curl -v -X POST -H "Content-Type: application/json" --data '{"todo": "buy whole wheat bread"}' localhost:9001/todos 43 | (POST "/todos" 44 | (json/encode (array/push todos body))) 45 | 46 | 47 | # this updates todos in the array 48 | # :id is assumed to be an integer 49 | # since todos is an array 50 | 51 | # try this 52 | # $ curl -v -X PATCH -H "Content-Type: application/json" --data '{"todo": "buy whole grain bread"}' localhost:9001/todos/0 53 | (PATCH "/todos/:id" 54 | (json/encode (update todos (params :id) merge body))) 55 | 56 | 57 | # this deletes todos from the array 58 | # :id is assumed to be an integer 59 | # since todos is an array 60 | 61 | # try this 62 | # $ curl -v -X DELETE localhost:9001/todos/0 63 | (DELETE "/todos/:id" 64 | (json/encode (array/remove todos (params :id)))) 65 | 66 | 67 | # start the server on port 9001 68 | (server 9001) 69 | -------------------------------------------------------------------------------- /examples/layout.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | 4 | (layout 5 | (doctype :html5) 6 | [:html 7 | [:head 8 | [:title (request :path)]] 9 | [:body response]]) 10 | 11 | 12 | (GET "/" 13 | [:h1 "home"]) 14 | 15 | 16 | (server 9001) 17 | -------------------------------------------------------------------------------- /examples/logging.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | 3 | (enable :logging (fn [s _ _] 4 | (def dur (* 1000 (- (os/clock) s))) 5 | (print "Hey it took " dur "ms." (if (> 0.02 dur) " Not bad!")))) 6 | 7 | (GET "/" "HI") 8 | 9 | (server "8000") 10 | -------------------------------------------------------------------------------- /examples/multiple-layouts.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | 4 | (layout 5 | (doctype :html5) 6 | [:html 7 | [:head 8 | [:title (request :path)]] 9 | [:body response]]) 10 | 11 | 12 | (layout :a 13 | (doctype :html5) 14 | [:html 15 | [:head 16 | [:title (request :path)]] 17 | [:body 18 | [:h1 "/a"] 19 | response]]) 20 | 21 | 22 | (layout :b 23 | (doctype :html5) 24 | [:html 25 | [:head 26 | [:title (request :path)]] 27 | [:body 28 | [:h1 "/b"] 29 | response]]) 30 | 31 | 32 | (before 33 | (when (string/has-prefix? "/a" (request :path)) 34 | (use-layout :a))) 35 | 36 | 37 | (GET "/" 38 | [:h1 "home"]) 39 | 40 | 41 | (GET "/a" 42 | (use-layout :a) 43 | 44 | [:div "/a!"]) 45 | 46 | 47 | (GET "/a/1" 48 | [:div "/a/1!"]) 49 | 50 | 51 | (GET "/b" 52 | (use-layout :b) 53 | 54 | [:div "/b!"]) 55 | 56 | 57 | (server 9001) 58 | -------------------------------------------------------------------------------- /examples/not-found.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | (GET "/" "home") 4 | 5 | (not-found 6 | (content-type "text/html") 7 | 8 | (html/encode 9 | [:h1 "404 Page not found"])) 10 | 11 | (server 9001) 12 | -------------------------------------------------------------------------------- /examples/sessions.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | 3 | 4 | # enable session cookies 5 | (enable :sessions {:secure false}) 6 | 7 | 8 | # wrap all html responses in the html below 9 | (layout 10 | (doctype :html5) 11 | [:html {:lang "en"} 12 | [:head 13 | [:title (request :uri)]] 14 | [:body response]]) 15 | 16 | 17 | (GET "/" 18 | [:main 19 | (if (session :session?) 20 | [:p "yes, there is a session!"] 21 | [:p "no, there is not a session"]) 22 | 23 | # the form helper is only available in route macros 24 | # it also automatically assigns method to POST 25 | (form {:action "/create-session"} 26 | [:input {:type "submit" :value "Sign in"}]) 27 | 28 | (form {:action "/delete-session"} 29 | [:input {:type "submit" :value "Sign out"}])]) 30 | 31 | 32 | (POST "/create-session" 33 | (session :session? true) 34 | 35 | (redirect "/")) 36 | 37 | 38 | (POST "/delete-session" 39 | (session :session? nil) 40 | 41 | (redirect "/")) 42 | 43 | 44 | (server 9001) 45 | -------------------------------------------------------------------------------- /examples/static.janet: -------------------------------------------------------------------------------- 1 | (use ../src/osprey) 2 | 3 | (enable :static-files) 4 | 5 | (server 9001) 6 | -------------------------------------------------------------------------------- /project.janet: -------------------------------------------------------------------------------- 1 | (declare-project 2 | :name "osprey" 3 | :description "A sinatra inspired web framework for janet" 4 | :dependencies ["https://github.com/joy-framework/halo2" 5 | "https://github.com/joy-framework/cipher" 6 | "https://github.com/andrewchambers/janet-uri" 7 | "https://github.com/janet-lang/path" 8 | "https://github.com/swlkr/janet-html"] 9 | :author "Sean Walker" 10 | :license "MIT" 11 | :url "https://github.com/swlkr/osprey" 12 | :repo "git+https://github.com/swlkr/osprey") 13 | 14 | (declare-source 15 | :source @["src/osprey" "src/osprey.janet"]) 16 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | Osprey 3 |

Hello from Osprey

4 | 5 | -------------------------------------------------------------------------------- /public/test.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /src/osprey.janet: -------------------------------------------------------------------------------- 1 | (import ./osprey/router :prefix "" :export true) 2 | (import ./osprey/helpers :prefix "" :export true) 3 | (import ./osprey/form :prefix "form/" :export true) 4 | (import ./osprey/multipart :prefix "multipart/" :export true) 5 | (import janet-html :prefix "" :export true) 6 | 7 | # convenience method for (use opsrey) 8 | (def html/encode encode) 9 | (def html/unsafe raw) 10 | (def multipart? multipart/multipart?) 11 | (def form? form/form?) 12 | -------------------------------------------------------------------------------- /src/osprey/csrf.janet: -------------------------------------------------------------------------------- 1 | (import cipher) 2 | 3 | 4 | (def PADLENGTH 32) 5 | 6 | 7 | (defn- xor-byte-strings [str1 str2] 8 | (let [arr @[] 9 | bytes1 (string/bytes str1) 10 | bytes2 (string/bytes str2)] 11 | (when (= (length bytes2) (length bytes1) PADLENGTH) 12 | (loop [i :range [0 PADLENGTH]] 13 | (array/push arr (bxor (get bytes1 i) (get bytes2 i)))) 14 | (string/from-bytes ;arr)))) 15 | 16 | 17 | (defn mask [unmasked-token] 18 | (let [pad (os/cryptorand PADLENGTH) 19 | masked-token (xor-byte-strings pad unmasked-token)] 20 | (cipher/bin2hex (string pad masked-token)))) 21 | 22 | 23 | (defn- unmask [masked-token] 24 | (when masked-token 25 | (try 26 | (let [token (cipher/hex2bin masked-token) 27 | pad (string/slice token 0 PADLENGTH) 28 | csrf-token (string/slice token PADLENGTH)] 29 | (xor-byte-strings pad csrf-token)) 30 | ([err fib] 31 | (unless (= err "failed to convert hex to binary") 32 | (debug/stacktrace fib err)))))) 33 | 34 | 35 | (defn request-token [headers body] 36 | (let [token (or (get headers "X-CSRF-Token") 37 | (get body :__csrf-token))] 38 | (unmask token))) 39 | 40 | 41 | (defn session-token [session] 42 | (get session :csrf-token)) 43 | 44 | 45 | (defn token [] 46 | (os/cryptorand PADLENGTH)) 47 | 48 | 49 | (defn tokens-equal? [req-token session-token] 50 | (when (and req-token session-token) 51 | (cipher/secure-compare req-token session-token))) 52 | -------------------------------------------------------------------------------- /src/osprey/form.janet: -------------------------------------------------------------------------------- 1 | (import uri) 2 | (import ./helpers :prefix "") 3 | 4 | 5 | (defn- indexed-param? [str] 6 | (string/has-suffix? "[]" str)) 7 | 8 | 9 | (defn- body-table [all-pairs] 10 | (var output @{}) 11 | (loop [[k v] :in all-pairs] 12 | (let [k (uri/unescape k) 13 | v (uri/unescape v)] 14 | (cond 15 | (indexed-param? k) (let [k (string/replace "[]" "" k)] 16 | (if (output k) 17 | (update output k array/concat v) 18 | (put output k @[v]))) 19 | :else (put output k v)))) 20 | output) 21 | 22 | 23 | (defn decode [str] 24 | (when (or (string? str) 25 | (buffer? str)) 26 | (as-> (string/replace-all "+" "%20" str) ? 27 | (string/split "&" ?) 28 | (filter |(not (empty? $)) ?) 29 | (map |(string/split "=" $) ?) 30 | (body-table ?) 31 | (map-keys keyword ?)))) 32 | 33 | 34 | (defn form? [request] 35 | (string/has-prefix? "application/x-www-form-urlencoded" (get-in request [:headers "Content-Type"] ""))) 36 | -------------------------------------------------------------------------------- /src/osprey/helpers.janet: -------------------------------------------------------------------------------- 1 | (defmacro set! 2 | ` 3 | Deprecated. You probably don't want this. 4 | 5 | WARNING: this is a *global variable* that persists 6 | between requests. Be sure you know what you are doing if 7 | you use this. 8 | 9 | set! creates a varglobal and also sets 10 | that value when a before or after 11 | function is executed. Returns nil. 12 | 13 | Example: 14 | 15 | (use osprey) 16 | 17 | (before "/" 18 | (set! id 1)) 19 | 20 | (get "/" id) 21 | 22 | (server 9001) 23 | 24 | curl localhost:9001 => 1` 25 | [name value] 26 | (varglobal (keyword name) nil) 27 | ~(set ,name ,value)) 28 | 29 | 30 | (defn timestamp 31 | "Get the current date nicely formatted" 32 | [] 33 | (let [date (os/date) 34 | M (+ 1 (date :month)) 35 | D (+ 1 (date :month-day)) 36 | Y (date :year) 37 | HH (date :hours) 38 | MM (date :minutes) 39 | SS (date :seconds)] 40 | (string/format "%d-%.2d-%.2d %.2d:%.2d:%.2d" 41 | Y M D HH MM SS))) 42 | 43 | 44 | (defmacro foreach [binding & body] 45 | ~(map (fn [val] 46 | (let [,(first binding) val] 47 | ,;body)) 48 | ,(get binding 1))) 49 | 50 | 51 | (def text/html @{"Content-Type" "text/html; charset=UTF-8"}) 52 | (def text/plain @{"Content-Type" "text/plain"}) 53 | (def application/json @{"Content-Type" "application/json; charset=UTF-8"}) 54 | 55 | 56 | (defn ok [headers body] 57 | @{:status 200 58 | :body body 59 | :headers headers}) 60 | 61 | 62 | (defn inspect [val] 63 | (printf "%m" val) 64 | val) 65 | 66 | 67 | (defn map-keys 68 | `Executes a function on a dictionary's keys and 69 | returns a struct 70 | 71 | Example 72 | 73 | (map-keys snake-case {:created_at "" :uploaded_by ""}) -> {:created-at "" :uploaded-by ""} 74 | ` 75 | [f dict] 76 | (let [acc @{}] 77 | (loop [[k v] :pairs dict] 78 | (put acc (f k) v)) 79 | acc)) 80 | -------------------------------------------------------------------------------- /src/osprey/multipart.janet: -------------------------------------------------------------------------------- 1 | (import uri) 2 | (import ./helpers :prefix "") 3 | 4 | 5 | (defn multipart? [request] 6 | (let [content-type (get-in request [:headers "Content-Type"] "")] 7 | (string/has-prefix? "multipart/form-data" content-type))) 8 | 9 | 10 | (defn- boundary [request] 11 | (first (peg/match '(sequence "multipart/form-data; boundary=" (capture (some 1))) 12 | (get-in request [:headers "Content-Type"] "")))) 13 | 14 | 15 | (defn- decode-part [part] 16 | (if (and (= "form-data" (get part "Content-Disposition")) 17 | (get part "value")) 18 | (update part "value" uri/unescape) 19 | part)) 20 | 21 | 22 | (defn decode [request] 23 | (when-let [boundary (boundary request) 24 | parts (peg/match ~{:main (some (sequence "--" ,boundary (opt "--") :crlf (group (sequence :header :crlf :crlf (constant "value") :value)) :crlf)) 25 | :header (sequence (capture "Content-Disposition") ":" :s+ (capture (to ";")) ";" :s+ :name) 26 | :name (sequence (capture "name") "=\"" (capture (to "\"")) "\"") 27 | :value (capture (to :crlf)) 28 | :crlf "\r\n"} 29 | (get request :body ""))] 30 | 31 | (->> (map (partial apply table) parts) 32 | (map decode-part)))) 33 | 34 | 35 | (defn params 36 | `This function converts multipart form data to a single dictionary. 37 | Multiple parts with the same name will be coerced into an array of values. 38 | 39 | Example: 40 | 41 | (params {:headers {"Content-Type" "multipart/form-data; boundary=---whatever"} 42 | :body "-----whatever\r\nContent-Disposition: form-data; name=\"test\"\r\n\r\nthis is a test\r\n-----whatever--\r\n"}) 43 | 44 | => 45 | 46 | @{:test "this is a test"}` 47 | [request] 48 | (->> (decode request) 49 | (mapcat (fn [dict] [(keyword (get dict "name")) (get dict "value")])) 50 | (apply table))) 51 | -------------------------------------------------------------------------------- /src/osprey/router.janet: -------------------------------------------------------------------------------- 1 | (import halo2) 2 | (import path) 3 | (import cipher) 4 | (import uri) 5 | (import ./session) 6 | (import ./csrf) 7 | (import ./form) 8 | (import ./multipart) 9 | (import ./helpers :prefix "") 10 | (import janet-html :as html) 11 | 12 | 13 | (def- *routes* @[]) 14 | (def- *before-fns* @[]) 15 | (def- *after-fns* @[]) 16 | (def- *osprey-after-fns* @[]) 17 | (var- *session-secret* nil) 18 | (var- *not-found-fn* nil) 19 | (var- *layout* @{}) 20 | 21 | 22 | (defn- slash-suffix [p] 23 | (if (keyword? (last p)) 24 | (put p (dec (length p)) :slash-param) 25 | p)) 26 | 27 | 28 | (defn- wildcard-params [patt uri] 29 | (when (and patt uri) 30 | (let [p (->> (string/split "*" patt) 31 | (interpose :param) 32 | (filter any?) 33 | (slash-suffix) 34 | (freeze)) 35 | 36 | route-peg ~{:param (<- (any (+ :w (set "%$-_.+!*'(),")))) 37 | :slash-param (<- (any (+ :w (set "%$-_.+!*'(),/")))) 38 | :main (* ,;p)}] 39 | 40 | (if (= patt uri) 41 | @[""] 42 | (or (peg/match route-peg uri) 43 | @[]))))) 44 | 45 | 46 | (defn- route-param? [val] 47 | (string/has-prefix? ":" val)) 48 | 49 | 50 | (defn- route-param [val] 51 | (if (route-param? val) 52 | val 53 | (string ":" val))) 54 | 55 | 56 | (defn- route-params [app-url path] 57 | (when (and app-url path) 58 | (let [app-parts (string/split "/" app-url) 59 | req-parts (string/split "/" path)] 60 | (as-> (interleave app-parts req-parts) ? 61 | (partition 2 ?) 62 | (filter (fn [[x]] (route-param? x)) ?) 63 | (map (fn [[x y]] @[(keyword (drop 1 x)) y]) ?) 64 | (mapcat identity ?) 65 | (table ;?))))) 66 | 67 | 68 | (defn- part? [[s1 s2]] 69 | (or (= s1 s2) 70 | (string/find ":" s1))) 71 | 72 | 73 | (def- parts '(some (* "/" '(any (+ :a :d (set ":%$-_.+!*'(),")))))) 74 | 75 | 76 | (defn- route? [request app-route] 77 | (let [[route-method route-url] app-route 78 | {:path uri :method method} request] 79 | 80 | # check methods match first 81 | (and (= (string/ascii-lower method) 82 | (string/ascii-lower route-method)) 83 | 84 | # check that the url isn't an exact match 85 | (or (= route-url uri) 86 | 87 | # check for urls with params 88 | (let [uri-parts (peg/match parts uri) 89 | route-parts (peg/match parts route-url)] 90 | 91 | # 1. same length 92 | # 2. the route definition has a semicolon in it 93 | # 3. the length of the parts are equal after 94 | # accounting for params 95 | (and (= (length route-parts) (length uri-parts)) 96 | (string/find ":" route-url) 97 | (= (length route-parts) 98 | (as-> (interleave route-parts uri-parts) ? 99 | (partition 2 ?) 100 | (filter part? ?) 101 | (length ?))))) 102 | 103 | # wildcard params (still a work in progress) 104 | (and (string/find "*" route-url) 105 | (let [idx (string/find "*" route-url) 106 | sub (string/slice route-url 0 idx)] 107 | (string/has-prefix? sub uri))))))) 108 | 109 | 110 | (defn- find-route [routes request] 111 | (or (find (partial route? request) routes) 112 | @[])) 113 | 114 | 115 | (defn- run-before-fns [request] 116 | (each [patt f] *before-fns* 117 | (when (any? (wildcard-params patt (request :uri))) 118 | (f request)))) 119 | 120 | 121 | (defn- run-after-fns [response request after-fns] 122 | (var res response) 123 | 124 | (each [patt f] after-fns 125 | (when (any? (wildcard-params patt (request :uri))) 126 | (set res (f res request)))) 127 | 128 | res) 129 | 130 | 131 | (defn halt 132 | ` 133 | halts all processing and returns immediately with the given response dictionary 134 | 135 | Example: 136 | 137 | (halt {:status 500 138 | :body "internal server error" 139 | :headers {"Content-Type" "text/plain"}}) 140 | ` 141 | [response] 142 | (return :halt response)) 143 | 144 | 145 | (defn- parse-body [request] 146 | (cond 147 | (multipart/multipart? request) 148 | (multipart/params request) 149 | 150 | (form/form? request) 151 | (form/decode (get request :body)) 152 | 153 | :else 154 | @{})) 155 | 156 | 157 | (defn add-header 158 | `Deprecated. 159 | 160 | Use (header key value) instead.` 161 | [response key value] 162 | (let [val (get-in response [:headers key])] 163 | (if (indexed? val) 164 | (update-in response [:headers key] array/push value) 165 | (put-in response [:headers key] value)))) 166 | 167 | (defn header 168 | ` 169 | Adds a header to the current response dyn 170 | 171 | If the value is an array it uses array/push to add to the value 172 | 173 | Returns the response dictionary 174 | 175 | Example: 176 | 177 | (before "*" 178 | (header "Content-Type" "application/json")) 179 | ` 180 | [key value] 181 | (let [response (dyn :response) 182 | val (get-in response [:headers key])] 183 | (if (indexed? val) 184 | (update-in response [:headers key] array/push value) 185 | (put-in response [:headers key] value)))) 186 | 187 | 188 | (defn content-type 189 | `Sets the content-type of the current response 190 | 191 | Example: 192 | 193 | (before "*" 194 | (content-type "text/html"))` 195 | [ct] 196 | (header "Content-Type" ct)) 197 | 198 | 199 | (defn status 200 | `Sets the status of the current response 201 | 202 | Example: 203 | 204 | (before "*" 205 | (content-type "text/html"))` 206 | [s] 207 | (put (dyn :response) :status s)) 208 | 209 | 210 | (defn flash [k &opt v] 211 | (if v 212 | (put (dyn :flash) k v) 213 | (get (dyn :flash) k))) 214 | 215 | 216 | (defn session [& args] 217 | (case (length args) 218 | 1 (get (dyn :session) (first args)) 219 | 2 (put (dyn :session) (first args) (last args)) 220 | (dyn :session))) 221 | 222 | 223 | (defn- response-table 224 | `Coerce any value into a response dictionary` 225 | [response] 226 | (if (dictionary? response) 227 | (merge (dyn :response) response) 228 | (put (dyn :response) :body response))) 229 | 230 | 231 | (defn- not-found-response [response request f] 232 | (let [file (get response :file "")] 233 | (cond 234 | (halo2/file-exists? file) 235 | response 236 | 237 | (nil? f) 238 | (if *not-found-fn* 239 | (do 240 | # run user defined 404 function 241 | (status 404) 242 | (halt (response-table (*not-found-fn* request)))) 243 | # otherwise return a basic not found plaintext 404 244 | (halt @{:status 404 245 | :body "not found" 246 | :headers @{"Content-Type" "text/plain"}})) 247 | 248 | :else 249 | response))) 250 | 251 | 252 | (defn- redirect-response [response] 253 | (if (empty? (dyn :redirect)) 254 | response 255 | (merge response (dyn :redirect)))) 256 | 257 | 258 | (defn- handler 259 | "Creates a handler function from routes. Returns nil when handler/route doesn't exist." 260 | [routes] 261 | (fn [request] 262 | (prompt 263 | :halt 264 | 265 | (let [request (merge request (uri/parse (request :uri))) 266 | route (find-route routes request) 267 | [method uri f] route 268 | wildcard (wildcard-params uri (request :uri)) 269 | params (or (route-params uri (request :path)) {}) 270 | params (merge params (map-keys keyword (get request :query {}))) 271 | params (merge params (parse-body request)) 272 | request (merge request {:params params 273 | :wildcard wildcard 274 | :text-body (request :body) 275 | :route-uri (get route 1)})] 276 | 277 | (with-dyns [:response @{:status 200 278 | :headers @{"Content-Type" "text/plain"}} 279 | :redirect @{} 280 | :layout :default 281 | :flashed? nil 282 | :flash @{} 283 | :csrf-token nil] 284 | 285 | # run all before-fns before request 286 | (run-before-fns request) 287 | 288 | # run handler fn 289 | (as-> (f request) ? 290 | # run all after-fns after request 291 | (run-after-fns ? request *after-fns*) 292 | # coerce response into table 293 | (response-table ?) 294 | # check for redirects 295 | (redirect-response ?) 296 | # check for 404s 297 | (not-found-response ? request f) 298 | # apply session bits to response table 299 | (run-after-fns ? request *osprey-after-fns*))))))) 300 | 301 | 302 | (def app :public ` 303 | Stops just short of sending http requests and responses over the server. 304 | Mostly useful for testing. 305 | 306 | Example: 307 | 308 | (import osprey :prefix "") 309 | 310 | (GET "/example" "example") 311 | 312 | (app {:uri "/example" :method "GET"}) 313 | 314 | # => 315 | 316 | @{:status 200 :body "example" :headers @{"Content-Type" "text/plain"}} 317 | ` 318 | (handler *routes*)) 319 | 320 | 321 | (defn render 322 | ` 323 | Re-renders a given GET route with a new request that you define. 324 | 325 | Good for re-rendering forms on errors. 326 | 327 | Example: 328 | 329 | (use osprey) 330 | 331 | (before "*" 332 | (content-type "text/html")) 333 | 334 | (GET "/" [:h1 "home"]) 335 | 336 | (GET "/form" 337 | [:main 338 | (when (request :errors) 339 | [:div (request :errors)]) 340 | 341 | (form {:action "/form"} 342 | [:input {:type "text" :name "name"}] 343 | [:input {:type "submit" :value "submit"}])]) 344 | 345 | (POST "/form" 346 | (if (empty? (get params :name "")) 347 | (render "/form" (merge request {:errors "name is blank"})) 348 | (redirect "/"))) 349 | ` 350 | [request url &opt req] 351 | (app (merge (or req request) {:uri url :method "GET"}))) 352 | 353 | 354 | (defn view 355 | ` 356 | Outputs the return value for a given route without re-running before macros 357 | 358 | Example: 359 | 360 | (use osprey) 361 | 362 | (before "*" (print "before")) 363 | 364 | (GET "/im" "i'm") 365 | 366 | (GET "/home" (string (view "/im") " home")) # outputs "i'm home" and doesn't print "before" 367 | ` 368 | [request uri] 369 | (let [route (-> (filter (fn [[method uri*]] (and (= method :get) (= uri uri*))) *routes*) 370 | (first)) 371 | f (last route)] 372 | (f request))) 373 | 374 | 375 | (defn- add-route [method uri f] 376 | (array/push *routes* [method uri f])) 377 | 378 | 379 | (defn- route-url [string-route &opt params] 380 | (default params @{}) 381 | (var mut-string-route string-route) 382 | (loop [[k v] :in (pairs params)] 383 | (set mut-string-route (string/replace (route-param k) (string v) mut-string-route)) 384 | (when (and (= k :*) 385 | (indexed? v)) 386 | (loop [wc* :in v] 387 | (set mut-string-route (string/replace "*" (string wc*) mut-string-route))))) 388 | mut-string-route) 389 | 390 | 391 | (defn form 392 | ` 393 | Form helper that outputs a form with a hidden input with the csrf-token defined 394 | and sets the method to POST 395 | 396 | Note: this only works inside of GET macros 397 | 398 | Example: 399 | 400 | (enable :sessions) 401 | (enable :csrf-tokens) 402 | 403 | (GET "/" 404 | (form {:action "/"} 405 | [:input {:type "text"}])) 406 | 407 | # => 408 | 409 | [:form {:action "/" :method "post"} 410 | [:input {:type "hidden" :name "__csrf-token" :value ""}] 411 | [:input {:type "text"}]] 412 | ` 413 | [csrf-token attrs & body] 414 | [:form (merge {:method "post"} attrs) 415 | (when csrf-token 416 | [:input {:type "hidden" :name "__csrf-token" :value csrf-token}]) 417 | ;body]) 418 | 419 | 420 | (defmacro GET 421 | `Creates a GET route 422 | 423 | Example: 424 | 425 | (GET "/" "home") 426 | ` 427 | [uri & *osprey-args*] 428 | (with-syms [$uri] 429 | ~(let [,$uri ,uri] 430 | (,add-route :get 431 | ,$uri 432 | (fn [request] 433 | (let [{:params params 434 | :body body 435 | :headers headers} request 436 | render (partial render request) 437 | form (partial form (get request :csrf-token)) 438 | view (partial view request)] 439 | (do ,;*osprey-args*))))))) 440 | 441 | 442 | (defmacro POST 443 | `Creates a POST route 444 | 445 | Example: 446 | 447 | (POST "/" (redirect "/elsewhere"))` 448 | [uri & *osprey-args*] 449 | (with-syms [$uri] 450 | ~(let [,$uri ,uri] 451 | (,add-route :post 452 | ,$uri 453 | (fn [request] 454 | (let [{:params params 455 | :body body 456 | :headers headers} request 457 | render (partial render request) 458 | form (partial form (get request :csrf-token)) 459 | view (partial view request)] 460 | (do ,;*osprey-args*))))))) 461 | 462 | 463 | (defmacro PUT 464 | [uri & *osprey-args*] 465 | (with-syms [$uri] 466 | ~(let [,$uri ,uri] 467 | (,add-route :put 468 | ,$uri 469 | (fn [request] 470 | (let [{:params params 471 | :body body 472 | :headers headers} request 473 | render (partial render request)] 474 | (do ,;*osprey-args*))))))) 475 | 476 | 477 | (defmacro PATCH 478 | [uri & *osprey-args*] 479 | (with-syms [$uri] 480 | ~(let [,$uri ,uri] 481 | (,add-route :patch 482 | ,$uri 483 | (fn [request] 484 | (let [{:params params 485 | :body body 486 | :headers headers} request 487 | render (partial render request)] 488 | (do ,;*osprey-args*))))))) 489 | 490 | 491 | (defmacro DELETE 492 | [uri & *osprey-args*] 493 | (with-syms [$uri] 494 | ~(let [,$uri ,uri] 495 | (,add-route :delete 496 | ,$uri 497 | (fn [request] 498 | (let [{:params params 499 | :body body 500 | :headers headers} request 501 | render (partial render request)] 502 | (do ,;*osprey-args*))))))) 503 | 504 | 505 | (defn- add-before [uri args] 506 | (array/push *before-fns* [uri args])) 507 | 508 | 509 | (defmacro before 510 | `Runs a bit of code before all routes defined by uri 511 | 512 | Examples: 513 | 514 | (before "*" 515 | (print "this code will run before all routes")) 516 | 517 | (before "/todos/*" 518 | (print "this code will run before all routes starting with /todos/")) 519 | ` 520 | [& *osprey-args*] 521 | (var uri "*") 522 | (var *osprey-args* *osprey-args*) 523 | 524 | (when (keyword? (first *osprey-args*)) 525 | (set uri (first *osprey-args*)) 526 | (set *osprey-args* (drop 1 *osprey-args*))) 527 | 528 | (with-syms [$uri] 529 | ~(let [,$uri ,uri] 530 | (,add-before ,$uri 531 | (fn [request] 532 | (let [{:headers headers 533 | :body body 534 | :params params 535 | :method method} request 536 | form (partial form (get request :csrf-token)) 537 | response (dyn :response)] 538 | (do ,;*osprey-args*))))))) 539 | 540 | 541 | (defn- add-after [uri args] 542 | (array/push *after-fns* [uri args])) 543 | 544 | 545 | (defn- add-osprey-after [uri args] 546 | (array/push *osprey-after-fns* [uri args])) 547 | 548 | 549 | (defmacro after 550 | `Deprecated. 551 | 552 | Try not to use this, it has weird side effects when combined 553 | with things like (enable), (not-found), and (layout)` 554 | [& *osprey-args*] 555 | (var uri "*") 556 | (var *osprey-args* *osprey-args*) 557 | 558 | (when (keyword? (first *osprey-args*)) 559 | (set uri (first *osprey-args*)) 560 | (set *osprey-args* (drop 1 *osprey-args*))) 561 | 562 | (with-syms [$uri] 563 | ~(let [,$uri ,uri] 564 | (,add-after ,$uri 565 | (fn [response &opt request] 566 | (let [{:headers headers 567 | :body body 568 | :params params 569 | :method method} request 570 | form (partial form (get request :csrf-token))] 571 | (do ,;*osprey-args*))))))) 572 | 573 | 574 | (defmacro- after-last [& *osprey-args*] 575 | (var uri "*") 576 | (var *osprey-args* *osprey-args*) 577 | 578 | (when (keyword? (first *osprey-args*)) 579 | (set uri (first *osprey-args*)) 580 | (set *osprey-args* (drop 1 *osprey-args*))) 581 | 582 | (with-syms [$uri] 583 | ~(let [,$uri ,uri] 584 | (,add-osprey-after ,$uri 585 | (fn [response request] 586 | (let [{:headers headers 587 | :body body 588 | :params params} request] 589 | (do ,;*osprey-args*))))))) 590 | 591 | 592 | (defn- set-not-found [args] 593 | (set *not-found-fn* args)) 594 | 595 | 596 | (defmacro not-found 597 | ` 598 | Runs a bit of code when a route or static file can't be found 599 | 600 | Example: 601 | 602 | (not-found 603 | (status :404) 604 | (content-type "text/html") 605 | 606 | [:h1 "not found"]) 607 | ` 608 | [& *osprey-body*] 609 | ~(,set-not-found (fn [request] 610 | (let [{:headers headers 611 | :params params 612 | :method method 613 | :body body} request] 614 | (do ,;*osprey-body*))))) 615 | 616 | 617 | (defn use-layout 618 | `Sets which layout to use if using named layouts` 619 | [name] 620 | (setdyn :layout name)) 621 | 622 | 623 | (defmacro layout 624 | ` 625 | Creates a layout which will wrap all janet-html responses. 626 | 627 | Also sets the content-type to "text/html" 628 | 629 | Create multiple layouts by passing a keyword as the first argument 630 | and calling use-layout. 631 | ` 632 | [& *osprey-args*] 633 | (var name :default) 634 | (var *args* *osprey-args*) 635 | 636 | (when (keyword? (first *osprey-args*)) 637 | (set name (first *osprey-args*)) 638 | (set *args* (drop 1 *osprey-args*))) 639 | 640 | (when (= name :default) 641 | (use-layout :default)) 642 | 643 | (with-syms [$name] 644 | ~(let [,$name ,name] 645 | 646 | (after "*" 647 | (if (and (tuple? response) 648 | (= (dyn :layout) ,$name)) 649 | (do 650 | (content-type "text/html") 651 | (html/encode ,;*args*)) 652 | 653 | response))))) 654 | 655 | 656 | (defn server 657 | `Start an http server listening on port 0 and localhost by default` 658 | [&opt port host] 659 | (default port 0) 660 | (default host "localhost") 661 | 662 | (halo2/server app port host)) 663 | 664 | 665 | # alias route-url to href 666 | # for anchor tags 667 | (def href :public 668 | `Helper for working with route urls. 669 | 670 | Example: 671 | 672 | (href "/todos/:id" {:id 1}) # => "/todos/1"` 673 | route-url) 674 | 675 | (def action :public 676 | `Helper for working with form actions. Same as href.` 677 | route-url) 678 | 679 | 680 | (defn redirect 681 | `Help for responding with a redirect response 682 | 683 | Examples: 684 | 685 | (redirect "/hello") => @{:status 302 :headers @{"Location" "/hello"}} 686 | 687 | # given a route that looks like this: 688 | (get "/todos/:id" [:div (string "todo " (params :id))]) 689 | 690 | (redirect "/todos/:id" {:id 1}) => @{:status 302 :headers @{"Location" "/todos/1"}}` 691 | [str &opt params] 692 | (default params @{}) 693 | (let [uri (route-url str params)] 694 | (setdyn :redirect @{:status 302 695 | :headers @{"Location" uri}}))) 696 | 697 | 698 | (defn- enable-static-files [public-folder] 699 | (after "*" 700 | (if response 701 | response 702 | (let [public-folder (or public-folder "public") 703 | path (request :path) 704 | file-path (if (string/has-suffix? "/" path) 705 | (string path "index.html") 706 | path)] 707 | (put (dyn :response) :file (path/join public-folder file-path)))))) 708 | 709 | 710 | (defn- enable-sessions [options] 711 | (set *session-secret* (get options :secret (cipher/encryption-key))) 712 | 713 | (before "*" 714 | (let [o-session (session/decrypt *session-secret* request)] 715 | (setdyn :session (get o-session :user @{})) 716 | (setdyn :flash (get o-session :flash @{})) 717 | (setdyn :flashed? (not (empty? (dyn :flash)))))) 718 | 719 | (after-last "*" 720 | (let [response (if (dictionary? response) 721 | (merge (dyn :response) response) 722 | (put (dyn :response) :body (string response)))] 723 | (as-> (session/encrypt *session-secret* 724 | {:user (dyn :session) 725 | :flash (if (dyn :flashed?) @{} (dyn :flash)) 726 | :flashed? (not (dyn :flashed?)) 727 | :csrf-token (dyn :csrf-token)}) ? 728 | (session/cookie ? options) 729 | (add-header response "Set-Cookie" ?))))) 730 | 731 | 732 | (defn- enable-csrf-tokens [&opt options] 733 | (default options {:skip []}) 734 | 735 | (before "*" 736 | (when (find (partial = (request :route-uri)) (options :skip)) 737 | (break)) 738 | 739 | (let [session (session/decrypt *session-secret* request)] 740 | (when (= "post" (string/ascii-lower method)) 741 | (unless (csrf/tokens-equal? (csrf/request-token headers params) (csrf/session-token session)) 742 | (halt @{:status 403 :body "Invalid CSRF Token" :headers @{"Content-Type" "text/plain"}}))) 743 | 744 | # set a new token 745 | (setdyn :csrf-token (get session :csrf-token (csrf/token))) 746 | 747 | # mask the token for forms 748 | (put request :csrf-token (csrf/mask (dyn :csrf-token)))))) 749 | 750 | 751 | (defn- enable-logging [&opt options] 752 | (def formats 753 | (or options 754 | (fn [start request response] 755 | (def {:uri uri 756 | :http-version version 757 | :method method 758 | :query-string qs} request) 759 | (def fulluri (if (and qs (pos? (length qs))) (string uri "?" qs) uri)) 760 | (def elapsed (* 1000 (- (os/clock) start))) 761 | (def status (or (get response :status) 200)) 762 | (printf "HTTP/%s %s %i %s elapsed %.3fms" version method status fulluri elapsed)))) 763 | 764 | (before "*" 765 | (put request :_start-clock (os/clock))) 766 | 767 | (after "*" 768 | (formats (request :_start-clock) request response) 769 | response)) 770 | 771 | 772 | (defn enable 773 | `Enable different middleware. 774 | 775 | Options are: 776 | 777 | - :static-files 778 | - :sessions 779 | - :csrf-tokens 780 | - :logging 781 | 782 | ## :static-files 783 | 784 | Serve static files from "public" directory. 785 | Pass in a string to change which directory they are served from. 786 | 787 | (enable :static-files) 788 | 789 | or 790 | 791 | (enable :static-files "static") 792 | 793 | ## :sessions 794 | 795 | Adds an encrypted session cookie to all responses, pass a dictionary to configure. 796 | 797 | Dictionary keys/values correspond to cookie keys/values, except for secret. 798 | 799 | The :secret key is for encrypting the session cookies and persisting that data between 800 | server restarts. If you don't pass it, when you restart the server, all existing 801 | session cookies will be invalid since there will be a new secret key. 802 | 803 | Default cookie headers look like this: 804 | 805 | Set-Cookie: session= SameSite=Lax; HttpOnly; Path=/ 806 | 807 | All cookie options: 808 | 809 | :samesite <"Lax" or "Strict" or "None"> 810 | :httponly 811 | :path 812 | :secret 813 | :domain 814 | :expires 815 | :max-age 816 | 817 | Example: 818 | 819 | (enable :sessions) 820 | 821 | or 822 | 823 | (enable :sessions {:secret (os/getenv "SECRET_KEY") :secure false :samesite "Strict"}) 824 | 825 | ## :csrf-tokens 826 | 827 | Enables csrf tokens on forms. Requires a call to (enable :sessions) as well. 828 | 829 | Example: 830 | 831 | (enable :csrf-tokens {:skip ["/stripe-web-hooks"]}) 832 | 833 | Pass a dictionary with :skip key to skip a set of routes, like: 834 | 835 | (enable :csrf-tokens) 836 | 837 | ## :logging 838 | 839 | Enables logging. Can pass a function as the argument to configure logging. 840 | 841 | See the amazing example put together by @pepe: https://github.com/swlkr/osprey/examples/logging.janet 842 | 843 | Example: 844 | 845 | (enable :logging) 846 | ` 847 | [key &opt val] 848 | (case key 849 | :static-files 850 | (enable-static-files val) 851 | 852 | :sessions 853 | (enable-sessions val) 854 | 855 | :csrf-tokens 856 | (enable-csrf-tokens val) 857 | 858 | :logging 859 | (enable-logging val))) 860 | -------------------------------------------------------------------------------- /src/osprey/session.janet: -------------------------------------------------------------------------------- 1 | (import cipher) 2 | (import ./helpers :prefix "") 3 | 4 | 5 | (def cookie-peg (peg/compile '{:main (some (sequence :pair (opt "; "))) 6 | :pair (sequence (capture :key) (opt "=") (capture :value)) 7 | :value (opt :allowed) 8 | :key :allowed 9 | :allowed (some (if-not (set "=;") 1))})) 10 | 11 | 12 | (defn find-cookie [request name] 13 | (as-> (get-in request [:headers "Cookie"] "") ? 14 | (peg/match cookie-peg ?) 15 | (or ? []) 16 | (struct ;?) 17 | (get ? name))) 18 | 19 | 20 | (defn decrypt [key request] 21 | (when-let [cookie (find-cookie request "session")] 22 | (try 23 | (as-> cookie ? 24 | (cipher/decrypt key ?) 25 | (parse ?)) 26 | ([_] nil)))) 27 | 28 | 29 | (defn encrypt [key value] 30 | (->> (string/format "%j" value) 31 | (cipher/encrypt key))) 32 | 33 | 34 | (defn cookie [session options] 35 | (def default-options {:samesite "Lax" 36 | :httponly true 37 | :path "/" 38 | :secure true}) 39 | 40 | (let [{:samesite samesite 41 | :httponly httponly 42 | :path path 43 | :secure secure 44 | :domain domain 45 | :expires expires 46 | :max-age max-age} (merge default-options options) 47 | 48 | parts [(string "session=" session) 49 | (if samesite (string "SameSite=" samesite) "") 50 | (if httponly "HttpOnly" "") 51 | (if path (string "Path=" path) "") 52 | (if secure "Secure" "") 53 | (if domain (string "Domain=" domain) "") 54 | (if expires (string "Expires=" expires) "") 55 | (if max-age (string "Max-Age=" max-age) "")]] 56 | 57 | (-> (filter (comp not empty?) parts) 58 | (string/join "; ")))) 59 | -------------------------------------------------------------------------------- /test/osprey-test.janet: -------------------------------------------------------------------------------- 1 | (import ../src/osprey :prefix "") 2 | (import tester :prefix "" :exit true) 3 | 4 | (defsuite "readme" 5 | (test "osprey works" 6 | (is (deep= @{:status 200 :body "osprey" :headers @{"Content-Type" "text/plain"}} 7 | (do 8 | (GET "/" "osprey") 9 | (app @{:uri "/" :method "GET"})))))) 10 | -------------------------------------------------------------------------------- /test/osprey/multipart-test.janet: -------------------------------------------------------------------------------- 1 | (import ../../src/osprey/multipart) 2 | (import tester :prefix "" :exit true) 3 | 4 | (defsuite "multipart" 5 | (test "multipart parsing works" 6 | (is (deep= @{:__csrf-token "05f57ebf2265388a72dda844e69ffc92ffd2ad23545d664e14ba630c6d0949a5150082650dfa3764b6e211e08379231fb96db77a39bb410089d291c0719f90af" 7 | :email "test12@example.com"} 8 | (multipart/params @{:headers @{"Content-Type" "multipart/form-data; boundary=----WebKitFormBoundary2KcsybfKqIt05O6D"} 9 | :body "------WebKitFormBoundary2KcsybfKqIt05O6D\r\nContent-Disposition: form-data; name=\"__csrf-token\"\r\n\r\n05f57ebf2265388a72dda844e69ffc92ffd2ad23545d664e14ba630c6d0949a5150082650dfa3764b6e211e08379231fb96db77a39bb410089d291c0719f90af\r\n------WebKitFormBoundary2KcsybfKqIt05O6D\r\nContent-Disposition: form-data; name=\"email\"\r\n\r\ntest12@example.com\r\n------WebKitFormBoundary2KcsybfKqIt05O6D--"}))))) 10 | --------------------------------------------------------------------------------