diff --git a/src/territory_bro/infra/middleware.clj b/src/territory_bro/infra/middleware.clj index b6781339..c70d0457 100644 --- a/src/territory_bro/infra/middleware.clj +++ b/src/territory_bro/infra/middleware.clj @@ -3,7 +3,8 @@ ;; The license text is at http://www.apache.org/licenses/LICENSE-2.0 (ns territory-bro.infra.middleware - (:require [clojure.tools.logging :as log] + (:require [clojure.string :as str] + [clojure.tools.logging :as log] [ring-ttl-session.core :as ttl-session] [ring.logger :as logger] [ring.middleware.defaults :refer [site-defaults wrap-defaults]] @@ -75,6 +76,27 @@ (refresh-projections!)) resp))) +(defn- static-asset? [path] + (or (str/starts-with? path "/assets/") + (= "/favicon.ico" path))) + +(defn- content-hashed? [path] + (some? (re-find #"-[0-9a-f]{8,40}\.\w+$" path))) + +(defn wrap-cache-control [handler] + (fn [request] + (let [response (handler request) + path (:uri request)] + (if (some? (response/get-header response "cache-control")) + response + (response/header response "Cache-Control" + (if (and (= 200 (:status response)) + (static-asset? path)) + (if (content-hashed? path) + "public, max-age=2592000, immutable" + "public, max-age=3600, stale-while-revalidate=86400") + "private, no-cache")))))) + (defn wrap-base [handler] (-> handler wrap-auto-refresh-projections @@ -90,4 +112,5 @@ (assoc-in [:security :anti-forgery] false) ; TODO: enable CSRF, create a custom error page for it (assoc-in [:session :store] session-store) (assoc-in [:session :flash] false))) + wrap-cache-control wrap-internal-error)) diff --git a/test/territory_bro/infra/middleware_test.clj b/test/territory_bro/infra/middleware_test.clj new file mode 100644 index 00000000..d8476cd2 --- /dev/null +++ b/test/territory_bro/infra/middleware_test.clj @@ -0,0 +1,68 @@ +;; Copyright © 2015-2024 Esko Luontola +;; This software is released under the Apache License 2.0. +;; The license text is at http://www.apache.org/licenses/LICENSE-2.0 + +(ns territory-bro.infra.middleware-test + (:require [clojure.test :refer :all] + [ring.util.http-response :as http-response] + [ring.util.response :as response] + [territory-bro.infra.middleware :as middleware])) + +(deftest wrap-cache-control-test + (testing "SSR pages are not cached" + (let [handler (-> (constantly (http-response/ok "")) + middleware/wrap-cache-control)] + (is (= {:status 200 + :headers {"Cache-Control" "private, no-cache"} + :body ""} + (handler {:request-method :get + :uri "/"}) + (handler {:request-method :get + :uri "/some/page"}))))) + + (testing "if response contains a custom cache-control header, that one is used" + (let [handler (-> (constantly (-> (http-response/ok "") + (response/header "Cache-Control" "custom value"))) + middleware/wrap-cache-control)] + (is (= {:status 200 + :headers {"Cache-Control" "custom value"} + :body ""} + (handler {:request-method :get + :uri "/"}) + (handler {:request-method :get + :uri "/some/page"}))))) + + (testing "static assets are cached for one hour" + (let [handler (-> (constantly (http-response/ok "")) + middleware/wrap-cache-control)] + (is (= {:status 200 + :headers {"Cache-Control" "public, max-age=3600, stale-while-revalidate=86400"} + :body ""} + (handler {:request-method :get + :uri "/favicon.ico"}) + (handler {:request-method :get + :uri "/assets/style.css"}))))) + + (testing "static assets with a content hash are cached indefinitely" + (let [handler (-> (constantly (http-response/ok "")) + middleware/wrap-cache-control)] + (is (= {:status 200 + :headers {"Cache-Control" "public, max-age=2592000, immutable"} + :body ""} + (handler {:request-method :get + :uri "/assets/style-4da573e6.css"}) + (handler {:request-method :get + :uri "/assets/image-28ead48996a4ca92f07ee100313e57355dbbcbf2.svg"}))))) + + (testing "error responses are not cached" + (let [handler (-> (constantly (http-response/not-found "")) + middleware/wrap-cache-control)] + (is (= {:status 404 + :headers {"Cache-Control" "private, no-cache"} + :body ""} + (handler {:request-method :get + :uri "/some/page"}) + (handler {:request-method :get + :uri "/assets/style.css"}) + (handler {:request-method :get + :uri "/assets/style-4da573e6.css"}))))))