🎉 Add link preview metadata for shared links

This commit is contained in:
elhombretecla 2026-07-14 14:47:54 +02:00
parent c119622ad9
commit 3d93700614
13 changed files with 355 additions and 5 deletions

View File

@ -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.hash);</script>
</body>
</html>

View File

@ -21,6 +21,7 @@
[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]
@ -149,6 +150,7 @@
[::rpc/routes schema:routes]
[::oidc/routes schema:routes]
[::assets/routes schema:routes]
[::unfurl/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)
(::unfurl/routes cfg)
(::debug/routes cfg)
["/webhooks"

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]
@ -74,11 +75,19 @@
(: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]
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(not (public-bucket? bucket))))
(defn- authenticated?
"Check if the request has an authenticated profile, either via session

View File

@ -0,0 +1,85 @@
;; 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.unfurl
"Link unfurl (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- resolve-image-uri
[media-id]
(str (cf/get :public-uri) "/assets/by-id/" media-id))
(defn- resolve-default-image-uri
[]
(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
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 (resolve-image-uri media-id)))))
(defn- get-context
[pool params]
(let [file-id (some-> (:file-id params) d/parse-uuid)
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"))))
(defn- handler
[{:keys [::db/pool]} request]
(let [context (when (contains? cf/flags :link-unfurl)
(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")
(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]
["/unfurl" {:handler (partial handler cfg)}])

View File

@ -23,6 +23,7 @@
[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]
@ -278,9 +279,13 @@
::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.ws/routes (ig/ref ::http.ws/routes)
::http.awsns/routes (ig/ref ::http.awsns/routes)}
::http.unfurl/routes
{::db/pool (ig/ref ::db/pool)}
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)

View File

@ -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]
@ -137,6 +138,26 @@
;; Tests: objects-handler — non-public buckets (auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-file-thumbnail-bucket-link-unfurl-flag
;; Objects in the file-thumbnail bucket are public only when the
;; link-unfurl 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-unfurl)]
(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)]
(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*)
@ -198,10 +219,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-unfurl flag is enabled; see
;; objects-handler-file-thumbnail-bucket-link-unfurl-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")

View File

@ -0,0 +1,98 @@
;; 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

@ -170,7 +170,12 @@
:mcp
:background-blur
:available-viewer-wasm
:stroke-path})
:stroke-path
;; 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})
(def all-flags
(set/union email login varia))
@ -204,7 +209,8 @@
:enable-render-wasm-info
:enable-available-viewer-wasm
:enable-background-blur
:enable-token-combobox])
:enable-token-combobox
:enable-link-unfurl])
(defn parse
[& flags]

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_unfurl_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;
@ -115,6 +122,10 @@ 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;
}
# On production, this is controlled by ELB
location /api/export {
proxy_pass http://127.0.0.1:6061;
@ -291,6 +302,10 @@ http {
return 301 " /404";
}
if ($penpot_unfurl_agent) {
rewrite ^/$ /unfurl 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;

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_unfurl_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;
@ -125,6 +132,10 @@ http {
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
}
location = /unfurl {
proxy_pass $PENPOT_BACKEND_URI/unfurl$is_args$args;
}
location /api/export {
proxy_pass $PENPOT_EXPORTER_URI;
}
@ -172,6 +183,10 @@ http {
return 301 " /404";
}
if ($penpot_unfurl_agent) {
rewrite ^/$ /unfurl 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;

View File

@ -65,6 +65,23 @@
;; --- Navigate (Event)
(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 (unfurl) metadata."
[match]
(let [path-params (dm/get-in match [:params :path])
query-params (get match :query-params)
file-id (or (get query-params :file-id) (get path-params :file-id))
team-id (or (get query-params :team-id) (get path-params :team-id))
project-id (or (get query-params :project-id) (get 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 +102,16 @@
(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))]
(.replaceState js/history nil "" href)))))
(defn navigate
[id params & {:keys [::replace ::new-window] :as options}]

View File

@ -0,0 +1,41 @@
;; 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]
[cljs.test :as t :include-macros true]))
(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)))))

View File

@ -46,6 +46,7 @@
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.router-test]
[frontend-tests.svg-fills-test]
[frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test]
@ -120,6 +121,7 @@
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.router-test
'frontend-tests.svg-fills-test
'frontend-tests.tokens.import-export-test
'frontend-tests.tokens.logic.token-actions-test