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