mirror of
https://github.com/penpot/penpot.git
synced 2026-09-12 06:58:57 +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
f5c8e56a54
commit
60b3ac25f4
@ -17,6 +17,6 @@
|
|||||||
<meta name="twitter:image" content="{{image}}" />
|
<meta name="twitter:image" content="{{image}}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>location.replace("/" + location.hash);</script>
|
<script>location.replace("/" + location.search + location.hash);</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -62,7 +62,7 @@ export PENPOT_FLAGS="\
|
|||||||
enable-file-validation \
|
enable-file-validation \
|
||||||
enable-file-schema-validation \
|
enable-file-schema-validation \
|
||||||
enable-redis-cache \
|
enable-redis-cache \
|
||||||
enable-link-unfurl \
|
enable-link-preview \
|
||||||
enable-subscriptions";
|
enable-subscriptions";
|
||||||
|
|
||||||
# Uncomment for nexus integration testing
|
# Uncomment for nexus integration testing
|
||||||
|
|||||||
@ -17,11 +17,11 @@
|
|||||||
[app.http.awsns :as-alias awsns]
|
[app.http.awsns :as-alias awsns]
|
||||||
[app.http.debug :as-alias debug]
|
[app.http.debug :as-alias debug]
|
||||||
[app.http.errors :as errors]
|
[app.http.errors :as errors]
|
||||||
|
[app.http.link-preview :as-alias link-preview]
|
||||||
[app.http.management :as mgmt]
|
[app.http.management :as mgmt]
|
||||||
[app.http.middleware :as mw]
|
[app.http.middleware :as mw]
|
||||||
[app.http.security :as sec]
|
[app.http.security :as sec]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.http.unfurl :as-alias unfurl]
|
|
||||||
[app.http.websocket :as-alias ws]
|
[app.http.websocket :as-alias ws]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.metrics :as mtx]
|
[app.metrics :as mtx]
|
||||||
@ -150,7 +150,7 @@
|
|||||||
[::rpc/routes schema:routes]
|
[::rpc/routes schema:routes]
|
||||||
[::oidc/routes schema:routes]
|
[::oidc/routes schema:routes]
|
||||||
[::assets/routes schema:routes]
|
[::assets/routes schema:routes]
|
||||||
[::unfurl/routes schema:routes]
|
[::link-preview/routes schema:routes]
|
||||||
[::debug/routes schema:routes]
|
[::debug/routes schema:routes]
|
||||||
[::mtx/routes schema:routes]
|
[::mtx/routes schema:routes]
|
||||||
[::awsns/routes schema:routes]
|
[::awsns/routes schema:routes]
|
||||||
@ -179,7 +179,7 @@
|
|||||||
|
|
||||||
(::mtx/routes cfg)
|
(::mtx/routes cfg)
|
||||||
(::assets/routes cfg)
|
(::assets/routes cfg)
|
||||||
(::unfurl/routes cfg)
|
(::link-preview/routes cfg)
|
||||||
(::debug/routes cfg)
|
(::debug/routes cfg)
|
||||||
|
|
||||||
["/webhooks"
|
["/webhooks"
|
||||||
|
|||||||
@ -36,6 +36,14 @@
|
|||||||
"file-data-fragment"
|
"file-data-fragment"
|
||||||
"organization"})
|
"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
|
(defn get-id
|
||||||
[{:keys [path-params]}]
|
[{:keys [path-params]}]
|
||||||
(or (some-> path-params :id d/parse-uuid)
|
(or (some-> path-params :id d/parse-uuid)
|
||||||
@ -57,7 +65,7 @@
|
|||||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||||
cch-max-age (or cache-max-age default-cache-max-age)
|
cch-max-age (or cache-max-age default-cache-max-age)
|
||||||
bucket (-> obj meta :bucket)
|
bucket (-> obj meta :bucket)
|
||||||
public? (contains? public-buckets bucket)
|
public? (public-bucket? bucket)
|
||||||
;; The disposition is also signed into the presigned url: this
|
;; The disposition is also signed into the presigned url: this
|
||||||
;; response is a redirect, so the header below applies to the
|
;; response is a redirect, so the header below applies to the
|
||||||
;; redirect itself and not to the bytes the client then fetches
|
;; redirect itself and not to the bytes the client then fetches
|
||||||
@ -85,7 +93,7 @@
|
|||||||
headers (cond-> {"x-accel-redirect" (:path purl)
|
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||||
"content-type" (:content-type mdata)
|
"content-type" (:content-type mdata)
|
||||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||||
(not (contains? public-buckets bucket))
|
(not (public-bucket? bucket))
|
||||||
(assoc "content-disposition" "attachment"))]
|
(assoc "content-disposition" "attachment"))]
|
||||||
{::yres/status 204
|
{::yres/status 204
|
||||||
::yres/headers headers}))
|
::yres/headers headers}))
|
||||||
@ -98,14 +106,6 @@
|
|||||||
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||||
(:fs :assets-fs) (serve-object-from-fs 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?
|
(defn- requires-auth?
|
||||||
"Check if the storage object requires authentication based on its bucket."
|
"Check if the storage object requires authentication based on its bucket."
|
||||||
[obj]
|
[obj]
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
;;
|
;;
|
||||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
(ns app.http.unfurl
|
(ns app.http.link-preview
|
||||||
"Link unfurl (Open Graph metadata) related handlers.
|
"Link preview (Open Graph metadata) related handlers.
|
||||||
|
|
||||||
Serves a minimal HTML page with Open Graph metadata used by link
|
Serves a minimal HTML page with Open Graph metadata used by link
|
||||||
preview crawlers (Slack, Discord, Twitter, ...). The reverse proxy
|
preview crawlers (Slack, Discord, Twitter, ...). The reverse proxy
|
||||||
@ -44,7 +44,7 @@
|
|||||||
(str (cf/get :public-uri) "/images/penpot-link-preview.png"))
|
(str (cf/get :public-uri) "/images/penpot-link-preview.png"))
|
||||||
|
|
||||||
(defn- get-file-context
|
(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."
|
and, when available, the last dashboard thumbnail as image."
|
||||||
[pool file-id]
|
[pool file-id]
|
||||||
(when-let [{:keys [name media-id]} (db/exec-one! pool [sql:get-file file-id])]
|
(when-let [{:keys [name media-id]} (db/exec-one! pool [sql:get-file file-id])]
|
||||||
@ -54,24 +54,28 @@
|
|||||||
|
|
||||||
(defn- get-context
|
(defn- get-context
|
||||||
[pool params]
|
[pool params]
|
||||||
(let [file-id (some-> (:file-id params) d/parse-uuid)
|
(let [project-id (some-> (:project-id params) d/parse-uuid)
|
||||||
project-id (some-> (:project-id params) d/parse-uuid)
|
|
||||||
team-id (some-> (:team-id params) d/parse-uuid)]
|
team-id (some-> (:team-id params) d/parse-uuid)]
|
||||||
(cond
|
;; A present file-id is decisive: file links never fall through to the
|
||||||
(some? file-id) (get-file-context pool file-id)
|
;; project/team card, even when the value is malformed or unknown (both
|
||||||
(some? project-id) (assoc default-context :title "Project | Penpot")
|
;; yield nil and the handler falls back to the default context).
|
||||||
(some? team-id) (assoc default-context :title "Team dashboard | Penpot"))))
|
(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
|
(defn- handler
|
||||||
[{:keys [::db/pool]} request]
|
[{: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)))
|
(get-context pool (:query-params request)))
|
||||||
context (-> (or context default-context)
|
context (-> (or context default-context)
|
||||||
(update :image #(or % (resolve-default-image-uri))))]
|
(update :image #(or % (resolve-default-image-uri))))]
|
||||||
{::yres/status 200
|
{::yres/status 200
|
||||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||||
"cache-control" "no-store, no-cache, max-age=0"}
|
"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))}))
|
(tmpl/render context))}))
|
||||||
|
|
||||||
;; --- Initialization
|
;; --- Initialization
|
||||||
@ -82,4 +86,5 @@
|
|||||||
|
|
||||||
(defmethod ig/init-key ::routes
|
(defmethod ig/init-key ::routes
|
||||||
[_ cfg]
|
[_ 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.awsns :as http.awsns]
|
||||||
[app.http.client :as-alias http.client]
|
[app.http.client :as-alias http.client]
|
||||||
[app.http.debug :as-alias http.debug]
|
[app.http.debug :as-alias http.debug]
|
||||||
|
[app.http.link-preview :as-alias http.link-preview]
|
||||||
[app.http.management :as mgmt]
|
[app.http.management :as mgmt]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.http.session.tasks :as-alias session.tasks]
|
[app.http.session.tasks :as-alias session.tasks]
|
||||||
[app.http.unfurl :as-alias http.unfurl]
|
|
||||||
[app.http.websocket :as http.ws]
|
[app.http.websocket :as http.ws]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.metrics :as-alias mtx]
|
[app.metrics :as-alias mtx]
|
||||||
@ -284,11 +284,11 @@
|
|||||||
::mgmt/routes (ig/ref ::mgmt/routes)
|
::mgmt/routes (ig/ref ::mgmt/routes)
|
||||||
::http.debug/routes (ig/ref ::http.debug/routes)
|
::http.debug/routes (ig/ref ::http.debug/routes)
|
||||||
::http.assets/routes (ig/ref ::http.assets/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.ws/routes (ig/ref ::http.ws/routes)
|
||||||
::http.awsns/routes (ig/ref ::http.awsns/routes)}
|
::http.awsns/routes (ig/ref ::http.awsns/routes)}
|
||||||
|
|
||||||
::http.unfurl/routes
|
::http.link-preview/routes
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
::http.debug/routes
|
::http.debug/routes
|
||||||
|
|||||||
@ -158,9 +158,9 @@
|
|||||||
;; Tests: objects-handler — non-public buckets (auth required)
|
;; 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
|
;; 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*)
|
(let [storage (-> (:app.storage/storage th/*system*)
|
||||||
(configure-storage-backend))
|
(configure-storage-backend))
|
||||||
cfg (make-handler-cfg storage)
|
cfg (make-handler-cfg storage)
|
||||||
@ -168,13 +168,13 @@
|
|||||||
request {:path-params {:id (str (:id object))}}]
|
request {:path-params {:id (str (:id object))}}]
|
||||||
|
|
||||||
(t/testing "flag enabled"
|
(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)]
|
(let [response (assets/objects-handler cfg request)]
|
||||||
(t/is (not= 401 (::yres/status response)))
|
(t/is (not= 401 (::yres/status response)))
|
||||||
(t/is (not= 404 (::yres/status response))))))
|
(t/is (not= 404 (::yres/status response))))))
|
||||||
|
|
||||||
(t/testing "flag disabled"
|
(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)]
|
(let [response (assets/objects-handler cfg request)]
|
||||||
(t/is (= 401 (::yres/status response))))))))
|
(t/is (= 401 (::yres/status response))))))))
|
||||||
|
|
||||||
@ -240,8 +240,8 @@
|
|||||||
profile (th/create-profile* 1)]
|
profile (th/create-profile* 1)]
|
||||||
|
|
||||||
;; NOTE: file-thumbnail is not included here because it is public
|
;; NOTE: file-thumbnail is not included here because it is public
|
||||||
;; when the link-unfurl flag is enabled; see
|
;; when the link-preview flag is enabled; see
|
||||||
;; objects-handler-file-thumbnail-bucket-link-unfurl-flag.
|
;; objects-handler-file-thumbnail-bucket-link-preview-flag.
|
||||||
(doseq [bucket ["profile"
|
(doseq [bucket ["profile"
|
||||||
"tempfile"
|
"tempfile"
|
||||||
"file-data"
|
"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
|
;; Enables serving link preview (Open Graph) metadata for shared
|
||||||
;; links; exposes file names and dashboard thumbnails to anyone
|
;; links; exposes file names and dashboard thumbnails to anyone
|
||||||
;; that knows the file id.
|
;; that knows the file id.
|
||||||
:link-unfurl})
|
:link-preview})
|
||||||
|
|
||||||
(def all-flags
|
(def all-flags
|
||||||
(set/union email login varia))
|
(set/union email login varia))
|
||||||
@ -225,8 +225,7 @@
|
|||||||
:enable-available-viewer-wasm
|
:enable-available-viewer-wasm
|
||||||
:enable-background-blur
|
:enable-background-blur
|
||||||
:enable-stroke-path
|
:enable-stroke-path
|
||||||
:enable-token-combobox
|
:enable-token-combobox])
|
||||||
:enable-link-unfurl])
|
|
||||||
|
|
||||||
(defn parse
|
(defn parse
|
||||||
[& flags]
|
[& flags]
|
||||||
|
|||||||
@ -51,7 +51,7 @@ http {
|
|||||||
|
|
||||||
# Link preview crawlers; their requests for the application root
|
# Link preview crawlers; their requests for the application root
|
||||||
# are served with dynamic Open Graph metadata from the backend.
|
# 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;
|
default 0;
|
||||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
~*(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";
|
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /unfurl {
|
location = /link-preview {
|
||||||
proxy_pass http://127.0.0.1:6060/unfurl$is_args$args;
|
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args;
|
||||||
}
|
}
|
||||||
|
|
||||||
# On production, this is controlled by ELB
|
# On production, this is controlled by ELB
|
||||||
@ -303,8 +303,8 @@ http {
|
|||||||
return 301 " /404";
|
return 301 " /404";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($penpot_unfurl_agent) {
|
if ($penpot_link_preview_agent) {
|
||||||
rewrite ^/$ /unfurl last;
|
rewrite ^/$ /link-preview last;
|
||||||
}
|
}
|
||||||
|
|
||||||
include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf;
|
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
|
# Link preview crawlers; their requests for the application root
|
||||||
# are served with dynamic Open Graph metadata from the backend.
|
# 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;
|
default 0;
|
||||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
~*(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";
|
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /unfurl {
|
location = /link-preview {
|
||||||
proxy_pass $PENPOT_BACKEND_URI/unfurl$is_args$args;
|
proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/export {
|
location /api/export {
|
||||||
@ -191,8 +191,8 @@ http {
|
|||||||
return 301 " /404";
|
return 301 " /404";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($penpot_unfurl_agent) {
|
if ($penpot_link_preview_agent) {
|
||||||
rewrite ^/$ /unfurl last;
|
rewrite ^/$ /link-preview last;
|
||||||
}
|
}
|
||||||
|
|
||||||
include /etc/nginx/nginx-security-headers.conf;
|
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.
|
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,
|
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
|
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
|
* In any other case (or when the feature is disabled): the default Penpot
|
||||||
title, description and preview image.
|
title, description and preview image.
|
||||||
|
|
||||||
The whole feature is gated behind the `link-unfurl` flag (enabled with
|
The whole feature is gated behind the `link-preview` flag (enabled with
|
||||||
`enable-link-unfurl` in `PENPOT_FLAGS`), and is **disabled by default**. See
|
`enable-link-preview` in `PENPOT_FLAGS`), and is **disabled by default**. See
|
||||||
[Security considerations](#security-considerations) below for why.
|
[Security considerations](#security-considerations) below for why.
|
||||||
|
|
||||||
## How it works, end to end
|
## 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]
|
[frontend] [nginx] [nginx]
|
||||||
mirrors context user-agent matches crawler user-agent is normal
|
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)
|
fragment on every (query string preserved)
|
||||||
navigation │
|
navigation │
|
||||||
▼
|
▼
|
||||||
[backend]
|
[backend]
|
||||||
GET /unfurl?file-id=X
|
GET /link-preview?file-id=X
|
||||||
query DB, render Open
|
query DB, render Open
|
||||||
Graph HTML template
|
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`
|
`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`);
|
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
|
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
|
from `:query-params` (current routes) and from `[:params :path]` (legacy
|
||||||
routes that carry them as path params).
|
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`:
|
A `map` block classifies the request by `User-Agent`:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
map $http_user_agent $penpot_unfurl_agent {
|
map $http_user_agent $penpot_link_preview_agent {
|
||||||
default 0;
|
default 0;
|
||||||
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
~*(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
|
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`):
|
`rewrite ... last`):
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
if ($penpot_unfurl_agent) {
|
if ($penpot_link_preview_agent) {
|
||||||
rewrite ^/$ /unfurl last;
|
rewrite ^/$ /link-preview last;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /unfurl {
|
location = /link-preview {
|
||||||
proxy_pass http://127.0.0.1:6060/unfurl$is_args$args; # devenv
|
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args; # devenv
|
||||||
# proxy_pass $PENPOT_BACKEND_URI/unfurl$is_args$args; # production template
|
# 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
|
If you self-host behind a different reverse proxy, you need to replicate this
|
||||||
routing there.
|
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:
|
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.
|
default context.
|
||||||
2. Otherwise parses `file-id` / `project-id` / `team-id` from the query
|
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
|
3. For a `file-id`, runs a single query joining `file` with its most recent
|
||||||
non-deleted `file_thumbnail` row (the dashboard thumbnail):
|
non-deleted `file_thumbnail` row (the dashboard thumbnail):
|
||||||
|
|
||||||
@ -133,7 +138,7 @@ The handler:
|
|||||||
data falls back to the defaults; the default image is
|
data falls back to the defaults; the default image is
|
||||||
`<public-uri>/images/penpot-link-preview.png` (a static asset shipped in
|
`<public-uri>/images/penpot-link-preview.png` (a static asset shipped in
|
||||||
`frontend/resources/public/images/`).
|
`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`.
|
`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
|
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.
|
error, so crawlers always get a valid preview.
|
||||||
|
|
||||||
The route is registered in `backend/src/app/http.clj` and wired in the
|
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`,
|
integrant system map in `backend/src/app/main.clj` (`::http.link-preview/routes`,
|
||||||
which only needs the `::db/pool` dependency).
|
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
|
### 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
|
A minimal HTML page with `og:title`, `og:description`, `og:image`, the
|
||||||
equivalent `twitter:*` card tags and `<meta name="robots" content="noindex">`.
|
equivalent `twitter:*` card tags and `<meta name="robots" content="noindex">`.
|
||||||
The body contains a single script:
|
The body contains a single script:
|
||||||
|
|
||||||
```html
|
```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
|
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.
|
JavaScript, so they just read the meta tags.
|
||||||
|
|
||||||
### Making file thumbnails publicly accessible
|
### 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
|
Crawlers fetch `og:image` anonymously, so the thumbnail asset must be served
|
||||||
without authentication. The assets handler decides per storage bucket whether
|
without authentication. The assets handler decides per storage bucket whether
|
||||||
auth is required; with this feature the `file-thumbnail` bucket is treated as
|
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
|
```clojure
|
||||||
(defn- public-bucket?
|
(defn- public-bucket?
|
||||||
[bucket]
|
[bucket]
|
||||||
(or (contains? public-buckets bucket)
|
(or (contains? public-buckets bucket)
|
||||||
(and (= "file-thumbnail" 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
|
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
|
## 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
|
`varia` set and **not** included in the default flags. Enable it on the
|
||||||
backend with:
|
backend with:
|
||||||
|
|
||||||
```bash
|
```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
|
It is a backend-only decision point; the frontend URL mirroring is always
|
||||||
@ -198,10 +205,10 @@ metadata.
|
|||||||
|
|
||||||
## Security considerations
|
## 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
|
* **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
|
* **Dashboard thumbnails become downloadable by anyone who knows the media
|
||||||
id** (the `file-thumbnail` bucket becomes public).
|
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
|
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.
|
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.
|
indexing these preview pages, and responses are marked non-cacheable.
|
||||||
|
|
||||||
## Testing it locally (devenv)
|
## 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:
|
2. Enable the flag before starting the backend REPL:
|
||||||
|
|
||||||
```bash
|
```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
|
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):
|
4. Hit the endpoint directly (bypasses the user-agent detection):
|
||||||
|
|
||||||
```bash
|
```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`
|
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
|
## 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
|
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`
|
* `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.
|
`file-thumbnail` bucket is public only while the flag is enabled.
|
||||||
* `frontend/test/frontend_tests/router_test.cljs` — `match->context-params`
|
* `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
|
## Relevant files
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `backend/src/app/http/unfurl.clj` | `/unfurl` handler: flag check, DB lookup, template rendering |
|
| `backend/src/app/http/link_preview.clj` | `/link-preview` handler: flag check, DB lookup, template rendering |
|
||||||
| `backend/resources/app/templates/unfurl.tmpl` | Open Graph HTML template + human redirect script |
|
| `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/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 |
|
| `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 |
|
| `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 |
|
| `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
|
They are mirrored on the query string (before the fragment) because
|
||||||
the fragment is never sent to the server; this way shared links
|
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]
|
[match]
|
||||||
(let [path-params (dm/get-in match [:params :path])
|
(let [path-params (dm/get-in match [:params :path])
|
||||||
query-params (get match :query-params)
|
query-params (get match :query-params)
|
||||||
@ -111,7 +111,11 @@
|
|||||||
href (dm/str (.-pathname globals/location)
|
href (dm/str (.-pathname globals/location)
|
||||||
(if (some? query) (dm/str "?" query) "")
|
(if (some? query) (dm/str "?" query) "")
|
||||||
(.-hash globals/location))]
|
(.-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
|
(defn navigate
|
||||||
[id params & {:keys [::replace ::new-window] :as options}]
|
[id params & {:keys [::replace ::new-window] :as options}]
|
||||||
|
|||||||
@ -39,3 +39,19 @@
|
|||||||
(t/deftest match-context-params-no-context
|
(t/deftest match-context-params-no-context
|
||||||
(let [match {:query-params {:token "some-token"}}]
|
(let [match {:query-params {:token "some-token"}}]
|
||||||
(t/is (nil? (rt/match->context-params match)))))
|
(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