mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 12:19:58 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8e2c4a2a | ||
|
|
db84b77c7e | ||
|
|
3bc46a21dc | ||
|
|
471493cd02 | ||
|
|
e052a7befc |
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>
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}}])
|
||||
@@ -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) "<script>"))))))
|
||||
|
||||
(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")))))
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
@@ -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))))))
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user