mirror of
https://github.com/penpot/penpot.git
synced 2026-09-10 05:58:47 +00:00
♻️ Rename unfurl to link-preview and harden preview endpoint
Rename the whole feature from unfurl to link-preview: handler namespace, template, /link-preview route, :link-preview flag, nginx variable and docs page. Make the flag opt-in by removing it from the default flags. Serve file-thumbnail objects with full public semantics while the flag is on, restrict the route to GET/HEAD, make a present file-id decisive over project/team context, preserve the query string on the human redirect and skip no-op replaceState writes. Cover the new behavior with backend and frontend tests. AI-assisted-by: muse-spark-1.3-contributor Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
471493cd02
commit
3bc46a21dc
@ -17,6 +17,6 @@
|
||||
<meta name="twitter:image" content="{{image}}" />
|
||||
</head>
|
||||
<body>
|
||||
<script>location.replace("/" + location.hash);</script>
|
||||
<script>location.replace("/" + location.search + location.hash);</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -62,7 +62,7 @@ export PENPOT_FLAGS="\
|
||||
enable-file-validation \
|
||||
enable-file-schema-validation \
|
||||
enable-redis-cache \
|
||||
enable-link-unfurl \
|
||||
enable-link-preview \
|
||||
enable-subscriptions";
|
||||
|
||||
# Uncomment for nexus integration testing
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
[app.http.awsns :as-alias awsns]
|
||||
[app.http.debug :as-alias debug]
|
||||
[app.http.errors :as errors]
|
||||
[app.http.link-preview :as-alias link-preview]
|
||||
[app.http.management :as mgmt]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.security :as sec]
|
||||
[app.http.session :as session]
|
||||
[app.http.unfurl :as-alias unfurl]
|
||||
[app.http.websocket :as-alias ws]
|
||||
[app.main :as-alias main]
|
||||
[app.metrics :as mtx]
|
||||
@ -150,7 +150,7 @@
|
||||
[::rpc/routes schema:routes]
|
||||
[::oidc/routes schema:routes]
|
||||
[::assets/routes schema:routes]
|
||||
[::unfurl/routes schema:routes]
|
||||
[::link-preview/routes schema:routes]
|
||||
[::debug/routes schema:routes]
|
||||
[::mtx/routes schema:routes]
|
||||
[::awsns/routes schema:routes]
|
||||
@ -179,7 +179,7 @@
|
||||
|
||||
(::mtx/routes cfg)
|
||||
(::assets/routes cfg)
|
||||
(::unfurl/routes cfg)
|
||||
(::link-preview/routes cfg)
|
||||
(::debug/routes cfg)
|
||||
|
||||
["/webhooks"
|
||||
|
||||
@ -36,6 +36,14 @@
|
||||
"file-data-fragment"
|
||||
"organization"})
|
||||
|
||||
(defn- public-bucket?
|
||||
[bucket]
|
||||
(or (contains? public-buckets bucket)
|
||||
;; Dashboard file thumbnails become public when link previews
|
||||
;; are enabled, so link preview crawlers can fetch them.
|
||||
(and (= "file-thumbnail" bucket)
|
||||
(contains? cf/flags :link-preview))))
|
||||
|
||||
(defn get-id
|
||||
[{:keys [path-params]}]
|
||||
(or (some-> path-params :id d/parse-uuid)
|
||||
@ -57,7 +65,7 @@
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
bucket (-> obj meta :bucket)
|
||||
public? (contains? public-buckets bucket)
|
||||
public? (public-bucket? bucket)
|
||||
;; The disposition is also signed into the presigned url: this
|
||||
;; response is a redirect, so the header below applies to the
|
||||
;; redirect itself and not to the bytes the client then fetches
|
||||
@ -85,7 +93,7 @@
|
||||
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(not (public-bucket? bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
@ -98,14 +106,6 @@
|
||||
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
||||
|
||||
(defn- public-bucket?
|
||||
[bucket]
|
||||
(or (contains? public-buckets bucket)
|
||||
;; Dashboard file thumbnails become public when link unfurling
|
||||
;; is enabled, so link preview crawlers can fetch them.
|
||||
(and (= "file-thumbnail" bucket)
|
||||
(contains? cf/flags :link-unfurl))))
|
||||
|
||||
(defn- requires-auth?
|
||||
"Check if the storage object requires authentication based on its bucket."
|
||||
[obj]
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.http.unfurl
|
||||
"Link unfurl (Open Graph metadata) related handlers.
|
||||
(ns app.http.link-preview
|
||||
"Link preview (Open Graph metadata) related handlers.
|
||||
|
||||
Serves a minimal HTML page with Open Graph metadata used by link
|
||||
preview crawlers (Slack, Discord, Twitter, ...). The reverse proxy
|
||||
@ -44,7 +44,7 @@
|
||||
(str (cf/get :public-uri) "/images/penpot-link-preview.png"))
|
||||
|
||||
(defn- get-file-context
|
||||
"Return the unfurl context for a file link: the file name as title
|
||||
"Return the link preview context for a file link: the file name as title
|
||||
and, when available, the last dashboard thumbnail as image."
|
||||
[pool file-id]
|
||||
(when-let [{:keys [name media-id]} (db/exec-one! pool [sql:get-file file-id])]
|
||||
@ -54,24 +54,28 @@
|
||||
|
||||
(defn- get-context
|
||||
[pool params]
|
||||
(let [file-id (some-> (:file-id params) d/parse-uuid)
|
||||
project-id (some-> (:project-id params) d/parse-uuid)
|
||||
(let [project-id (some-> (:project-id params) d/parse-uuid)
|
||||
team-id (some-> (:team-id params) d/parse-uuid)]
|
||||
(cond
|
||||
(some? file-id) (get-file-context pool file-id)
|
||||
(some? project-id) (assoc default-context :title "Project | Penpot")
|
||||
(some? team-id) (assoc default-context :title "Team dashboard | Penpot"))))
|
||||
;; A present file-id is decisive: file links never fall through to the
|
||||
;; project/team card, even when the value is malformed or unknown (both
|
||||
;; yield nil and the handler falls back to the default context).
|
||||
(if (contains? params :file-id)
|
||||
(when-some [file-id (d/parse-uuid (:file-id params))]
|
||||
(get-file-context pool file-id))
|
||||
(cond
|
||||
(some? project-id) (assoc default-context :title "Project | Penpot")
|
||||
(some? team-id) (assoc default-context :title "Team dashboard | Penpot")))))
|
||||
|
||||
(defn- handler
|
||||
[{:keys [::db/pool]} request]
|
||||
(let [context (when (contains? cf/flags :link-unfurl)
|
||||
(let [context (when (contains? cf/flags :link-preview)
|
||||
(get-context pool (:query-params request)))
|
||||
context (-> (or context default-context)
|
||||
(update :image #(or % (resolve-default-image-uri))))]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
"cache-control" "no-store, no-cache, max-age=0"}
|
||||
::yres/body (-> (io/resource "app/templates/unfurl.tmpl")
|
||||
::yres/body (-> (io/resource "app/templates/link-preview.tmpl")
|
||||
(tmpl/render context))}))
|
||||
|
||||
;; --- Initialization
|
||||
@ -82,4 +86,5 @@
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
["/unfurl" {:handler (partial handler cfg)}])
|
||||
["/link-preview" {:handler (partial handler cfg)
|
||||
:allowed-methods #{:get :head}}])
|
||||
@ -20,10 +20,10 @@
|
||||
[app.http.awsns :as http.awsns]
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.debug :as-alias http.debug]
|
||||
[app.http.link-preview :as-alias http.link-preview]
|
||||
[app.http.management :as mgmt]
|
||||
[app.http.session :as session]
|
||||
[app.http.session.tasks :as-alias session.tasks]
|
||||
[app.http.unfurl :as-alias http.unfurl]
|
||||
[app.http.websocket :as http.ws]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.metrics :as-alias mtx]
|
||||
@ -284,11 +284,11 @@
|
||||
::mgmt/routes (ig/ref ::mgmt/routes)
|
||||
::http.debug/routes (ig/ref ::http.debug/routes)
|
||||
::http.assets/routes (ig/ref ::http.assets/routes)
|
||||
::http.unfurl/routes (ig/ref ::http.unfurl/routes)
|
||||
::http.link-preview/routes (ig/ref ::http.link-preview/routes)
|
||||
::http.ws/routes (ig/ref ::http.ws/routes)
|
||||
::http.awsns/routes (ig/ref ::http.awsns/routes)}
|
||||
|
||||
::http.unfurl/routes
|
||||
::http.link-preview/routes
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::http.debug/routes
|
||||
|
||||
@ -158,9 +158,9 @@
|
||||
;; Tests: objects-handler — non-public buckets (auth required)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest objects-handler-file-thumbnail-bucket-link-unfurl-flag
|
||||
(t/deftest objects-handler-file-thumbnail-bucket-link-preview-flag
|
||||
;; Objects in the file-thumbnail bucket are public only when the
|
||||
;; link-unfurl flag is enabled.
|
||||
;; link-preview flag is enabled.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
@ -168,13 +168,13 @@
|
||||
request {:path-params {:id (str (:id object))}}]
|
||||
|
||||
(t/testing "flag enabled"
|
||||
(with-redefs [cf/flags (conj cf/flags :link-unfurl)]
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (assets/objects-handler cfg request)]
|
||||
(t/is (not= 401 (::yres/status response)))
|
||||
(t/is (not= 404 (::yres/status response))))))
|
||||
|
||||
(t/testing "flag disabled"
|
||||
(with-redefs [cf/flags (disj cf/flags :link-unfurl)]
|
||||
(with-redefs [cf/flags (disj cf/flags :link-preview)]
|
||||
(let [response (assets/objects-handler cfg request)]
|
||||
(t/is (= 401 (::yres/status response))))))))
|
||||
|
||||
@ -240,8 +240,8 @@
|
||||
profile (th/create-profile* 1)]
|
||||
|
||||
;; NOTE: file-thumbnail is not included here because it is public
|
||||
;; when the link-unfurl flag is enabled; see
|
||||
;; objects-handler-file-thumbnail-bucket-link-unfurl-flag.
|
||||
;; when the link-preview flag is enabled; see
|
||||
;; objects-handler-file-thumbnail-bucket-link-preview-flag.
|
||||
(doseq [bucket ["profile"
|
||||
"tempfile"
|
||||
"file-data"
|
||||
|
||||
211
backend/test/backend_tests/http_link_preview_test.clj
Normal file
211
backend/test/backend_tests/http_link_preview_test.clj
Normal file
@ -0,0 +1,211 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.http-link-preview-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.link-preview :as link-preview]
|
||||
[app.storage :as sto]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each (th/serial
|
||||
th/database-reset
|
||||
th/clean-storage))
|
||||
|
||||
(def ^:private default-title
|
||||
"Penpot | Full-stack design")
|
||||
|
||||
(defn- run-handler
|
||||
[query-params]
|
||||
(let [cfg {::db/pool (:app.db/pool th/*system*)}]
|
||||
(#'link-preview/handler cfg {:query-params query-params})))
|
||||
|
||||
(defn- create-file-thumbnail!
|
||||
[file-id]
|
||||
(let [storage (::sto/storage th/*system*)
|
||||
object (sto/put-object! storage {::sto/content (sto/content "thumbnail data")
|
||||
:bucket "file-thumbnail"
|
||||
:content-type "image/png"})]
|
||||
(db/insert! (:app.db/pool th/*system*) :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn 1
|
||||
:media-id (:id object)})
|
||||
object))
|
||||
|
||||
(t/deftest link-preview-without-params
|
||||
(let [response (run-handler {})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (str/includes? (::yres/body response) "/images/penpot-link-preview.png"))))
|
||||
|
||||
(t/deftest link-preview-file-without-thumbnail
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})]
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (str/includes? (::yres/body response) "/images/penpot-link-preview.png"))))))
|
||||
|
||||
(t/deftest link-preview-file-with-thumbnail
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
object (create-file-thumbnail! (:id file))]
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (str/includes? (::yres/body response) (str "/assets/by-id/" (:id object))))))))
|
||||
|
||||
(t/deftest link-preview-non-existent-file
|
||||
(let [response (run-handler {:file-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))))
|
||||
|
||||
(t/deftest link-preview-invalid-file-id
|
||||
(let [response (run-handler {:file-id "not-a-uuid"})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))))
|
||||
|
||||
(t/deftest link-preview-team-link
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:team-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) "Team dashboard | Penpot")))))
|
||||
|
||||
(t/deftest link-preview-project-link
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:team-id (str (uuid/next))
|
||||
:project-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) "Project | Penpot")))))
|
||||
|
||||
(t/deftest link-preview-flag-disabled
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})]
|
||||
(with-redefs [cf/flags (disj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (not (str/includes? (::yres/body response) (:name file))))))))
|
||||
|
||||
(t/deftest link-preview-deleted-file
|
||||
;; A deleted file never leaks its name; crawlers get the generic card.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})]
|
||||
(th/mark-file-deleted* {:id (:id file)})
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (not (str/includes? (::yres/body response) (:name file))))))))
|
||||
|
||||
(t/deftest link-preview-file-with-only-deleted-thumbnail
|
||||
;; A file whose only thumbnail is deleted keeps its title but falls back
|
||||
;; to the default image.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
object (create-file-thumbnail! (:id file))]
|
||||
(db/update! th/*system* :file-thumbnail
|
||||
{:deleted-at (ct/now)}
|
||||
{:file-id (:id file)})
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (str/includes? (::yres/body response) "/images/penpot-link-preview.png"))
|
||||
(t/is (not (str/includes? (::yres/body response) (str (:id object)))))))))
|
||||
|
||||
(t/deftest link-preview-file-picks-latest-thumbnail
|
||||
;; With several thumbnail revisions, the latest non-deleted one wins.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
pool (:app.db/pool th/*system*)
|
||||
storage (::sto/storage th/*system*)
|
||||
old (sto/put-object! storage {::sto/content (sto/content "old thumbnail")
|
||||
:bucket "file-thumbnail"
|
||||
:content-type "image/png"})
|
||||
latest (sto/put-object! storage {::sto/content (sto/content "latest thumbnail")
|
||||
:bucket "file-thumbnail"
|
||||
:content-type "image/png"})]
|
||||
(db/insert! pool :file-thumbnail
|
||||
{:file-id (:id file)
|
||||
:revn 1
|
||||
:media-id (:id old)
|
||||
:deleted-at (ct/now)})
|
||||
(db/insert! pool :file-thumbnail
|
||||
{:file-id (:id file)
|
||||
:revn 2
|
||||
:media-id (:id latest)})
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str "/assets/by-id/" (:id latest))))
|
||||
(t/is (not (str/includes? (::yres/body response) (str (:id old)))))))))
|
||||
|
||||
(t/deftest link-preview-escapes-file-name
|
||||
;; Hostile file names are HTML-escaped in the rendered meta tags.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:name "<script>alert(\"x\")</script> & co"})]
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (not (str/includes? (::yres/body response) "<script>alert")))
|
||||
(t/is (str/includes? (::yres/body response) "<script>"))))))
|
||||
|
||||
(t/deftest link-preview-response-headers
|
||||
;; The preview page is explicit HTML, never cached nor indexed.
|
||||
(let [response (run-handler {})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "text/html; charset=utf-8"
|
||||
(get (::yres/headers response) "content-type")))
|
||||
(t/is (str/includes? (get (::yres/headers response) "cache-control") "no-store"))
|
||||
(t/is (str/includes? (::yres/body response) "name=\"robots\" content=\"noindex\""))))
|
||||
|
||||
(t/deftest link-preview-file-beats-project-and-team
|
||||
;; With file, project and team ids present, the file card wins.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})]
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id (str (:id file))
|
||||
:project-id (str (uuid/next))
|
||||
:team-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (not (str/includes? (::yres/body response) "Project | Penpot")))))))
|
||||
|
||||
(t/deftest link-preview-malformed-file-id-with-project
|
||||
;; A present-but-malformed file-id is decisive: it renders the generic
|
||||
;; card instead of falling through to the project card.
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:file-id "not-a-uuid"
|
||||
:project-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (not (str/includes? (::yres/body response) "Project | Penpot"))))))
|
||||
|
||||
(t/deftest link-preview-absent-file-id-with-project
|
||||
;; Without any file-id key, the project card still applies.
|
||||
(with-redefs [cf/flags (conj cf/flags :link-preview)]
|
||||
(let [response (run-handler {:project-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) "Project | Penpot")))))
|
||||
@ -1,98 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.http-unfurl-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.unfurl :as unfurl]
|
||||
[app.storage :as sto]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each (th/serial
|
||||
th/database-reset
|
||||
th/clean-storage))
|
||||
|
||||
(def ^:private default-title
|
||||
"Penpot | Full-stack design")
|
||||
|
||||
(defn- run-handler
|
||||
[query-params]
|
||||
(let [cfg {::db/pool (:app.db/pool th/*system*)}]
|
||||
(#'unfurl/handler cfg {:query-params query-params})))
|
||||
|
||||
(defn- create-file-thumbnail!
|
||||
[file-id]
|
||||
(let [storage (::sto/storage th/*system*)
|
||||
object (sto/put-object! storage {::sto/content (sto/content "thumbnail data")
|
||||
:bucket "file-thumbnail"
|
||||
:content-type "image/png"})]
|
||||
(db/insert! (:app.db/pool th/*system*) :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn 1
|
||||
:media-id (:id object)})
|
||||
object))
|
||||
|
||||
(t/deftest unfurl-without-params
|
||||
(let [response (run-handler {})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (str/includes? (::yres/body response) "/images/penpot-link-preview.png"))))
|
||||
|
||||
(t/deftest unfurl-file-without-thumbnail
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (str/includes? (::yres/body response) "/images/penpot-link-preview.png"))))
|
||||
|
||||
(t/deftest unfurl-file-with-thumbnail
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
object (create-file-thumbnail! (:id file))
|
||||
response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
|
||||
(t/is (str/includes? (::yres/body response) (str "/assets/by-id/" (:id object))))))
|
||||
|
||||
(t/deftest unfurl-non-existent-file
|
||||
(let [response (run-handler {:file-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))))
|
||||
|
||||
(t/deftest unfurl-invalid-file-id
|
||||
(let [response (run-handler {:file-id "not-a-uuid"})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))))
|
||||
|
||||
(t/deftest unfurl-team-link
|
||||
(let [response (run-handler {:team-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) "Team dashboard | Penpot"))))
|
||||
|
||||
(t/deftest unfurl-project-link
|
||||
(let [response (run-handler {:team-id (str (uuid/next))
|
||||
:project-id (str (uuid/next))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) "Project | Penpot"))))
|
||||
|
||||
(t/deftest unfurl-flag-disabled
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})]
|
||||
(with-redefs [cf/flags (disj cf/flags :link-unfurl)]
|
||||
(let [response (run-handler {:file-id (str (:id file))})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (str/includes? (::yres/body response) default-title))
|
||||
(t/is (not (str/includes? (::yres/body response) (:name file))))))))
|
||||
@ -190,7 +190,7 @@
|
||||
;; Enables serving link preview (Open Graph) metadata for shared
|
||||
;; links; exposes file names and dashboard thumbnails to anyone
|
||||
;; that knows the file id.
|
||||
:link-unfurl})
|
||||
:link-preview})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
@ -225,8 +225,7 @@
|
||||
:enable-available-viewer-wasm
|
||||
:enable-background-blur
|
||||
:enable-stroke-path
|
||||
:enable-token-combobox
|
||||
:enable-link-unfurl])
|
||||
:enable-token-combobox])
|
||||
|
||||
(defn parse
|
||||
[& flags]
|
||||
|
||||
@ -51,7 +51,7 @@ http {
|
||||
|
||||
# Link preview crawlers; their requests for the application root
|
||||
# are served with dynamic Open Graph metadata from the backend.
|
||||
map $http_user_agent $penpot_unfurl_agent {
|
||||
map $http_user_agent $penpot_link_preview_agent {
|
||||
default 0;
|
||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
||||
}
|
||||
@ -123,8 +123,8 @@ http {
|
||||
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
|
||||
}
|
||||
|
||||
location = /unfurl {
|
||||
proxy_pass http://127.0.0.1:6060/unfurl$is_args$args;
|
||||
location = /link-preview {
|
||||
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args;
|
||||
}
|
||||
|
||||
# On production, this is controlled by ELB
|
||||
@ -303,8 +303,8 @@ http {
|
||||
return 301 " /404";
|
||||
}
|
||||
|
||||
if ($penpot_unfurl_agent) {
|
||||
rewrite ^/$ /unfurl last;
|
||||
if ($penpot_link_preview_agent) {
|
||||
rewrite ^/$ /link-preview last;
|
||||
}
|
||||
|
||||
include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf;
|
||||
|
||||
@ -59,7 +59,7 @@ http {
|
||||
|
||||
# Link preview crawlers; their requests for the application root
|
||||
# are served with dynamic Open Graph metadata from the backend.
|
||||
map $http_user_agent $penpot_unfurl_agent {
|
||||
map $http_user_agent $penpot_link_preview_agent {
|
||||
default 0;
|
||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
||||
}
|
||||
@ -134,8 +134,8 @@ http {
|
||||
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
|
||||
}
|
||||
|
||||
location = /unfurl {
|
||||
proxy_pass $PENPOT_BACKEND_URI/unfurl$is_args$args;
|
||||
location = /link-preview {
|
||||
proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args;
|
||||
}
|
||||
|
||||
location /api/export {
|
||||
@ -191,8 +191,8 @@ http {
|
||||
return 301 " /404";
|
||||
}
|
||||
|
||||
if ($penpot_unfurl_agent) {
|
||||
rewrite ^/$ /unfurl last;
|
||||
if ($penpot_link_preview_agent) {
|
||||
rewrite ^/$ /link-preview last;
|
||||
}
|
||||
|
||||
include /etc/nginx/nginx-security-headers.conf;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Link unfurl (link previews)
|
||||
title: Link previews
|
||||
desc: How Penpot serves Open Graph metadata for shared links, so that Slack, Discord, Twitter and other platforms render rich previews with the file name and thumbnail.
|
||||
---
|
||||
|
||||
# Link unfurl (link previews)
|
||||
# Link previews
|
||||
|
||||
When a user pastes a Penpot link in a chat or social platform (Slack, Discord,
|
||||
Twitter/X, WhatsApp, Telegram, LinkedIn, Mastodon, Bluesky...), the platform's
|
||||
@ -17,8 +17,8 @@ render a rich preview card. This subsystem serves that metadata dynamically:
|
||||
* In any other case (or when the feature is disabled): the default Penpot
|
||||
title, description and preview image.
|
||||
|
||||
The whole feature is gated behind the `link-unfurl` flag (enabled with
|
||||
`enable-link-unfurl` in `PENPOT_FLAGS`), and is **disabled by default**. See
|
||||
The whole feature is gated behind the `link-preview` flag (enabled with
|
||||
`enable-link-preview` in `PENPOT_FLAGS`), and is **disabled by default**. See
|
||||
[Security considerations](#security-considerations) below for why.
|
||||
|
||||
## How it works, end to end
|
||||
@ -36,12 +36,12 @@ link points to. The feature is therefore built from three cooperating pieces:
|
||||
▼ ▼ ▼
|
||||
[frontend] [nginx] [nginx]
|
||||
mirrors context user-agent matches crawler user-agent is normal
|
||||
params before the rewrite / -> /unfurl serve SPA index.html
|
||||
params before the rewrite / -> /link-preview serve SPA index.html
|
||||
fragment on every (query string preserved)
|
||||
navigation │
|
||||
▼
|
||||
[backend]
|
||||
GET /unfurl?file-id=X
|
||||
GET /link-preview?file-id=X
|
||||
query DB, render Open
|
||||
Graph HTML template
|
||||
```
|
||||
@ -64,7 +64,9 @@ https://design.penpot.app/?team-id=<uuid>#/dashboard/recent?team-id=...
|
||||
`match->context-params` implements a priority: if the route has a `file-id`
|
||||
only that is mirrored; otherwise `project-id` (together with its `team-id`);
|
||||
otherwise `team-id`. Routes without any of those ids (e.g. auth pages) mirror
|
||||
nothing and `replaceState` strips any stale query string. Ids are read both
|
||||
nothing; `replaceState` only writes when the computed href differs from the
|
||||
current one, so it strips a stale query string without churning the URL on
|
||||
every navigation. Ids are read both
|
||||
from `:query-params` (current routes) and from `[:params :path]` (legacy
|
||||
routes that carry them as path params).
|
||||
|
||||
@ -79,24 +81,24 @@ Files: `docker/devenv/files/nginx.conf` (devenv) and
|
||||
A `map` block classifies the request by `User-Agent`:
|
||||
|
||||
```nginx
|
||||
map $http_user_agent $penpot_unfurl_agent {
|
||||
map $http_user_agent $penpot_link_preview_agent {
|
||||
default 0;
|
||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
||||
}
|
||||
```
|
||||
|
||||
Inside the SPA root location, crawler requests for `/` are internally
|
||||
rewritten to the backend unfurl endpoint (the query string is preserved by
|
||||
rewritten to the backend link-preview endpoint (the query string is preserved by
|
||||
`rewrite ... last`):
|
||||
|
||||
```nginx
|
||||
if ($penpot_unfurl_agent) {
|
||||
rewrite ^/$ /unfurl last;
|
||||
if ($penpot_link_preview_agent) {
|
||||
rewrite ^/$ /link-preview last;
|
||||
}
|
||||
|
||||
location = /unfurl {
|
||||
proxy_pass http://127.0.0.1:6060/unfurl$is_args$args; # devenv
|
||||
# proxy_pass $PENPOT_BACKEND_URI/unfurl$is_args$args; # production template
|
||||
location = /link-preview {
|
||||
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args; # devenv
|
||||
# proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args; # production template
|
||||
}
|
||||
```
|
||||
|
||||
@ -104,16 +106,19 @@ Regular browsers are not affected: they keep receiving the SPA `index.html`.
|
||||
If you self-host behind a different reverse proxy, you need to replicate this
|
||||
routing there.
|
||||
|
||||
### 3. Backend: the `/unfurl` endpoint
|
||||
### 3. Backend: the `/link-preview` endpoint
|
||||
|
||||
File: `backend/src/app/http/unfurl.clj` (new namespace).
|
||||
File: `backend/src/app/http/link_preview.clj` (new namespace).
|
||||
|
||||
The handler:
|
||||
|
||||
1. If the `link-unfurl` flag is not set, skips any lookup and uses the
|
||||
1. If the `link-preview` flag is not set, skips any lookup and uses the
|
||||
default context.
|
||||
2. Otherwise parses `file-id` / `project-id` / `team-id` from the query
|
||||
params (invalid UUIDs are tolerated and treated as absent).
|
||||
params. A present `file-id` is decisive: file links never fall through
|
||||
to the project/team card, even when the value is a malformed or unknown
|
||||
id (both yield the generic card); only a missing `file-id` key falls
|
||||
through to project/team.
|
||||
3. For a `file-id`, runs a single query joining `file` with its most recent
|
||||
non-deleted `file_thumbnail` row (the dashboard thumbnail):
|
||||
|
||||
@ -133,7 +138,7 @@ The handler:
|
||||
data falls back to the defaults; the default image is
|
||||
`<public-uri>/images/penpot-link-preview.png` (a static asset shipped in
|
||||
`frontend/resources/public/images/`).
|
||||
5. Renders `backend/resources/app/templates/unfurl.tmpl` and responds with
|
||||
5. Renders `backend/resources/app/templates/link-preview.tmpl` and responds with
|
||||
`200`, `text/html` and `cache-control: no-store, no-cache, max-age=0`.
|
||||
|
||||
The endpoint **always returns 200** with at least the generic metadata; a
|
||||
@ -141,24 +146,26 @@ non-existent file id, a malformed id or a disabled flag never produce an
|
||||
error, so crawlers always get a valid preview.
|
||||
|
||||
The route is registered in `backend/src/app/http.clj` and wired in the
|
||||
integrant system map in `backend/src/app/main.clj` (`::http.unfurl/routes`,
|
||||
which only needs the `::db/pool` dependency).
|
||||
integrant system map in `backend/src/app/main.clj` (`::http.link-preview/routes`,
|
||||
which only needs the `::db/pool` dependency). The route declares
|
||||
`:allowed-methods #{:get :head}`, so other methods get a `405` from the shared
|
||||
`restrict-methods` middleware.
|
||||
|
||||
### The HTML template
|
||||
|
||||
File: `backend/resources/app/templates/unfurl.tmpl`.
|
||||
File: `backend/resources/app/templates/link-preview.tmpl`.
|
||||
|
||||
A minimal HTML page with `og:title`, `og:description`, `og:image`, the
|
||||
equivalent `twitter:*` card tags and `<meta name="robots" content="noindex">`.
|
||||
The body contains a single script:
|
||||
|
||||
```html
|
||||
<script>location.replace("/" + location.hash);</script>
|
||||
<script>location.replace("/" + location.search + location.hash);</script>
|
||||
```
|
||||
|
||||
so that if a *human* somehow lands on `/unfurl` (e.g. some clients let users
|
||||
so that if a *human* somehow lands on `/link-preview` (e.g. some clients let users
|
||||
click through to the fetched URL), the browser bounces back to the SPA root
|
||||
keeping the fragment, and the app loads normally. Crawlers do not execute
|
||||
keeping the query string and the fragment, and the app loads normally. Crawlers do not execute
|
||||
JavaScript, so they just read the meta tags.
|
||||
|
||||
### Making file thumbnails publicly accessible
|
||||
@ -168,14 +175,14 @@ File: `backend/src/app/http/assets.clj`.
|
||||
Crawlers fetch `og:image` anonymously, so the thumbnail asset must be served
|
||||
without authentication. The assets handler decides per storage bucket whether
|
||||
auth is required; with this feature the `file-thumbnail` bucket is treated as
|
||||
public **only while the `link-unfurl` flag is enabled**:
|
||||
public **only while the `link-preview` flag is enabled**:
|
||||
|
||||
```clojure
|
||||
(defn- public-bucket?
|
||||
[bucket]
|
||||
(or (contains? public-buckets bucket)
|
||||
(and (= "file-thumbnail" bucket)
|
||||
(contains? cf/flags :link-unfurl))))
|
||||
(contains? cf/flags :link-preview))))
|
||||
```
|
||||
|
||||
With the flag disabled, `file-thumbnail` objects keep requiring an
|
||||
@ -183,12 +190,12 @@ authenticated profile with access to the file, as before.
|
||||
|
||||
## The feature flag
|
||||
|
||||
Defined in `common/src/app/common/flags.cljc` as `:link-unfurl`, listed in the
|
||||
Defined in `common/src/app/common/flags.cljc` as `:link-preview`, listed in the
|
||||
`varia` set and **not** included in the default flags. Enable it on the
|
||||
backend with:
|
||||
|
||||
```bash
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-unfurl"
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-preview"
|
||||
```
|
||||
|
||||
It is a backend-only decision point; the frontend URL mirroring is always
|
||||
@ -198,10 +205,10 @@ metadata.
|
||||
|
||||
## Security considerations
|
||||
|
||||
Enabling `link-unfurl` deliberately trades some privacy for shareability:
|
||||
Enabling `link-preview` deliberately trades some privacy for shareability:
|
||||
|
||||
* **File names become readable by anyone who knows the file id** (the
|
||||
`/unfurl` endpoint does no permission check).
|
||||
`/link-preview` endpoint does no permission check).
|
||||
* **Dashboard thumbnails become downloadable by anyone who knows the media
|
||||
id** (the `file-thumbnail` bucket becomes public).
|
||||
|
||||
@ -210,7 +217,7 @@ knowledge-of-the-id access, not real authorization. This is the standard
|
||||
trade-off that link preview features make; it is the reason the flag is off
|
||||
by default and should be documented to self-hosters before they enable it.
|
||||
|
||||
The unfurl page also sets `robots: noindex` to keep search engines from
|
||||
The preview page also sets `robots: noindex` to keep search engines from
|
||||
indexing these preview pages, and responses are marked non-cacheable.
|
||||
|
||||
## Testing it locally (devenv)
|
||||
@ -221,7 +228,7 @@ indexing these preview pages, and responses are marked non-cacheable.
|
||||
2. Enable the flag before starting the backend REPL:
|
||||
|
||||
```bash
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-unfurl"
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-preview"
|
||||
```
|
||||
|
||||
3. In the browser (`http://localhost:3449`), open a file in the workspace and
|
||||
@ -232,7 +239,7 @@ indexing these preview pages, and responses are marked non-cacheable.
|
||||
4. Hit the endpoint directly (bypasses the user-agent detection):
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3449/unfurl?file-id=<FILE_ID>"
|
||||
curl "http://localhost:3449/link-preview?file-id=<FILE_ID>"
|
||||
```
|
||||
|
||||
Expect HTML with `og:title` containing the file name and `og:image`
|
||||
@ -264,24 +271,28 @@ indexing these preview pages, and responses are marked non-cacheable.
|
||||
|
||||
## Automated tests
|
||||
|
||||
* `backend/test/backend_tests/http_unfurl_test.clj` — endpoint behavior:
|
||||
* `backend/test/backend_tests/http_link_preview_test.clj` — endpoint behavior:
|
||||
default context, file with/without thumbnail, non-existent and malformed
|
||||
file ids, project and team links, and flag disabled.
|
||||
file ids, deleted file, only-deleted thumbnail, latest-thumbnail revision
|
||||
ordering, file-name HTML escaping, response headers, file-beats-project
|
||||
priority, decisive file-id (malformed vs absent with a project id),
|
||||
project and team links, and flag disabled.
|
||||
* `backend/test/backend_tests/http_assets_test.clj`
|
||||
(`objects-handler-file-thumbnail-bucket-link-unfurl-flag`) — the
|
||||
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
||||
`file-thumbnail` bucket is public only while the flag is enabled.
|
||||
* `frontend/test/frontend_tests/router_test.cljs` — `match->context-params`
|
||||
priority and legacy path-params support.
|
||||
priority (file > project > team), project link without team, and legacy
|
||||
path-params support.
|
||||
|
||||
## Relevant files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `backend/src/app/http/unfurl.clj` | `/unfurl` handler: flag check, DB lookup, template rendering |
|
||||
| `backend/resources/app/templates/unfurl.tmpl` | Open Graph HTML template + human redirect script |
|
||||
| `backend/src/app/http/link_preview.clj` | `/link-preview` handler: flag check, DB lookup, template rendering |
|
||||
| `backend/resources/app/templates/link-preview.tmpl` | Open Graph HTML template + human redirect script |
|
||||
| `backend/src/app/http/assets.clj` | Makes `file-thumbnail` bucket public under the flag |
|
||||
| `backend/src/app/http.clj`, `backend/src/app/main.clj` | Route registration and system wiring |
|
||||
| `common/src/app/common/flags.cljc` | `:link-unfurl` flag definition |
|
||||
| `common/src/app/common/flags.cljc` | `:link-preview` flag definition |
|
||||
| `frontend/src/app/main/router.cljs` | Mirrors context ids on the query string on navigation |
|
||||
| `docker/devenv/files/nginx.conf` | Devenv crawler detection and `/unfurl` routing |
|
||||
| `docker/devenv/files/nginx.conf` | Devenv crawler detection and `/link-preview` routing |
|
||||
| `docker/images/files/nginx.conf.template` | Same routing for the production image |
|
||||
@ -70,7 +70,7 @@
|
||||
|
||||
They are mirrored on the query string (before the fragment) because
|
||||
the fragment is never sent to the server; this way shared links
|
||||
carry enough context for rendering link preview (unfurl) metadata."
|
||||
carry enough context for rendering link preview metadata."
|
||||
[match]
|
||||
(let [path-params (dm/get-in match [:params :path])
|
||||
query-params (get match :query-params)
|
||||
@ -111,7 +111,11 @@
|
||||
href (dm/str (.-pathname globals/location)
|
||||
(if (some? query) (dm/str "?" query) "")
|
||||
(.-hash globals/location))]
|
||||
(.replaceState js/history nil "" href)))))
|
||||
;; The pre-fragment query string is owned by this mirroring: skip
|
||||
;; the write when nothing changed to avoid URL churn and dropping
|
||||
;; unrelated params set by other code.
|
||||
(when (not= href (.-href globals/location))
|
||||
(.replaceState js/history nil "" href))))))
|
||||
|
||||
(defn navigate
|
||||
[id params & {:keys [::replace ::new-window] :as options}]
|
||||
|
||||
@ -39,3 +39,19 @@
|
||||
(t/deftest match-context-params-no-context
|
||||
(let [match {:query-params {:token "some-token"}}]
|
||||
(t/is (nil? (rt/match->context-params match)))))
|
||||
|
||||
(t/deftest match-context-params-project-link-without-team
|
||||
;; Without a team-id the raw map keeps a nil team-id; the nil is dropped
|
||||
;; later by the query-string serialization, not here.
|
||||
(let [match {:query-params {:project-id "project-1"}}]
|
||||
(t/is (= {:team-id nil
|
||||
:project-id "project-1"}
|
||||
(rt/match->context-params match)))))
|
||||
|
||||
(t/deftest match-context-params-file-beats-project-and-team
|
||||
;; With file, project and team ids present, only the file-id is mirrored.
|
||||
(let [match {:query-params {:team-id "team-1"
|
||||
:project-id "project-1"
|
||||
:file-id "file-1"}}]
|
||||
(t/is (= {:file-id "file-1"}
|
||||
(rt/match->context-params match)))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user