Compare commits

...
Author SHA1 Message Date
Andrey Antukh 9e8e2c4a2a 🚧 Port subpath-safe public URI helpers to link-preview
Port join-uri and get-public-uri from the unified jobs substrate WIP and use them for the link-preview image URLs, replacing the naive string concatenation that dropped configured subpaths. Includes the ported config tests.

AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-09-09 13:51:16 +00:00
Andrey Antukh db84b77c7e 🐛 Fix link-preview replaceState guard and cover navigated effect
Compare the mirrored href against the path-relative current location so the no-op guard actually skips unchanged URLs. Read context ids through get-query-param so repeated keys resolve to the last value. Cover the navigated effect with stubbed-globals tests (write on change, skip when mirrored, strip stale context) and surface enable-link-preview in the configuration flags list.

AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-09-09 14:40:20 +02:00
Andrey Antukh 3bc46a21dc ♻️ 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>
2026-09-09 14:40:20 +02:00
elhombretecla 471493cd02 📚 Add link unfurl documentation 2026-09-09 14:40:15 +02:00
elhombretecla e052a7befc 🎉 Add link preview metadata for shared links 2026-09-09 11:25:38 +02:00
18 changed files with 920 additions and 16 deletions

No files matched your search

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{title}}</title>
<meta name="robots" content="noindex" />
<meta name="description" content="{{description}}" />
<meta property="og:site_name" content="Penpot" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="{{title}}" />
<meta property="og:description" content="{{description}}" />
<meta property="og:image" content="{{image}}" />
<meta name="twitter:title" content="{{title}}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:description" content="{{description}}" />
<meta name="twitter:image" content="{{image}}" />
</head>
<body>
<script>location.replace("/" + location.search + location.hash);</script>
</body>
</html>
+1
View File
@@ -62,6 +62,7 @@ export PENPOT_FLAGS="\
enable-file-validation \
enable-file-schema-validation \
enable-redis-cache \
enable-link-preview \
enable-subscriptions";
# Uncomment for nexus integration testing
+14
View File
@@ -377,6 +377,20 @@
(or (c/get config :file-clean-delay)
(ct/duration {:days 2})))
(defn join-uri
"Join path segments onto a base URI, preserving a potential subpath
(same semantics as the frontend config). The base is normalized with
a trailing slash; segments must not start with `/` (a leading slash
would resolve against the host root and drop the subpath)."
[base & segments]
(str (apply u/join (u/ensure-path-slash base) segments)))
(defn get-public-uri
"Canonical public URI builder: `join-uri` over the configured
:public-uri. With no segments, returns the normalized base."
[& segments]
(apply join-uri (c/get config :public-uri) segments))
(defn get
"A configuration getter. Helps code be more testable."
([key]
+3
View File
@@ -17,6 +17,7 @@
[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]
@@ -149,6 +150,7 @@
[::rpc/routes schema:routes]
[::oidc/routes schema:routes]
[::assets/routes schema:routes]
[::link-preview/routes schema:routes]
[::debug/routes schema:routes]
[::mtx/routes schema:routes]
[::awsns/routes schema:routes]
@@ -177,6 +179,7 @@
(::mtx/routes cfg)
(::assets/routes cfg)
(::link-preview/routes cfg)
(::debug/routes cfg)
["/webhooks"
+12 -3
View File
@@ -11,6 +11,7 @@
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.http.access-token :as actoken]
[app.http.session :as session]
@@ -35,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)
@@ -56,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
@@ -84,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}))
@@ -101,7 +110,7 @@
"Check if the storage object requires authentication based on its bucket."
[obj]
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(not (public-bucket? bucket))))
(defn- request-profile-id
"Extract the authenticated profile-id from the request."
+82
View File
@@ -0,0 +1,82 @@
;; 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 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
routes crawler requests for the application root to this endpoint,
preserving the query string params that the frontend mirrors on
navigation (`file-id`, `project-id` and `team-id`)."
(:require
[app.common.data :as d]
[app.config :as cf]
[app.db :as db]
[app.util.template :as tmpl]
[clojure.java.io :as io]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
(def ^:private default-context
{:title "Penpot | Full-stack design"
:description "Penpot is the open-source design platform for teams that build digital products at scale."})
(def ^:private sql:get-file
"SELECT f.name, ft.media_id
FROM file AS f
LEFT JOIN file_thumbnail AS ft
ON (ft.file_id = f.id AND ft.deleted_at IS NULL)
WHERE f.id = ?
AND f.deleted_at IS NULL
ORDER BY ft.revn DESC NULLS LAST
LIMIT 1")
(defn- get-file-context
"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])]
(cond-> (assoc default-context :title (str name " | Penpot"))
(some? media-id)
(assoc :image (cf/get-public-uri (str "assets/by-id/" media-id))))))
(defn- get-context
[pool params]
(let [project-id (some-> (:project-id params) d/parse-uuid)
team-id (some-> (:team-id params) d/parse-uuid)]
;; 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-preview)
(get-context pool (:query-params request)))
context (-> (or context default-context)
(update :image #(or % (cf/get-public-uri "images/penpot-link-preview.png"))))]
{::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/link-preview.tmpl")
(tmpl/render context))}))
;; --- Initialization
(defmethod ig/assert-key ::routes
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
(defmethod ig/init-key ::routes
[_ cfg]
["/link-preview" {:handler (partial handler cfg)
:allowed-methods #{:get :head}}])
+5
View File
@@ -20,6 +20,7 @@
[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]
@@ -283,9 +284,13 @@
::mgmt/routes (ig/ref ::mgmt/routes)
::http.debug/routes (ig/ref ::http.debug/routes)
::http.assets/routes (ig/ref ::http.assets/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.link-preview/routes
{::db/pool (ig/ref ::db/pool)}
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
@@ -0,0 +1,35 @@
;; 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.config-test
(:require
[app.config :as cf]
[clojure.test :as t]))
(t/deftest get-public-uri-normalizes-base
(t/testing "trailing slash is ensured with and without subpath"
(doseq [[base expected] [["http://localhost:3449" "http://localhost:3449/"]
["http://localhost:3449/" "http://localhost:3449/"]
["https://example.com/penpot" "https://example.com/penpot/"]
["https://example.com/penpot/" "https://example.com/penpot/"]]]
(t/testing (str "base " base)
(with-redefs [cf/config (assoc cf/config :public-uri base)]
(t/is (= expected (cf/get-public-uri))))))))
(t/deftest get-public-uri-preserves-subpath
(t/testing "joined segments keep the subpath"
(with-redefs [cf/config (assoc cf/config :public-uri "https://example.com/penpot")]
(t/is (= "https://example.com/penpot/assets/by-id/123"
(cf/get-public-uri "assets/by-id/123")))
(t/is (= "https://example.com/penpot/api/main/doc"
(cf/get-public-uri "api/main/doc"))))))
(t/deftest join-uri-joins-arbitrary-base
(t/testing "segments join onto any base with trailing slash normalization"
(t/is (= "https://nitrate.example.com/api/teams/123"
(cf/join-uri "https://nitrate.example.com" "api/teams/123")))
(t/is (= "https://nitrate.example.com/api/teams/123"
(cf/join-uri "https://nitrate.example.com/" "api/teams/123")))))
@@ -8,6 +8,7 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
@@ -157,6 +158,26 @@
;; Tests: objects-handler — non-public buckets (auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-file-thumbnail-bucket-link-preview-flag
;; Objects in the file-thumbnail bucket are public only when the
;; link-preview flag is enabled.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-thumbnail" "thumbnail data")
request {:path-params {:id (str (:id object))}}]
(t/testing "flag enabled"
(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-preview)]
(let [response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response))))))))
(t/deftest objects-handler-non-public-bucket-no-auth
;; Objects in non-public buckets should return 401 without authentication.
(let [storage (-> (:app.storage/storage th/*system*)
@@ -218,10 +239,12 @@
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)]
;; NOTE: file-thumbnail is not included here because it is public
;; when the link-preview flag is enabled; see
;; objects-handler-file-thumbnail-bucket-link-preview-flag.
(doseq [bucket ["profile"
"tempfile"
"file-data"
"file-thumbnail"
"file-change"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "some data")
@@ -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")))))
+6 -1
View File
@@ -185,7 +185,12 @@
;; renderer.
:wasm-export
:custom-shortcuts
:remote-media-processing})
:remote-media-processing
;; Enables serving link preview (Open Graph) metadata for shared
;; links; exposes file names and dashboard thumbnails to anyone
;; that knows the file id.
:link-preview})
(def all-flags
(set/union email login varia))
+15
View File
@@ -49,6 +49,13 @@ http {
'' close;
}
# Link preview crawlers; their requests for the application root
# are served with dynamic Open Graph metadata from the backend.
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;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@@ -116,6 +123,10 @@ http {
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
}
location = /link-preview {
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args;
}
# On production, this is controlled by ELB
location /api/export {
proxy_pass http://127.0.0.1:6061;
@@ -292,6 +303,10 @@ http {
return 301 " /404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri /index.html$is_args$args /index.html =404;
+15
View File
@@ -57,6 +57,13 @@ http {
'' close;
}
# Link preview crawlers; their requests for the application root
# are served with dynamic Open Graph metadata from the backend.
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;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@@ -127,6 +134,10 @@ http {
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
}
location = /link-preview {
proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args;
}
location /api/export {
proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_EXPORTER_URI;
@@ -180,6 +191,10 @@ http {
return 301 " /404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, max-age=0" always;
try_files $uri /index.html$is_args$args /index.html =404;
+4
View File
@@ -730,6 +730,9 @@ __Since version 2.0.0__
- <code class="language-bash">enable-webhooks</code>: enables webhooks. More detail about this configuration in [webhooks section][6].
- <code class="language-bash">enable-access-tokens</code>: enables access tokens. More detail about this configuration in [access tokens section][7].
- <code class="language-bash">disable-google-fonts-provider</code>: disables the google fonts provider.
- <code class="language-bash">enable-link-preview</code>: enables Open Graph link previews for shared links.
File names and dashboard thumbnails become readable by anyone holding the link, so only enable
it if you accept that trade-off. More detail in the [link previews page][9].
[1]: /technical-guide/getting-started#configure-penpot-with-elestio
[2]: /technical-guide/getting-started#configure-penpot-with-docker
@@ -739,3 +742,4 @@ __Since version 2.0.0__
[6]: /technical-guide/integration/#webhooks
[7]: /technical-guide/integration/#access-tokens
[8]: /mcp/
[9]: /technical-guide/developer/subsystems/link-preview/
@@ -0,0 +1,300 @@
---
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 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
crawler fetches the URL and looks for [Open Graph](https://ogp.me/) metadata to
render a rich preview card. This subsystem serves that metadata dynamically:
* For a **file** link: the file name as title and the latest dashboard
thumbnail of the file as preview image.
* For a **project** or **team** link: a generic "Project | Penpot" or
"Team dashboard | Penpot" title.
* 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-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
The main obstacle is that Penpot is a SPA and all the routing state lives in
the URL **fragment** (`#/workspace?file-id=...`). The fragment is never sent to
the server, so with a plain URL the backend has no way to know which file the
link points to. The feature is therefore built from three cooperating pieces:
```text
user shares URL crawler (Slackbot, ...) regular browser
│ │ │
│ https://host/?file-id=X#/workspace?... │
│ │ │
▼ ▼ ▼
[frontend] [nginx] [nginx]
mirrors context user-agent matches crawler user-agent is normal
params before the rewrite / -> /link-preview serve SPA index.html
fragment on every (query string preserved)
navigation │
[backend]
GET /link-preview?file-id=X
query DB, render Open
Graph HTML template
```
### 1. Frontend: mirroring context params on the query string
File: `frontend/src/app/main/router.cljs`
On every navigation, the `navigated` event calls `match->context-params` to
extract the identifiers that give sharing context to the current route, and
mirrors them on the query string (before the fragment) using
`history.replaceState`. The resulting URLs look like:
```text
https://design.penpot.app/?file-id=<uuid>#/workspace?team-id=...&file-id=...&page-id=...
https://design.penpot.app/?team-id=<uuid>&project-id=<uuid>#/dashboard/recent?...
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; `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).
This way, when the user copies the URL from the address bar and shares it, the
context ids travel in a part of the URL that *does* reach the server.
### 2. Nginx: detecting link preview crawlers
Files: `docker/devenv/files/nginx.conf` (devenv) and
`docker/images/files/nginx.conf.template` (production image).
A `map` block classifies the request by `User-Agent`:
```nginx
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 link-preview endpoint (the query string is preserved by
`rewrite ... last`):
```nginx
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
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
}
```
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 `/link-preview` endpoint
File: `backend/src/app/http/link_preview.clj` (new namespace).
The handler:
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. 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):
```sql
SELECT f.name, ft.media_id
FROM file AS f
LEFT JOIN file_thumbnail AS ft
ON (ft.file_id = f.id AND ft.deleted_at IS NULL)
WHERE f.id = ?
AND f.deleted_at IS NULL
ORDER BY ft.revn DESC NULLS LAST
LIMIT 1
```
4. Builds the context: `:title` is `"<file name> | Penpot"` and `:image` is
`<public-uri>/assets/by-id/<media-id>` when a thumbnail exists. Missing
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/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
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.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/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.search + location.hash);</script>
```
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 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
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-preview` flag is enabled**:
```clojure
(defn- public-bucket?
[bucket]
(or (contains? public-buckets bucket)
(and (= "file-thumbnail" bucket)
(contains? cf/flags :link-preview))))
```
With the flag disabled, `file-thumbnail` objects keep requiring an
authenticated profile with access to the file, as before.
## The feature flag
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-preview"
```
It is a backend-only decision point; the frontend URL mirroring is always
active (it is harmless on its own), and the nginx crawler routing is also
unconditional — with the flag off the endpoint simply serves the generic
metadata.
## Security considerations
Enabling `link-preview` deliberately trades some privacy for shareability:
* **File names become readable by anyone who knows the file id** (the
`/link-preview` endpoint does no permission check).
* **Dashboard thumbnails become downloadable by anyone who knows the media
id** (the `file-thumbnail` bucket becomes public).
Both ids are random UUIDs, so they are not enumerable, but this is
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 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)
1. Make sure the devenv nginx picked up the config (restart the devenv, or
`nginx -s reload` inside the container, if it predates these changes).
2. The flag already ships enabled in devenv via `backend/scripts/_env`, so
no export is needed there; outside devenv, enable it before starting
the backend:
```bash
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-preview"
```
3. In the browser (`http://localhost:3449`), open a file in the workspace and
go back to the dashboard — leaving the workspace is what generates the
dashboard thumbnail. Verify the address bar now shows `?file-id=...`
before the `#`.
4. Hit the endpoint directly (bypasses the user-agent detection):
```bash
curl "http://localhost:3449/link-preview?file-id=<FILE_ID>"
```
Expect HTML with `og:title` containing the file name and `og:image`
pointing to `/assets/by-id/<media-id>` (or the default image if the file
has no thumbnail yet).
5. Simulate a real crawler against the root, exercising the full
nginx → rewrite → backend path:
```bash
curl -A "Slackbot-LinkExpanding 1.0" "http://localhost:3449/?file-id=<FILE_ID>"
```
The same URL with a normal user-agent must return the SPA `index.html`.
6. Verify the thumbnail is public:
```bash
curl -I "http://localhost:3449/assets/by-id/<MEDIA_ID>"
```
Expect `200` without any session cookie while the flag is on, and `401`
with the flag off (restart the backend after changing flags).
7. To see the actual preview card rendered by Slack/Discord you need a
publicly reachable URL (`og:image` is built from `PENPOT_PUBLIC_URI`), so
use a tunnel such as ngrok; for local verification the `curl` checks above
are enough.
## Automated tests
* `backend/test/backend_tests/http_link_preview_test.clj` — endpoint behavior:
default context, file with/without thumbnail, non-existent and malformed
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-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 (file > project > team), project link without team, and legacy
path-params support.
## Relevant files
| File | Role |
|---|---|
| `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-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 `/link-preview` routing |
| `docker/images/files/nginx.conf.template` | Same routing for the production image |
+47 -11
View File
@@ -65,6 +65,36 @@
;; --- Navigate (Event)
(defn get-query-param
"Safely extracts a scalar value for a query param key from a params
map. When the same key appears multiple times in a URL,
query-string->map returns a vector for that key; this function
always returns a single (last) element in that case, so downstream
consumers such as parse-long always receive a plain string or nil."
[params k]
(let [v (get params k)]
(if (sequential? v) (peek v) v)))
(defn match->context-params
"Extract the params that give sharing context to the current URL.
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 metadata."
[match]
(let [path-params (dm/get-in match [:params :path])
query-params (get match :query-params)
file-id (or (get-query-param query-params :file-id)
(get-query-param path-params :file-id))
team-id (or (get-query-param query-params :team-id)
(get-query-param path-params :team-id))
project-id (or (get-query-param query-params :project-id)
(get-query-param path-params :project-id))]
(cond
(some? file-id) {:file-id file-id}
(some? project-id) {:team-id team-id :project-id project-id}
(some? team-id) {:team-id team-id})))
(defn navigated
[match send-event-info?]
(ptk/reify ::navigated
@@ -85,7 +115,23 @@
(update [_ state]
(-> state
(assoc :route match)
(dissoc :exception)))))
(dissoc :exception)))
ptk/EffectEvent
(effect [_ _ _]
(let [query (some-> (match->context-params match)
(u/map->query-string))
href (dm/str (.-pathname globals/location)
(if (some? query) (dm/str "?" query) "")
(.-hash globals/location))
current (dm/str (.-pathname globals/location)
(.-search globals/location)
(.-hash globals/location))]
;; 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. Both sides are path-relative.
(when (not= href current)
(.replaceState js/history nil "" href))))))
(defn navigate
[id params & {:keys [::replace ::new-window] :as options}]
@@ -135,16 +181,6 @@
[state]
(dm/get-in state [:route :params :query]))
(defn get-query-param
"Safely extracts a scalar value for a query param key from a params
map. When the same key appears multiple times in a URL,
query-string->map returns a vector for that key; this function
always returns a single (last) element in that case, so downstream
consumers such as parse-long always receive a plain string or nil."
[params k]
(let [v (get params k)]
(if (sequential? v) (peek v) v)))
(defn nav-back
[]
(ptk/reify ::nav-back
@@ -0,0 +1,122 @@
;; 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 frontend-tests.router-test
(:require
[app.main.router :as rt]
[app.util.globals :as globals]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest match-context-params-file-link
;; Workspace and viewer links only mirror the file-id.
(let [match {:query-params {:team-id "team-1"
:file-id "file-1"
:page-id "page-1"}}]
(t/is (= {:file-id "file-1"}
(rt/match->context-params match)))))
(t/deftest match-context-params-file-link-path-params
;; Legacy routes carry the ids as path params.
(let [match {:params {:path {:project-id "project-1"
:file-id "file-1"}}}]
(t/is (= {:file-id "file-1"}
(rt/match->context-params match)))))
(t/deftest match-context-params-project-link
(let [match {:query-params {:team-id "team-1"
:project-id "project-1"}}]
(t/is (= {:team-id "team-1"
:project-id "project-1"}
(rt/match->context-params match)))))
(t/deftest match-context-params-team-link
(let [match {:query-params {:team-id "team-1"}}]
(t/is (= {:team-id "team-1"}
(rt/match->context-params match)))))
(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)))))
(t/deftest match-context-params-repeated-key
;; A repeated query key arrives as a vector; the last value wins.
(let [match {:query-params {:file-id ["file-old" "file-1"]}}]
(t/is (= {:file-id "file-1"}
(rt/match->context-params match)))))
(defn- with-stubbed-browser
"Run `thunk` with the `globals/location` mock props and a recording
`js/history.replaceState`. Restores both originals afterwards: the
location mock is shared across tests and `js/history` may not exist
in the test environment at all."
[{:keys [pathname search hash href]} replace-calls thunk]
(let [loc globals/location
old-pathname (.-pathname loc)
old-search (.-search loc)
old-hash (.-hash loc)
old-href (.-href loc)
old-history (.-history js/globalThis)]
(set! (.-pathname loc) pathname)
(set! (.-search loc) search)
(set! (.-hash loc) hash)
(set! (.-href loc) href)
(set! (.-history js/globalThis)
#js {:replaceState (fn [_ _ url] (swap! replace-calls conj url))})
(try
(thunk)
(finally
(set! (.-pathname loc) old-pathname)
(set! (.-search loc) old-search)
(set! (.-hash loc) old-hash)
(set! (.-href loc) old-href)
(set! (.-history js/globalThis) old-history)))))
(t/deftest navigated-mirrors-context-on-change
;; New context in the match triggers exactly one mirrored write.
(let [calls (atom [])]
(with-stubbed-browser
{:pathname "/" :search "" :hash "#/workspace?file-id=file-1" :href "http://localhost/"}
calls
(fn []
(ptk/effect (rt/navigated {:query-params {:file-id "file-1"}} false) nil nil)
(t/is (= ["/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
(t/deftest navigated-skips-write-when-mirrored
;; When the URL already carries the mirrored context, nothing is written.
(let [calls (atom [])]
(with-stubbed-browser
{:pathname "/" :search "?file-id=file-1" :hash "#/workspace?file-id=file-1" :href "http://localhost/?file-id=file-1#/workspace?file-id=file-1"}
calls
(fn []
(ptk/effect (rt/navigated {:query-params {:file-id "file-1"}} false) nil nil)
(t/is (= [] @calls))))))
(t/deftest navigated-strips-stale-context
;; A stale pre-fragment query is replaced with the current context.
(let [calls (atom [])]
(with-stubbed-browser
{:pathname "/" :search "?file-id=old" :hash "#/dashboard/recent?team-id=team-1" :href "http://localhost/?file-id=old#/dashboard/recent?team-id=team-1"}
calls
(fn []
(ptk/effect (rt/navigated {:query-params {:team-id "team-1"}} false) nil nil)
(t/is (= ["/?team-id=team-1#/dashboard/recent?team-id=team-1"] @calls))))))
+2
View File
@@ -68,6 +68,7 @@
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.render-wasm.text-editor-apply-styles-test]
[frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.router-test]
[frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.copy-paste-props-test]
@@ -175,6 +176,7 @@
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.render-wasm.text-editor-apply-styles-test
'frontend-tests.render-wasm.text-editor-caret-color-test
'frontend-tests.router-test
'frontend-tests.svg-fills-test
'frontend-tests.tokens.copy-paste-props-test
'frontend-tests.tokens.import-export-test