From 3d937006146268297a68b897f9a624be61e6b874 Mon Sep 17 00:00:00 2001 From: elhombretecla Date: Tue, 14 Jul 2026 14:47:54 +0200 Subject: [PATCH] :tada: Add link preview metadata for shared links --- backend/resources/app/templates/unfurl.tmpl | 22 +++++ backend/src/app/http.clj | 3 + backend/src/app/http/assets.clj | 11 ++- backend/src/app/http/unfurl.clj | 85 ++++++++++++++++ backend/src/app/main.clj | 5 + .../test/backend_tests/http_assets_test.clj | 25 ++++- .../test/backend_tests/http_unfurl_test.clj | 98 +++++++++++++++++++ common/src/app/common/flags.cljc | 10 +- docker/devenv/files/nginx.conf | 15 +++ docker/images/files/nginx.conf.template | 15 +++ frontend/src/app/main/router.cljs | 28 +++++- frontend/test/frontend_tests/router_test.cljs | 41 ++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 13 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 backend/resources/app/templates/unfurl.tmpl create mode 100644 backend/src/app/http/unfurl.clj create mode 100644 backend/test/backend_tests/http_unfurl_test.clj create mode 100644 frontend/test/frontend_tests/router_test.cljs diff --git a/backend/resources/app/templates/unfurl.tmpl b/backend/resources/app/templates/unfurl.tmpl new file mode 100644 index 0000000000..fdbf3f083f --- /dev/null +++ b/backend/resources/app/templates/unfurl.tmpl @@ -0,0 +1,22 @@ + + + + + {{title}} + + + + + + + + + + + + + + + + + diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index e991fd9849..4ac0ab99a0 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -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" diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index aa0d750203..949c294e13 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -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 diff --git a/backend/src/app/http/unfurl.clj b/backend/src/app/http/unfurl.clj new file mode 100644 index 0000000000..f1fa745d15 --- /dev/null +++ b/backend/src/app/http/unfurl.clj @@ -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)}]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index ca8fba8af1..9664554241 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -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) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index bc521cb082..5f8fff90cb 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -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") diff --git a/backend/test/backend_tests/http_unfurl_test.clj b/backend/test/backend_tests/http_unfurl_test.clj new file mode 100644 index 0000000000..9e394ab423 --- /dev/null +++ b/backend/test/backend_tests/http_unfurl_test.clj @@ -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)))))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 62ce48aa9a..93388f3ccb 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -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] diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index ce5566079f..6573190ce1 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -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; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index a27d770f82..3199e13191 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -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; diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index ff7e6abbbd..e7af7b8752 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -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}] diff --git a/frontend/test/frontend_tests/router_test.cljs b/frontend/test/frontend_tests/router_test.cljs new file mode 100644 index 0000000000..8ba9fa8ce7 --- /dev/null +++ b/frontend/test/frontend_tests/router_test.cljs @@ -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))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 2adae3e61e..e1d156ee42 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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