├── .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 |
--------------------------------------------------------------------------------