Compare commits

..
Author SHA1 Message Date
Alejandro Alonso ffad71bdc5 🐛 Hard-clip tile atlas blit to avoid seam hairlines (#11639)
AA clip on the Current→atlas blit softens shared tile edges so
the canvas background shows through as 1px lines at the 512px
grid when tiles are composed with SrcOver.

Closes #11638
2026-09-11 09:22:36 +02:00
21 changed files with 166 additions and 920 deletions

No files matched your search

@@ -1,22 +0,0 @@
<!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.pathname.replace(/link-preview\/?$/, "") || "/") + location.search + location.hash);</script>
</body>
</html>
-1
View File
@@ -62,7 +62,6 @@ export PENPOT_FLAGS="\
enable-file-validation \
enable-file-schema-validation \
enable-redis-cache \
enable-link-preview \
enable-subscriptions";
# Uncomment for nexus integration testing
-16
View File
@@ -379,22 +379,6 @@
(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]
(assert (not (some #(str/starts-with? % "/") segments))
"URI segments must be relative (no leading slash)")
(str (apply u/join (u/ensure-path-slash base) segments)))
(defn get-public-uri
"Canonical public URI builder: `join-uri` over the configured
:public-uri. With no segments, returns the normalized base."
[& segments]
(apply join-uri (c/get config :public-uri) segments))
(defn get
"A configuration getter. Helps code be more testable."
([key]
-3
View File
@@ -17,7 +17,6 @@
[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]
@@ -150,7 +149,6 @@
[::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]
@@ -179,7 +177,6 @@
(::mtx/routes cfg)
(::assets/routes cfg)
(::link-preview/routes cfg)
(::debug/routes cfg)
["/webhooks"
+3 -12
View File
@@ -11,7 +11,6 @@
[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]
@@ -36,14 +35,6 @@
"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)
@@ -65,7 +56,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? (public-bucket? bucket)
public? (contains? public-buckets 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
@@ -93,7 +84,7 @@
headers (cond-> {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
(not (public-bucket? bucket))
(not (contains? public-buckets bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 204
::yres/headers headers}))
@@ -110,7 +101,7 @@
"Check if the storage object requires authentication based on its bucket."
[obj]
(let [bucket (-> obj meta :bucket)]
(not (public-bucket? bucket))))
(not (contains? public-buckets bucket))))
(defn- request-profile-id
"Extract the authenticated profile-id from the request."
-82
View File
@@ -1,82 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.http.link-preview
"Link preview (Open Graph metadata) related handlers.
Serves a minimal HTML page with Open Graph metadata used by link
preview crawlers (Slack, Discord, Twitter, ...). The reverse proxy
routes crawler requests for the application root to this endpoint,
preserving the query string params that the frontend mirrors on
navigation (`file-id`, `project-id` and `team-id`)."
(:require
[app.common.data :as d]
[app.config :as cf]
[app.db :as db]
[app.util.template :as tmpl]
[clojure.java.io :as io]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
(def ^:private default-context
{:title "Penpot | Full-stack design"
:description "Penpot is the open-source design platform for teams that build digital products at scale."})
(def ^:private sql:get-file
"SELECT f.name, ft.media_id
FROM file AS f
LEFT JOIN file_thumbnail AS ft
ON (ft.file_id = f.id AND ft.deleted_at IS NULL)
WHERE f.id = ?
AND f.deleted_at IS NULL
ORDER BY ft.revn DESC NULLS LAST
LIMIT 1")
(defn- get-file-context
"Return the link preview context for a file link: the file name as title
and, when available, the last dashboard thumbnail as image."
[pool file-id]
(when-let [{:keys [name media-id]} (db/exec-one! pool [sql:get-file file-id])]
(cond-> (assoc default-context :title (str name " | Penpot"))
(some? media-id)
(assoc :image (cf/get-public-uri (str "assets/by-id/" media-id))))))
(defn- get-context
[pool params]
(let [project-id (some-> (:project-id params) d/parse-uuid)
team-id (some-> (:team-id params) d/parse-uuid)]
;; A present file-id is decisive: file links never fall through to the
;; project/team card, even when the value is malformed or unknown (both
;; yield nil and the handler falls back to the default context).
(if (contains? params :file-id)
(when-some [file-id (d/parse-uuid (:file-id params))]
(get-file-context pool file-id))
(cond
(some? project-id) (assoc default-context :title "Project | Penpot")
(some? team-id) (assoc default-context :title "Team dashboard | Penpot")))))
(defn- handler
[{:keys [::db/pool]} request]
(let [context (when (contains? cf/flags :link-preview)
(get-context pool (:query-params request)))
context (-> (or context default-context)
(update :image #(or % (cf/get-public-uri "images/penpot-link-preview.png"))))]
{::yres/status 200
::yres/headers {"content-type" "text/html; charset=utf-8"
"cache-control" "no-store, no-cache, max-age=0"}
::yres/body (-> (io/resource "app/templates/link-preview.tmpl")
(tmpl/render context))}))
;; --- Initialization
(defmethod ig/assert-key ::routes
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
(defmethod ig/init-key ::routes
[_ cfg]
["/link-preview" {:handler (partial handler cfg)
:allowed-methods #{:get :head}}])
-5
View File
@@ -20,7 +20,6 @@
[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]
@@ -284,13 +283,9 @@
::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)
@@ -1,40 +0,0 @@
;; 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")))))
(t/deftest join-uri-rejects-leading-slash
(t/testing "a leading slash would drop the subpath, so it fails fast"
(t/is (thrown? AssertionError
(cf/join-uri "https://example.com/penpot" "/assets/by-id/123")))))
@@ -8,7 +8,6 @@
(: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]
@@ -158,26 +157,6 @@
;; 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*)
@@ -239,12 +218,10 @@
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")
@@ -1,211 +0,0 @@
;; 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 (th/tempfile "backend_tests/test_files/sample.png"))
: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 (th/tempfile "backend_tests/test_files/sample.jpg"))
:bucket "file-thumbnail"
:content-type "image/jpeg"})
latest (sto/put-object! storage {::sto/content (sto/content (th/tempfile "backend_tests/test_files/sample.png"))
:bucket "file-thumbnail"
:content-type "image/png"})]
(db/insert! pool :file-thumbnail
{:file-id (:id file)
:revn 1
:media-id (:id old)
:deleted-at (ct/now)})
(db/insert! pool :file-thumbnail
{:file-id (:id file)
:revn 2
:media-id (:id latest)})
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (run-handler {:file-id (str (:id file))})]
(t/is (= 200 (::yres/status response)))
(t/is (str/includes? (::yres/body response) (str "/assets/by-id/" (:id latest))))
(t/is (not (str/includes? (::yres/body response) (str (:id old)))))))))
(t/deftest link-preview-escapes-file-name
;; Hostile file names are HTML-escaped in the rendered meta tags.
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:name "<script>alert(\"x\")</script> & co"})]
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (run-handler {:file-id (str (:id file))})]
(t/is (= 200 (::yres/status response)))
(t/is (not (str/includes? (::yres/body response) "<script>alert")))
(t/is (str/includes? (::yres/body response) "&lt;script&gt;"))))))
(t/deftest link-preview-response-headers
;; The preview page is explicit HTML, never cached nor indexed.
(let [response (run-handler {})]
(t/is (= 200 (::yres/status response)))
(t/is (= "text/html; charset=utf-8"
(get (::yres/headers response) "content-type")))
(t/is (str/includes? (get (::yres/headers response) "cache-control") "no-store"))
(t/is (str/includes? (::yres/body response) "name=\"robots\" content=\"noindex\""))))
(t/deftest link-preview-file-beats-project-and-team
;; With file, project and team ids present, the file card wins.
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})]
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (run-handler {:file-id (str (:id file))
:project-id (str (uuid/next))
:team-id (str (uuid/next))})]
(t/is (= 200 (::yres/status response)))
(t/is (str/includes? (::yres/body response) (str (:name file) " | Penpot")))
(t/is (not (str/includes? (::yres/body response) "Project | Penpot")))))))
(t/deftest link-preview-malformed-file-id-with-project
;; A present-but-malformed file-id is decisive: it renders the generic
;; card instead of falling through to the project card.
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (run-handler {:file-id "not-a-uuid"
:project-id (str (uuid/next))})]
(t/is (= 200 (::yres/status response)))
(t/is (str/includes? (::yres/body response) default-title))
(t/is (not (str/includes? (::yres/body response) "Project | Penpot"))))))
(t/deftest link-preview-absent-file-id-with-project
;; Without any file-id key, the project card still applies.
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (run-handler {:project-id (str (uuid/next))})]
(t/is (= 200 (::yres/status response)))
(t/is (str/includes? (::yres/body response) "Project | Penpot")))))
+1 -6
View File
@@ -185,12 +185,7 @@
;; renderer.
:wasm-export
:custom-shortcuts
: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})
:remote-media-processing})
(def all-flags
(set/union email login varia))
-15
View File
@@ -49,13 +49,6 @@ 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|applebot|googlebot|bingbot|bingpreview|duckduckbot) 1;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@@ -123,10 +116,6 @@ 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;
@@ -303,10 +292,6 @@ http {
return 301 " /404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf;
add_header Cache-Control "no-store" always;
try_files $uri /index.html$is_args$args /index.html =404;
-15
View File
@@ -57,13 +57,6 @@ 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|applebot|googlebot|bingbot|bingpreview|duckduckbot) 1;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
@@ -134,10 +127,6 @@ 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;
@@ -191,10 +180,6 @@ http {
return 301 " /404";
}
if ($penpot_link_preview_agent) {
rewrite ^/$ /link-preview last;
}
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, max-age=0" always;
try_files $uri /index.html$is_args$args /index.html =404;
-4
View File
@@ -730,9 +730,6 @@ __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
@@ -742,4 +739,3 @@ __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/
@@ -1,305 +0,0 @@
---
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 reads the freshly stored
`(:route state)` and syncs its `file-id`/`team-id`/`project-id` fragment
params into the query string (before the fragment) using
`history.replaceState`. Every other param in the URL is left untouched,
so unrelated params owned by other code survive. The write is skipped
when the resulting href already matches the address bar. The backend
applies its own file > project > team priority, so no filtering happens
on the frontend. 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=...
```
Routes without any of those ids (e.g. auth pages) clear them from the
query string; `replaceState` only writes when the computed href differs
from the current one, so no URL churn happens on navigation.
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.
Legacy hash routes (`/workspace/:project-id/:file-id`, `/view/:file-id`,
`/dashboard/team/:team-id/...`) were removed: those old URLs no longer
redirect and resolve to the not-found page instead.
### 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|applebot|googlebot|bingbot|bingpreview|duckduckbot) 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.pathname.replace(/link-preview\/?$/, "") || "/") + 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. The
redirect strips only the trailing `link-preview` segment so subpath
deployments keep their prefix. 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` — the `navigated` URL
surgery (mirror, skip, stale-strip, clear, unrelated-param
preservation, every-present-id, repeated-key, subpath base).
## 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 |
+11 -34
View File
@@ -65,16 +65,6 @@
;; --- 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 navigated
[match send-event-info?]
(ptk/reify ::navigated
@@ -95,30 +85,7 @@
(update [_ state]
(-> state
(assoc :route match)
(dissoc :exception)))
ptk/EffectEvent
(effect [_ state _]
;; The route is read from the state the `update` above just stored:
;; the effect always runs after the update. The sharing-context ids
;; are synced into the pre-fragment query (the fragment never reaches
;; the server, so shared links need them there); every other param is
;; left untouched, except valueless ones (`?flag`), which the query
;; codec cannot round-trip and are dropped. The backend applies its
;; own file > project > team priority, so no filtering is needed
;; here.
(let [params (:query-params (:route state))
uri (u/uri (.-href globals/location))
search (reduce (fn [m k]
(let [v (get-query-param params k)]
(if (some? v)
(assoc m k v)
(dissoc m k))))
(u/query-string->map (:query uri))
[:file-id :team-id :project-id])
href (str (assoc uri :query (u/map->query-string search)))]
(when (not= href (.-href globals/location))
(.replaceState js/history nil "" href))))))
(dissoc :exception)))))
(defn navigate
[id params & {:keys [::replace ::new-window] :as options}]
@@ -168,6 +135,16 @@
[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
+131
View File
@@ -6,16 +6,21 @@
(ns app.main.ui
(:require
[app.common.data :as d]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.nitrate :as dnt]
[app.main.data.team :as dtm]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.debug.icons-preview :refer [icons-preview*]]
[app.main.ui.debug.playground :refer [playground*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.error-boundary :refer [error-boundary*]]
[app.main.ui.exports.files]
[app.main.ui.frame-preview :as frame-preview]
@@ -26,8 +31,10 @@
[app.main.ui.releases :refer [release-notes-modal]]
[app.main.ui.static :as static]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.modules :as mod]
[app.util.theme :as theme]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(def auth-page
@@ -48,6 +55,79 @@
(def workspace-page*
(mf/lazy #(mod/load 'app.main.ui.workspace/workspace-page*)))
(mf/defc workspace-legacy-redirect*
{::mf/props :obj
::mf/private true}
[{:keys [project-id file-id page-id layout]}]
(mf/with-effect []
(->> (rp/cmd! :get-project {:id project-id})
(rx/subs! (fn [{:keys [team-id]}]
(st/emit! (dcm/go-to-workspace :team-id team-id
:file-id file-id
:page-id page-id
:layout layout)))
errors/on-error)))
[:> loader*
{:title (tr "labels.loading")
:overlay true}])
(mf/defc dashboard-legacy-redirect*
{::mf/props :obj
::mf/private true}
[{:keys [section team-id project-id search-term plugin-url template]}]
(let [section (case section
:dashboard-legacy-search
:dashboard-search
:dashboard-legacy-projects
:dashboard-recent
:dashboard-legacy-files
:dashboard-files
:dashboard-legacy-libraries
:dashboard-libraries
:dashboard-legacy-fonts
:dashboard-fonts
:dashboard-legacy-font-providers
:dashboard-font-providers
:dashboard-legacy-team-members
:dashboard-members
:dashboard-legacy-team-invitations
:dashboard-invitations
:dashboard-legacy-team-webhooks
:dashboard-webhooks
:dashboard-legacy-team-settings
:dashboard-settings)]
(mf/with-effect []
(let [params {:team-id team-id
:project-id project-id
:search-term search-term
:plugin plugin-url
:template template}]
(st/emit! (rt/nav section (d/without-nils params)))))
[:> loader*
{:title (tr "labels.loading")
:overlay true}]))
(mf/defc viewer-legacy-redirect*
{::mf/props :obj
::mf/private true}
[{:keys [page-id file-id section index share-id interactions-mode frame-id share]}]
(mf/with-effect []
(let [params {:page-id page-id
:file-id file-id
:section section
:index index
:share-id share-id
:interactions-mode interactions-mode
:frame-id frame-id
:share share}]
(st/emit! (rt/nav :viewer (d/without-nils params)))))
[:> loader*
{:title (tr "labels.loading")
:overlay true}])
(mf/defc team-container*
{::mf/props :obj
::mf/private true}
@@ -235,6 +315,57 @@
:share share}]])
:workspace-legacy
(let [project-id (some-> params :path :project-id uuid/parse*)
file-id (some-> params :path :file-id uuid/parse*)
page-id (some-> params :query :page-id uuid/parse*)
layout (some-> params :query :layout keyword)]
[:> workspace-legacy-redirect*
{:project-id project-id
:file-id file-id
:page-id page-id
:layout layout}])
(:dashboard-legacy-search
:dashboard-legacy-projects
:dashboard-legacy-files
:dashboard-legacy-libraries
:dashboard-legacy-fonts
:dashboard-legacy-font-providers
:dashboard-legacy-team-members
:dashboard-legacy-team-invitations
:dashboard-legacy-team-webhooks
:dashboard-legacy-team-settings)
(let [team-id (some-> params :path :team-id uuid/parse*)
project-id (some-> params :path :project-id uuid/parse*)
search-term (some-> params :query :search-term)
plugin-url (some-> params :query :plugin)
template (some-> params :template)]
[:> dashboard-legacy-redirect*
{:team-id team-id
:section section
:project-id project-id
:search-term search-term
:plugin-url plugin-url
:template template}])
:viewer-legacy
(let [{:keys [query-params path-params]} route
{:keys [index share-id section page-id interactions-mode frame-id share]
:or {section :interactions interactions-mode :show-on-click}} query-params
{:keys [file-id]} path-params]
[:> viewer-legacy-redirect*
{:page-id page-id
:file-id file-id
:section section
:index index
:share-id share-id
:interactions-mode (keyword interactions-mode)
:frame-id frame-id
:share share}])
:frame-preview
[:> frame-preview/frame-preview*]
+16 -1
View File
@@ -55,6 +55,8 @@
["/view" :viewer]
["/view/:file-id" :viewer-legacy]
(when *assert*
["/debug/icons-preview" :debug-icons-preview])
@@ -77,7 +79,20 @@
["/files" :dashboard-files]
["/deleted" :dashboard-deleted]]
["/workspace" :workspace]])
["/dashboard/team/:team-id"
["/members" :dashboard-legacy-team-members]
["/invitations" :dashboard-legacy-team-invitations]
["/webhooks" :dashboard-legacy-team-webhooks]
["/settings" :dashboard-legacy-team-settings]
["/projects" :dashboard-legacy-projects]
["/search" :dashboard-legacy-search]
["/fonts" :dashboard-legacy-fonts]
["/fonts/providers" :dashboard-legacy-font-providers]
["/libraries" :dashboard-legacy-libraries]
["/projects/:project-id" :dashboard-legacy-files]]
["/workspace" :workspace]
["/workspace/:project-id/:file-id" :workspace-legacy]])
(defn- store-session-params
@@ -1,121 +0,0 @@
;; 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]))
(defn- with-stubbed-href
"Run `thunk` with the `globals/location` href replaced and a recording
`js/history.replaceState`. Only the href is stubbed: the effect parses
everything it needs out of it."
[href replace-calls thunk]
(let [loc globals/location
old-href (.-href loc)
old-history (.-history js/globalThis)]
(set! (.-href loc) href)
(set! (.-history js/globalThis)
#js {:replaceState (fn [_ _ url] (swap! replace-calls conj url))})
(try
(thunk)
(finally
(set! (.-href loc) old-href)
(set! (.-history js/globalThis) old-history)))))
(defn- emit-navigated
"Run the `navigated` effect with `match` stored as the state route.
The closed-over match is deliberately empty to prove the effect reads
the route from the state, not from the closure."
[match]
(ptk/effect (rt/navigated {} false) {:route match} nil))
(t/deftest navigated-mirrors-context-on-change
;; New context in the state route triggers exactly one mirrored write.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/#/workspace?file-id=file-1"
calls
(fn []
(emit-navigated {:query-params {:file-id "file-1"}})
(t/is (= ["http://localhost/?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-href
"http://localhost/?file-id=file-1#/workspace?file-id=file-1"
calls
(fn []
(emit-navigated {:query-params {:file-id "file-1"}})
(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-href
"http://localhost/?file-id=old#/dashboard/recent?team-id=team-1"
calls
(fn []
(emit-navigated {:query-params {:team-id "team-1"}})
(t/is (= ["http://localhost/?team-id=team-1#/dashboard/recent?team-id=team-1"] @calls))))))
(t/deftest navigated-clears-query-without-context
;; Routes without context clear a stale pre-fragment query.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/?file-id=old#/auth/login"
calls
(fn []
(emit-navigated {:query-params {:token "some-token"}})
(t/is (= ["http://localhost/#/auth/login"] @calls))))))
(t/deftest navigated-preserves-unrelated-params
;; Params owned by other code are kept as they are.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/?debug=1&file-id=old#/workspace?file-id=file-1"
calls
(fn []
(emit-navigated {:query-params {:file-id "file-1"}})
(t/is (= ["http://localhost/?debug=1&file-id=file-1#/workspace?file-id=file-1"] @calls))))))
(t/deftest navigated-mirrors-every-present-context-id
;; Every present context id is mirrored; the backend applies its own
;; file > project > team priority, so no filtering happens here.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"
calls
(fn []
(emit-navigated {:query-params {:file-id "file-1"
:team-id "team-1"
:project-id "project-1"
:page-id "page-1"}})
(t/is (= ["http://localhost/?file-id=file-1&team-id=team-1&project-id=project-1#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"]
@calls))))))
(t/deftest navigated-repeated-key-last-wins
;; A repeated query key arrives as a vector; the last value wins.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/#/workspace?file-id=file-1"
calls
(fn []
(emit-navigated {:query-params {:file-id ["file-old" "file-1"]}})
(t/is (= ["http://localhost/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
(t/deftest navigated-keeps-subpath-base
;; Under a subpath deployment the prefix survives untouched.
(let [calls (atom [])]
(with-stubbed-href
"http://localhost/penpot/#/workspace?file-id=file-1"
calls
(fn []
(emit-navigated {:query-params {:file-id "file-1"}})
(t/is (= ["http://localhost/penpot/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
-2
View File
@@ -70,7 +70,6 @@
[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]
@@ -180,7 +179,6 @@
'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
+3 -1
View File
@@ -40,7 +40,9 @@ fn draw_surface_src_rect_to_dst(
return;
}
to_canvas.save();
to_canvas.clip_rect(dst, None, true);
// Hard clip: AA softens shared tile edges so the backbuffer background
// shows through as 1px seams when tiles are composed with SrcOver.
to_canvas.clip_rect(dst, None, false);
let sx = dst.width() / src.width();
let sy = dst.height() / src.height();
to_canvas.translate((dst.left, dst.top));