Skip to content

Commit ca7eb62

Browse files
committed
feat(backend): refactor and document
This commit significantly cleans up the routes and middleware stack, and adds documentation to the same. The refactor resolves several problems with routes where format transformation, authentication, and others were not happening in the right order. It also removes several redundant or unnecessary middlewares. The result is a working Transit API + server-side rendered HTML pages.
1 parent d00fcf3 commit ca7eb62

10 files changed

+332
-199
lines changed

src/main/parts/api/account.clj

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"Update own account info"
1717
[request]
1818
(let [user-id (get-in request [:identity :sub])
19-
body (:body request)
19+
body (:body-params request)
2020
updated-user (user/update! user-id body)]
2121
(mulog/log ::update-account-success :user-id user-id)
2222
(-> (response/response updated-user)
@@ -25,7 +25,7 @@
2525
(defn register-account
2626
"Creates a record for a new user account"
2727
[request]
28-
(let [account (user/create! (:body request))]
28+
(let [account (user/create! (:body-params request))]
2929
(mulog/log ::register :email (:email account) :username (:username account) :status :success)
3030
(-> (response/response account)
3131
(response/status 201))))

src/main/parts/api/auth.clj

+9-5
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
[ring.util.response :as response]))
66

77
(defn login
8+
"Handler for the POST /api/auth/login endpoint.
9+
Generate new access and refresh tokens by authenticating via email/password"
810
[request]
9-
(let [{:keys [email password]} (:body request)]
11+
(let [{:keys [email password]} (:body-params request)]
1012
(if-let [tokens (auth/authenticate {:email email :password password})]
1113
(do
1214
(mulog/log ::login :email email :status :success)
@@ -18,9 +20,10 @@
1820
(response/status 401))))))
1921

2022
(defn refresh
21-
"Generate new access and refresh tokens using a valid refresh token"
23+
"Handler for the POST /api/auth/refresh endpoint.
24+
Generate new access and refresh tokens using a valid refresh token"
2225
[request]
23-
(let [refresh-token (get-in request [:body :refresh_token])]
26+
(let [refresh-token (get-in request [:body-params :refresh_token])]
2427
(if-let [new-tokens (auth/refresh-auth-tokens refresh-token)]
2528
(do
2629
(mulog/log ::refresh :status :success)
@@ -32,9 +35,10 @@
3235
(response/status 401))))))
3336

3437
(defn logout
35-
"Invalidate the refresh token to prevent its future use"
38+
"Handler for the POST /api/auth/logout endpoint.
39+
Invalidate the refresh token to prevent its future use"
3640
[request]
37-
(let [refresh-token (get-in request [:body :refresh_token])]
41+
(let [refresh-token (get-in request [:body-params :refresh_token])]
3842
(if refresh-token
3943
(do
4044
(auth/invalidate-refresh-token refresh-token)

src/main/parts/api/systems.clj

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
(ns parts.api.systems
22
(:require
3-
[parts.auth :as auth]
43
[parts.entity.system :as system]
54
[ring.util.response :as response]))
65

@@ -14,9 +13,9 @@
1413

1514
(defn create-system
1615
"Create a new system"
17-
[{:keys [identity body] :as _request}]
16+
[{:keys [identity body-params] :as _request}]
1817
(let [user-id (:sub identity)
19-
system-data (assoc body :owner_id user-id)
18+
system-data (assoc body-params :owner_id user-id)
2019
created (system/create-system! system-data)]
2120
(-> (response/response created)
2221
(response/status 201))))
@@ -35,13 +34,13 @@
3534

3635
(defn update-system
3736
"Update an existing system"
38-
[{:keys [identity parameters body] :as _request}]
37+
[{:keys [identity parameters body-params] :as _request}]
3938
(let [user-id (:sub identity)
4039
system-id (get-in parameters [:path :id])
4140
existing (system/get-system system-id)]
4241
(if (= user-id (:owner_id existing))
4342
(let [updated (system/update-system! system-id
44-
(assoc body :owner_id (:owner_id existing)))]
43+
(assoc body-params :owner_id (:owner_id existing)))]
4544
(-> (response/response updated)
4645
(response/status 200)))
4746
(-> (response/response {:error "Not authorized"})

src/main/parts/middleware.clj

+132-26
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,38 @@
66
[com.brunobonacci.mulog :as mulog]
77
[parts.auth :as auth]
88
[reitit.ring.middleware.exception :as exception]
9-
[ring.middleware.content-type :refer [wrap-content-type]]
10-
[ring.middleware.defaults :refer [site-defaults wrap-defaults]]
9+
[ring.middleware.anti-forgery :refer [wrap-anti-forgery]]
10+
[ring.middleware.defaults :refer [api-defaults wrap-defaults site-defaults]]
1111
[ring.middleware.resource :refer [wrap-resource]]
12-
[ring.middleware.session :refer [wrap-session]]
13-
[ring.middleware.session.cookie :refer [cookie-store]]
1412
[ring.util.response :as response])
1513
(:import
1614
(org.sqlite SQLiteException)))
1715

18-
(defn exception-handler
19-
"Generic exceptions handler"
16+
(defn- exception-handler
17+
"Generic exceptions handler used by the `exception` middleware.
18+
19+
Sets the response status to the provided `status`, and sets the response
20+
message to the error message retrieved from the exception, or, failing that,
21+
to the `message` provided."
2022
[message status]
2123
(fn [^Exception e _request]
2224
(let [error-message (.getMessage e)]
2325
{:status status
2426
:body {:error (or error-message message)}})))
2527

2628
(def sqlite-errors
29+
"A map containing substrings of a SQLiteException error message, and the
30+
corresponding user friendly error message."
2731
{"UNIQUE constraint failed" "A resource with this unique identifier already exists"
2832
"CHECK constraint failed" "The provided data does not meet the required constraints"
2933
"NOT NULL constraint failed" "A required field was missing"
3034
"FOREIGN KEY constraint failed" "The referenced resource does not exist"})
3135

3236
(defn sqlite-constraint-violation-handler
33-
"Handler for SQLite-specific exceptions"
37+
"Handler for SQLite-specific exceptions.
38+
39+
If the error message includes a string that is a key in `sqlite-errors`, the
40+
error message will be the corresponding value; otherwise a generic message."
3441
[^SQLiteException e _request]
3542
(let [error-message (.getMessage e)]
3643
(mulog/log ::sqlite-exception :error error-message)
@@ -41,7 +48,9 @@
4148
"A database constraint was violated")}}))
4249

4350
(def exception
44-
"Middleware handling exceptions"
51+
"Middleware handling exceptions. Combines the default exception handlers from
52+
Reitit with cutom handlers. New custom handlers should be added to this
53+
function."
4554
(exception/create-exception-middleware
4655
(merge
4756
exception/default-handlers
@@ -50,61 +59,158 @@
5059

5160
:not-found (exception-handler "Resource not found" 404)
5261

53-
;; SQLite exceptions
62+
;; SQLite exceptions
5463
SQLiteException sqlite-constraint-violation-handler
5564

56-
;; Default
65+
;; Default
5766
::exception/default
5867
(fn [^Exception e _request]
5968
(mulog/log ::unhandled-exception :error (.getMessage e))
6069
{:status 500
6170
:body {:error "Internal server error"}})})))
6271

6372
(defn logging
64-
"Middleware logging each incoming request"
73+
"Middleware logging each incoming request with minimal information.
74+
75+
This middleware is called before Muuntaja converts the input into clojure
76+
objects, so to `:body` value is a `java.io.InputStream`, which is not easily
77+
inspectable. Later in the lifecycle, Muuntaja will insert a parsed body under
78+
the `:parsed-params` key."
6579
[handler]
6680
(fn [request]
6781
(let [user-id (get-in request [:identity :sub])
82+
request-info {:uri (:uri request)
83+
:request-method (:request-method request)
84+
:query-params (:query-params request)
85+
:remote-addr (:remote-addr request)
86+
:user-agent (get-in request [:headers "user-agent"])}
6887
authenticated? (boolean user-id)]
69-
(mulog/log ::request :request request, :authenticated? authenticated? :user-id user-id)
88+
(mulog/log ::request :info request-info :authenticated? authenticated? :user-id user-id)
7089
(handler request))))
7190

7291
(defn wrap-jwt-authentication
73-
"Middleware adding JWT authentication to a route"
92+
"Middleware adding JWT authentication to a route. A route with this middleware
93+
applied will have an authentication status which can be validated against."
7494
[handler]
7595
(-> handler
7696
(wrap-authentication auth/backend)
7797
(wrap-authorization auth/backend)))
7898

7999
(defn jwt-auth
80-
"Middleware ensuring a route is only accessible to authenticated users"
100+
"Middleware ensuring a route is only accessible to authenticated users. "
81101
[handler]
82102
(fn [request]
83103
(if (authenticated? request)
84104
(handler request)
85105
(-> (response/response {:error "Unauthorized"})
86106
(response/status 401)))))
87107

88-
(defn wrap-default-middlewares
89-
"Wrap in the middlewares defined by ring-defaults"
108+
(defn wrap-html-defaults
109+
"Middleware that applies a customized set of Ring defaults for HTML routes.
110+
111+
This middleware configures a subset of Ring's `site-defaults` that's
112+
appropriate for server-rendered HTML pages.
113+
114+
Applied configurations:
115+
- Parameters: Parses standard, nested, and keyword parameters
116+
- Static resources: Enabled
117+
- Security headers:
118+
- X-Frame-Options
119+
- X-Content-Type-Options
120+
- X-XSS-Protection
121+
- X-Permitted-Cross-Domain-Policies
122+
- X-Download-Options
123+
- Cookie response attributes
124+
125+
Explicitly disabled:
126+
- Session handling: Disabled completely as we use stateless JWT auth
127+
- Global anti-forgery: Disabled here but applied selectively to form-handling
128+
routes, see `anti-forgery`
129+
130+
Usage: Apply this middleware to routes that serve HTML content, and separately
131+
apply `anti-forgery` middleware only to routes that process form submissions."
132+
[handler]
133+
(wrap-defaults
134+
handler
135+
(-> site-defaults
136+
(assoc :session false)
137+
(assoc :security
138+
(-> (:security site-defaults)
139+
(assoc :anti-forgery false))))))
140+
141+
;; TODO: Investigate whether Content Security Policy is needed:
142+
;; - https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
143+
;; - https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
144+
(defn wrap-api-defaults
145+
"Apply default middleware for API routes.
146+
147+
This uses standard `api-defaults` (not secure-api-defaults) as the base
148+
configuration, making it suitable for applications behind a reverse proxy that
149+
handles SSL termination.
150+
151+
Security enhancements:
152+
- Sets X-Frame-Options to SAMEORIGIN to prevent clickjacking attacks
153+
- Sets X-Content-Type-Options to nosniff to prevent MIME type sniffing
154+
- Enables X-XSS-Protection with mode=block to activate browser XSS filters
155+
156+
Notable omissions:
157+
- No HTTPS enforcement (handled by reverse proxy)
158+
- No Strict-Transport-Security header (better set at proxy level)
159+
- No Content-Security-Policy"
160+
[handler]
161+
(wrap-defaults
162+
handler
163+
(-> api-defaults
164+
(assoc-in [:security :frame-options] :sameorigin)
165+
(assoc-in [:security :content-type-options] :nosniff)
166+
(assoc-in [:security :xss-protection] {:mode :block}))))
167+
168+
(defn wrap-core-middlewares
169+
"Apply essential Ring middleware for the entire application. Currently, this
170+
is only `wrap-resources`, which allows to download static files from
171+
resources/public."
90172
[handler]
91173
(-> handler
92-
(wrap-defaults (-> site-defaults
93-
(assoc-in [:session :store] (cookie-store))))
94-
(wrap-resource "public")
95-
(wrap-session)
96-
(wrap-content-type)))
97-
98-
;; FIXME: This feels like it shouldn't need a custom middleware, right? Is there
99-
;; a middleware supplied by either httpkit or hiccup2 already?
174+
(wrap-resource "public")))
175+
100176
(defn wrap-html-response
101-
"Set content type to text/html and convert response to string"
177+
"Middleware for properly formatting HTML responses.
178+
179+
This middleware performs two essential transformations for HTML routes:
180+
181+
1. It ensures the response body is a string by calling `str` on it. This
182+
handles various body types like Hiccup structures or other Clojure data that
183+
should be rendered as HTML.
184+
185+
2. It sets the Content-Type header to 'text/html; charset=utf-8' if no
186+
Content-Type is already specified, ensuring browsers properly interpret the
187+
response.
188+
189+
This middleware only processes responses that:
190+
- Are proper Ring response maps
191+
- Don't already have a Content-Type header set
192+
193+
Usage: Apply this middleware to routes that return HTML content. It's an
194+
alternative to using Muuntaja for HTML formatting, which is more appropriate
195+
for data formats rather than presentation formats."
102196
[handler]
103197
(fn [request]
104198
(let [response (handler request)]
105199
(if (and (map? response)
106200
(not (get-in response [:headers "Content-Type"])))
107201
(-> response
108202
(update :body str)
109-
(assoc-in [:headers "Content-Type"] "text/html"))
203+
(assoc-in [:headers "Content-Type"] "text/html; charset=utf-8"))
110204
response))))
205+
206+
(def anti-forgery
207+
"Anti-forgery middleware for HTML forms, see docs on `wrap-anti-forgery`"
208+
wrap-anti-forgery)
209+
210+
;; File upload handling is commented out for now
211+
;; To enable file uploads, uncomment the following:
212+
;; (defn wrap-multipart-params
213+
;; "Handle multipart form data including file uploads"
214+
;; [handler]
215+
;; (-> handler
216+
;; (multipart-params/wrap-multipart-params)))

0 commit comments

Comments
 (0)