♻️ 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:
Andrey Antukh 2026-09-09 10:13:00 +00:00
parent 471493cd02
commit 3bc46a21dc
15 changed files with 339 additions and 191 deletions

View File

@ -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>

View File

@ -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

View File

@ -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"

View File

@ -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]

View File

@ -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}}])

View File

@ -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

View File

@ -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"

View 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) "&lt;script&gt;"))))))
(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")))))

View File

@ -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))))))))

View 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]

View File

@ -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;

View File

@ -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;

View File

@ -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 |

View File

@ -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}]

View File

@ -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)))))