diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md
index 1236dd7458..ff838d77a5 100644
--- a/.opencode/agents/planner.md
+++ b/.opencode/agents/planner.md
@@ -20,6 +20,9 @@ everything they need to know: which files to touch for each task, code, testing,
docs they might need to check, how to test it. Give them the whole plan as
bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
+Do **not** suggest commit messages or commit names anywhere in your plans or
+responses — committing is the developer's responsibility.
+
Assume they are a skilled developer, but know almost nothing about our toolset
or problem domain. Assume they don't know good test design very well.
diff --git a/CHANGES.md b/CHANGES.md
index 7d555b47a8..039e0f8f5e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -146,18 +146,26 @@
### :sparkles: New features & Enhancements
-- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
-- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
+- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
+- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
### :bug: Bugs fixed
-- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
-- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
+- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
+- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435)
+- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321)
+- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
+- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
-- Fix false “text editing” warning when applying tokens [Github #6346](https://github.com/penpot/penpot/issues/9346)
-
+- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
+- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
+- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
+- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
+- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126)
+- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110)
+- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178)
## 2.14.5
@@ -174,6 +182,7 @@
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
+
## 2.14.3
### :sparkles: New features & Enhancements
@@ -183,7 +192,6 @@
### :bug: Bugs fixed
-- Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882)
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
@@ -204,6 +212,7 @@
- Fix typo `:podition` in swap-shapes grid cell
- Fix multiple selection on shapes with token applied to stroke color
+
## 2.14.2
### :sparkles: New features & Enhancements
@@ -264,8 +273,6 @@
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
-- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
-- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
### :bug: Bugs fixed
@@ -274,17 +281,13 @@
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
-- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058)
-- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
-- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
-- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
@@ -319,8 +322,6 @@
### :heart: Community contributions (Thank you!)
-- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689)
-
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
### :sparkles: New features & Enhancements
@@ -343,12 +344,8 @@
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
-- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
-- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
-- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
-- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
@@ -441,10 +438,8 @@ example. It's still usable as before, we just removed the example.
### :bug: Bugs fixed
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
-- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
-- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
@@ -525,7 +520,7 @@ example. It's still usable as before, we just removed the example.
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
-- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
+- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12385](https://tree.taiga.io/project/penpot/issue/12385)
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
- Fix remove flex button doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj
index 782dfabca7..571fb0d819 100644
--- a/backend/src/app/auth/oidc.clj
+++ b/backend/src/app/auth/oidc.clj
@@ -43,7 +43,7 @@
(defn- discover-oidc-config
[cfg {:keys [base-uri] :as provider}]
(let [uri (u/join base-uri ".well-known/openid-configuration")
- rsp (http/req! cfg {:method :get :uri (dm/str uri)})]
+ rsp (http/req cfg {:method :get :uri (dm/str uri)})]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
@@ -105,7 +105,7 @@
(defn- fetch-oidc-jwks
[cfg jwks-uri]
- (let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})]
+ (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(ex/raise :type ::internal
@@ -235,7 +235,7 @@
:timeout 6000
:method :get}
- {:keys [status body]} (http/req! cfg params)]
+ {:keys [status body]} (http/req cfg params)]
(when-not (int-in-range? status 200 300)
(ex/raise :type :internal
@@ -453,7 +453,7 @@
:grant-type (:grant_type params)
:redirect-uri (:redirect_uri params))
- (let [{:keys [status body]} (http/req! cfg req)]
+ (let [{:keys [status body]} (http/req cfg req)]
(if (= status 200)
(let [data (json/decode body)
data {:token/access (get data :access_token)
@@ -508,7 +508,7 @@
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
:timeout 6000
:method :get}
- response (http/req! cfg params)]
+ response (http/req cfg params)]
(l/trc :hint "user info response"
:status (:status response)
diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 327d64da99..520c921397 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -85,7 +85,11 @@
:email-verify-threshold "15m"
:quotes-upload-sessions-per-profile 5
- :quotes-upload-chunks-per-session 20})
+ :quotes-upload-chunks-per-session 20
+
+ ;; SSRF protection
+ :ssrf-allowed-hosts #{}
+ :ssrf-extra-blocked-cidrs #{}})
(def schema:config
(do #_sm/optional-keys
@@ -245,7 +249,11 @@
[:objects-storage-fs-directory {:optional true} :string]
[:objects-storage-s3-bucket {:optional true} :string]
[:objects-storage-s3-region {:optional true} :keyword]
- [:objects-storage-s3-endpoint {:optional true} ::sm/uri]]))
+ [:objects-storage-s3-endpoint {:optional true} ::sm/uri]
+
+ ;; SSRF protection
+ [:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
+ [:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]]))
(defn- parse-flags
[config]
diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj
index c716ce8b85..d07090e074 100644
--- a/backend/src/app/http/assets.clj
+++ b/backend/src/app/http/assets.clj
@@ -12,43 +12,56 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.db :as db]
+ [app.http.session :as session]
[app.storage :as sto]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
-(def ^:private cache-max-age
+(def ^:private default-cache-max-age
(ct/duration {:hours 24}))
-(def ^:private signature-max-age
+(def ^:private default-signature-max-age
(ct/duration {:hours 24 :minutes 15}))
+;; Buckets that are legitimately public and do not require authentication.
+;; These are used by public shared board viewing, profile photos in UI,
+;; and embedded export/binfile flows.
+(def ^:private public-buckets
+ #{"file-media-object"
+ "file-object-thumbnail"
+ "team-font-variant"
+ "file-data-fragment"})
+
(defn get-id
[{:keys [path-params]}]
(or (some-> path-params :id d/parse-uuid)
(ex/raise :type :not-found
- :hunt "object not found")))
+ :hint "object not found")))
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
- [{:keys [::sto/storage] :as cfg} obj]
- (let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
+ [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
+ (let [sig-max-age (or signature-max-age default-signature-max-age)
+ cch-max-age (or cache-max-age default-cache-max-age)
+ {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
{::yres/status 307
::yres/headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
- "cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
+ "cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
(defn- serve-object-from-fs
- [{:keys [::path]} obj]
- (let [purl (u/join (u/uri path)
+ [{:keys [::path ::cache-max-age]} obj]
+ (let [cch-max-age (or cache-max-age default-cache-max-age)
+ purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
- "cache-control" (str "max-age=" (inst-ms cache-max-age))}]
+ "cache-control" (str "max-age=" (inst-ms cch-max-age))}]
{::yres/status 204
::yres/headers headers}))
@@ -60,14 +73,28 @@
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
+(defn- requires-auth?
+ "Check if the storage object requires authentication based on its bucket."
+ [obj]
+ (let [bucket (-> obj meta :bucket)]
+ (not (contains? public-buckets bucket))))
+
(defn objects-handler
- "Handler that servers storage objects by id."
+ "Handler that serves storage objects by id.
+ For non-public buckets (e.g. profile), requires an authenticated session."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
- (if obj
- (serve-object cfg obj)
- {::yres/status 404})))
+ (cond
+ (nil? obj)
+ {::yres/status 404}
+
+ (and (requires-auth? obj)
+ (nil? (::session/profile-id request)))
+ {::yres/status 401}
+
+ :else
+ (serve-object cfg obj))))
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
@@ -96,11 +123,12 @@
(defmethod ig/assert-key ::routes
[_ params]
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
+ (assert (session/manager? (::session/manager params)) "expected valid session manager")
(assert (string? (::path params))))
(defmethod ig/init-key ::routes
[_ cfg]
- ["/assets"
+ ["/assets" {:middleware [[session/authz cfg]]}
["/by-id/:id" {:handler (partial objects-handler cfg)}]
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj
index 051a2ecf8b..c90b0d7a82 100644
--- a/backend/src/app/http/awsns.clj
+++ b/backend/src/app/http/awsns.clj
@@ -53,7 +53,7 @@
(let [surl (get body "SubscribeURL")
stopic (get body "TopicArn")]
(l/info :action "subscription received" :topic stopic :url surl)
- (http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
+ (http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
(= mtype "Notification")
(when-let [message (parse-json (get body "Message"))]
diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj
index 7fcbd3ff00..5e10ee4c8f 100644
--- a/backend/src/app/http/client.clj
+++ b/backend/src/app/http/client.clj
@@ -5,13 +5,24 @@
;; Copyright (c) KALEIDOS INC
(ns app.http.client
- "Http client abstraction layer."
+ "Http client abstraction layer.
+
+ All outbound requests made through `req` and `req-with-redirects`
+ are validated against the SSRF blocklist by default. Pass
+ `:skip-ssrf-check? true` in the options map only when the target
+ is a well-known, operator-configured endpoint that cannot be
+ influenced by user input (e.g. internal telemetry, error webhooks)."
(:require
[app.common.schema :as sm]
+ [app.util.ssrf :as ssrf]
+ [cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
(:import
- java.net.http.HttpClient))
+ java.net.http.HttpClient
+ java.net.URI))
+
+(def default-max-redirects 5)
(defn client?
[o]
@@ -23,8 +34,8 @@
(defmethod ig/init-key ::client
[_ _]
- (http/build-client {:connect-timeout 30000 ;; 10s
- :follow-redirects :always}))
+ (http/build-client {:connect-timeout 30000
+ :follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
@@ -44,14 +55,82 @@
:else
(throw (UnsupportedOperationException. "invalid arguments"))))
-(defn req!
- "A convencience toplevel function for gradual migration to a new API
- convention."
+(defn req
+ "Issue a single HTTP request. SSRF validation is applied to the
+ target URI by default; pass `:skip-ssrf-check? true` in `options`
+ to bypass it for known-safe, operator-configured endpoints."
([cfg-or-client request]
- (let [client (resolve-client cfg-or-client)
- request (update request :uri str)]
- (send! client request {})))
- ([cfg-or-client request options]
- (let [client (resolve-client cfg-or-client)
- request (update request :uri str)]
- (send! client request options))))
+ (req cfg-or-client request {}))
+ ([cfg-or-client request {:keys [skip-ssrf-check?] :as options}]
+ (let [request (if skip-ssrf-check?
+ (update request :uri str)
+ (update request :uri ssrf/validate-uri))
+ client (resolve-client cfg-or-client)]
+ (send! client request (dissoc options :skip-ssrf-check?)))))
+
+(defn- resolve-location
+ "Resolve a Location header value against the original request URI.
+ Handles:
+ - Absolute URLs (http:// or https://) — returned as-is.
+ - Protocol-relative URLs (//host/path) — inherit the scheme from base-uri.
+ - Path-absolute and relative URLs — resolved against base-uri via URI.resolve."
+ [^String base-uri ^String location]
+ (cond
+ (or (str/starts-with? location "http://")
+ (str/starts-with? location "https://"))
+ location
+
+ (str/starts-with? location "//")
+ (let [scheme (.getScheme (URI. base-uri))]
+ (str scheme ":" location))
+
+ :else
+ (str (.resolve (URI. base-uri) location))))
+
+(defn- redirect-request
+ "Build the next request for a 3xx redirect.
+ Per RFC 7231 §6.4:
+ - 303 always issues GET (body dropped).
+ - 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped).
+ - 307/308 preserve the original method and body.
+ The Location URI has already been resolved by the caller."
+ [orig-request ^String next-uri status]
+ (let [method (:method orig-request)]
+ (if (or (= status 303)
+ (and (contains? #{301 302} status)
+ (not (contains? #{:get :head} method))))
+ ;; Downgrade to GET, drop body and content-type
+ (-> orig-request
+ (assoc :uri next-uri :method :get)
+ (dissoc :body)
+ (update :headers dissoc "content-type" "content-length"))
+ ;; Preserve method/body (307, 308, or GET/HEAD 301/302)
+ (assoc orig-request :uri next-uri))))
+
+(defn req-with-redirects
+ "Like `req`, but follows up to `max-redirects` HTTP 3xx redirects.
+ SSRF validation is applied before every hop (initial request and
+ each redirect target) unless `:skip-ssrf-check? true` is passed.
+ Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded
+ to GET; 303 always uses GET; 307/308 preserve the original method."
+ ([cfg-or-client request]
+ (req-with-redirects cfg-or-client request {}))
+ ([cfg-or-client request {:keys [max-redirects skip-ssrf-check?]
+ :or {max-redirects default-max-redirects}
+ :as opts}]
+ (let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?)
+ uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)]
+ (loop [current-req (update request :uri uri-coerce)
+ hops 0]
+ (let [client (resolve-client cfg-or-client)
+ resp (send! client current-req send-opts)
+ status (:status resp)]
+ (if (and (<= 300 status 399)
+ (< hops max-redirects))
+ (if-let [location (get-in resp [:headers "location"])]
+ (let [next-uri (resolve-location (str (:uri current-req)) location)]
+ (recur (update (redirect-request current-req next-uri status) :uri uri-coerce)
+ (inc hops)))
+ ;; No Location header on a 3xx — return the response as-is
+ resp)
+ resp))))))
diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj
index 1915652bbd..bc70e4ec83 100644
--- a/backend/src/app/loggers/audit/archive_task.clj
+++ b/backend/src/app/loggers/audit/archive_task.clj
@@ -59,7 +59,7 @@
:method :post
:headers headers
:body body}
- resp (http/req! cfg params)]
+ resp (http/req cfg params)]
(if (= (:status resp) 204)
true
diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj
index c2fd3d1dfb..52fd2edce3 100644
--- a/backend/src/app/loggers/mattermost.clj
+++ b/backend/src/app/loggers/mattermost.clj
@@ -52,12 +52,12 @@
trace
"```")))
- resp (http/req! cfg
- {:uri (cf/get :error-report-webhook)
- :method :post
- :headers {"content-type" "application/json"}
- :body (json/encode-str {:text text})}
- {:sync? true})]
+ resp (http/req cfg
+ {:uri (cf/get :error-report-webhook)
+ :method :post
+ :headers {"content-type" "application/json"}
+ :body (json/encode-str {:text text})}
+ {:sync? true})]
(when (not= 200 (:status resp))
(l/warn :hint "error on sending data"
diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj
index 8edb646260..eab1344047 100644
--- a/backend/src/app/loggers/webhooks.clj
+++ b/backend/src/app/loggers/webhooks.clj
@@ -159,7 +159,7 @@
:method :post
:body body}]
(try
- (let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
+ (let [rsp (http/req cfg req {:response-type :input-stream :sync? true})
err (interpret-response rsp)]
(report-delivery! whook req rsp err)
(update-webhook! whook err))
@@ -190,4 +190,11 @@
"invalid-uri"
(instance? java.net.http.HttpConnectTimeoutException cause)
- "timeout"))
+ "timeout"
+
+ :else
+ (let [data (ex-data cause)]
+ (if (and (= :validation (:type data))
+ (= :ssrf-blocked-target (:code data)))
+ (str "blocked-request:" (:hint data))
+ nil))))
diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj
index a1501e3ca0..9bcc96519a 100644
--- a/backend/src/app/main.clj
+++ b/backend/src/app/main.clj
@@ -304,10 +304,11 @@
::session/manager (ig/ref ::session/manager)}
:app.http.assets/routes
- {::http.assets/path (cf/get :assets-path)
- ::http.assets/cache-max-age (ct/duration {:hours 24})
- ::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
- ::sto/storage (ig/ref ::sto/storage)}
+ {::http.assets/path (cf/get :assets-path)
+ ::http.assets/cache-max-age (ct/duration {:hours 24})
+ ::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
+ ::sto/storage (ig/ref ::sto/storage)
+ ::session/manager (ig/ref ::session/manager)}
::rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)
diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj
index 9cf135213d..25bd3e0002 100644
--- a/backend/src/app/media.clj
+++ b/backend/src/app/media.clj
@@ -18,6 +18,7 @@
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
+ [app.media.sanitize :as sanitize]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[buddy.core.bytes :as bb]
@@ -325,9 +326,11 @@
(let [{:keys [body] :as response}
(try
- (http/req! client
- {:method :get :uri uri}
- {:response-type :input-stream})
+ (http/req-with-redirects
+ client
+ {:method :get :uri uri}
+ {:response-type :input-stream
+ :max-redirects 3})
(catch java.net.ConnectException cause
(ex/raise :type :validation
:code :unable-to-download-image
@@ -358,9 +361,11 @@
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
- {;; :size size
- :path path
- :mtype mtype})))
+ ;; Sanitize: strip trailing data after image EOF markers
+ (let [new-size (sanitize/truncate-after-eof path mtype)]
+ {:path path
+ :mtype mtype
+ :size new-size}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS
diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj
new file mode 100644
index 0000000000..4905f5b603
--- /dev/null
+++ b/backend/src/app/media/sanitize.clj
@@ -0,0 +1,191 @@
+;; 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
+
+(ns app.media.sanitize
+ "Image EOF truncation helpers — strips trailing data after image EOF
+ markers to prevent exfiltration of non-image bytes appended to
+ valid image files."
+ (:require
+ [app.common.buffer :as buf]
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [app.util.nio :as nio])
+ (:import
+ java.nio.ByteOrder
+ java.nio.channels.FileChannel))
+
+(set! *warn-on-reflection* true)
+
+(defn- scan-backwards
+ "Scan byte array `arr` backwards (from the end) for the byte pattern
+ `marker`. Returns the index in `arr` where the marker starts, or -1
+ if not found."
+ [^bytes arr ^bytes marker]
+ (let [arr-len (alength arr)
+ marker-len (alength marker)]
+ (loop [i (- arr-len marker-len)]
+ (if (< i 0)
+ -1
+ (if (loop [j 0]
+ (if (>= j marker-len)
+ true
+ (if (= (aget arr (+ i j)) (aget marker j))
+ (recur (inc j))
+ false)))
+ i
+ (recur (dec i)))))))
+
+(defn- find-last-png-iend
+ "Find the byte offset of the end of the PNG IEND chunk (12 bytes:
+ 4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset
+ AFTER the CRC32, or nil if not found."
+ [^FileChannel channel]
+ (let [size (nio/channel-size channel)]
+ (when (> size 8)
+ (let [buf-size (min (int size) (* 1024 1024))
+ marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND"
+ (loop [pos (max 0 (- size buf-size))]
+ (when (< pos size)
+ (let [arr (nio/read-at channel pos buf-size)
+ idx (scan-backwards arr marker)]
+ (if (neg? idx)
+ ;; Not found in this chunk, try earlier
+ (let [next-pos (max 0 (- pos (- buf-size 4)))]
+ (when (< next-pos pos)
+ (recur next-pos)))
+ ;; Found "IEND" at idx. Chunk starts 4 bytes before.
+ (let [chunk-start (- (+ pos idx) 4)]
+ (when (>= chunk-start 0)
+ ;; PNG chunk length is big-endian (network byte order).
+ ;; buf/wrap defaults to little-endian, so set it to big-endian.
+ (let [len-arr (nio/read-at channel chunk-start 4)
+ len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN)
+ chunk-len (buf/read-int len-buf 0)]
+ (when (zero? chunk-len)
+ (+ chunk-start 12)))))))))))))
+
+(defn- find-last-jpeg-eoi
+ "Find the byte offset of the last JPEG EOI marker (0xFF 0xD9).
+ Returns the offset AFTER the marker, or nil if not found."
+ [^FileChannel channel]
+ (let [size (nio/channel-size channel)]
+ (when (> size 2)
+ (let [buf-size (min (int size) (* 1024 1024))
+ marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])]
+ (loop [pos (max 0 (- size buf-size))]
+ (when (< pos size)
+ (let [arr (nio/read-at channel pos buf-size)
+ idx (scan-backwards arr marker)]
+ (if (neg? idx)
+ (let [next-pos (max 0 (- pos (- buf-size 2)))]
+ (when (< next-pos pos)
+ (recur next-pos)))
+ (+ pos idx 2)))))))))
+
+(defn- find-last-gif-trailer
+ "Find the byte offset immediately after the last GIF trailer byte (0x3B).
+ Scans backwards through the file so that appended data after the real
+ trailer is truncated even when it ends with 0x3B.
+ Returns the offset AFTER the trailer byte, or nil if 0x3B is not found."
+ [^FileChannel channel]
+ (let [size (nio/channel-size channel)]
+ (when (pos? size)
+ (let [buf-size (min (int size) (* 1024 1024))
+ marker (byte-array [(unchecked-byte 0x3B)])]
+ (loop [pos (max 0 (- size buf-size))]
+ (when (< pos size)
+ (let [arr (nio/read-at channel pos buf-size)
+ idx (scan-backwards arr marker)]
+ (if (neg? idx)
+ (let [next-pos (max 0 (- pos (- buf-size 1)))]
+ (when (< next-pos pos)
+ (recur next-pos)))
+ (+ pos idx 1)))))))))
+
+(defn- find-webp-end
+ "Parse the WebP RIFF header to find the declared file size.
+ WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian)
+ + 'WEBP' (4 bytes). The total size is the offset of the end of the file.
+ Returns nil if the RIFF or WEBP magic bytes are missing."
+ [^FileChannel channel]
+ (let [size (nio/channel-size channel)]
+ (when (>= size 12)
+ (let [^bytes arr (nio/read-at channel 0 12)
+ buf (buf/wrap arr)]
+ ;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11)
+ (when (and (= (aget arr 0) (byte 0x52)) ;; 'R'
+ (= (aget arr 1) (byte 0x49)) ;; 'I'
+ (= (aget arr 2) (byte 0x46)) ;; 'F'
+ (= (aget arr 3) (byte 0x46)) ;; 'F'
+ (= (aget arr 8) (byte 0x57)) ;; 'W'
+ (= (aget arr 9) (byte 0x45)) ;; 'E'
+ (= (aget arr 10) (byte 0x42)) ;; 'B'
+ (= (aget arr 11) (byte 0x50))) ;; 'P'
+ (let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)]
+ ;; RIFF size field is the size of the file minus 8 bytes
+ (+ riff-size 8)))))))
+
+(defn truncate-after-eof
+ "Given a `java.nio.file.Path` to a freshly-downloaded media file and a
+ declared MIME type, truncate the file in place to the position of the
+ format's EOF marker:
+ - image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32)
+ - image/jpeg → 2 bytes after FFD9
+ - image/gif → immediately after the last GIF trailer byte 0x3B
+ - image/webp → end of RIFF chunk declared in bytes 4..8
+ - image/svg+xml → no-op (text format; processed by SAX parser)
+ - other → no-op (return path unchanged)
+ Returns the new file size. Raises `:validation/:invalid-image` if no
+ EOF marker is found within the file."
+ [^java.nio.file.Path path ^String mtype]
+ (try
+ (with-open [channel (nio/open-channel path)]
+ (let [size (nio/channel-size channel)]
+ (if (zero? size)
+ 0
+ (let [needs-eof-marker? (or (= mtype "image/png")
+ (= mtype "image/jpeg")
+ (= mtype "image/gif")
+ (= mtype "image/webp"))
+
+ eof-offset
+ (cond
+ (= mtype "image/png") (find-last-png-iend channel)
+ (= mtype "image/jpeg") (find-last-jpeg-eoi channel)
+ (= mtype "image/gif") (find-last-gif-trailer channel)
+ (= mtype "image/webp") (find-webp-end channel)
+ :else nil)]
+
+ (cond
+ ;; No EOF marker applicable (SVG or other) — no-op
+ (nil? eof-offset)
+ (if needs-eof-marker?
+ (ex/raise :type :validation
+ :code :invalid-image
+ :hint "image format EOF marker not found")
+ size)
+
+ ;; Truncate if needed
+ (< eof-offset size)
+ (do
+ (l/dbg :hint "truncating trailing data"
+ :path (str path)
+ :mtype mtype
+ :original-size size
+ :truncated-to eof-offset)
+ (nio/truncate channel eof-offset)
+ eof-offset)
+
+ ;; Already at correct size or marker at end
+ :else
+ eof-offset)))))
+ (catch Exception e
+ (if (ex/exception? e)
+ (throw e)
+ (ex/raise :type :validation
+ :code :invalid-image
+ :hint "failed to sanitize image"
+ :cause e)))))
diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj
index 35d8d4f3ad..61f652c1ef 100644
--- a/backend/src/app/nitrate.clj
+++ b/backend/src/app/nitrate.clj
@@ -29,14 +29,14 @@
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
- (http/req! cfg (cond-> {:method method
- :headers {"content-type" "application/json"
- "accept" "application/json"
- "x-shared-key" shared-key
- "x-profile-id" (str profile-id)}
- :uri uri
- :version :http1.1}
- (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
+ (http/req cfg (cond-> {:method method
+ :headers {"content-type" "application/json"
+ "accept" "application/json"
+ "x-shared-key" shared-key
+ "x-profile-id" (str profile-id)}
+ :uri uri
+ :version :http1.1}
+ (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]
diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj
index 5b64541824..7a3c441335 100644
--- a/backend/src/app/rpc/commands/webhooks.clj
+++ b/backend/src/app/rpc/commands/webhooks.clj
@@ -50,24 +50,27 @@
(defn- validate-webhook!
[cfg whook params]
(when (not= (:uri whook) (:uri params))
- (let [response (ex/try!
- (http/req! cfg
+ (try
+ (let [response (http/req cfg
{:method :head
:uri (str (:uri params))
- :timeout (ct/duration "3s")}
- {:sync? true}))]
- (if (ex/exception? response)
- (if-let [hint (webhooks/interpret-exception response)]
- (ex/raise :type :validation
- :code :webhook-validation
- :hint hint)
- (ex/raise :type :internal
- :code :webhook-validation
- :cause response))
+ :timeout (ct/duration "3s")})]
(when-let [hint (webhooks/interpret-response response)]
(ex/raise :type :validation
:code :webhook-validation
- :hint hint))))))
+ :hint hint)))
+
+ (catch Throwable cause
+ (if-let [hint (webhooks/interpret-exception cause)]
+ (ex/raise :type :validation
+ :code :webhook-validation
+ :hint hint
+ :webhook-uri (str (:uri params))
+ :cause cause)
+ (ex/raise :type :internal
+ :code :webhook-validation
+ :webhook-uri (str (:uri params))
+ :cause cause))))))
(defn- validate-quotes!
[{:keys [::db/pool]} {:keys [team-id]}]
diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj
index 476ec25f58..5c86bc485d 100644
--- a/backend/src/app/setup/templates.clj
+++ b/backend/src/app/setup/templates.clj
@@ -57,9 +57,9 @@
(if (fs/exists? path)
(io/input-stream path)
- (let [resp (http/req! cfg
- {:method :get :uri (:file-uri template)}
- {:response-type :input-stream :sync? true})]
+ (let [resp (http/req cfg
+ {:method :get :uri (:file-uri template)}
+ {:response-type :input-stream :sync? true})]
(when-not (= 200 (:status resp))
(ex/raise :type :internal
:code :unexpected-status-code
diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj
index aa2cae58e0..c6d989694b 100644
--- a/backend/src/app/tasks/telemetry.clj
+++ b/backend/src/app/tasks/telemetry.clj
@@ -30,7 +30,7 @@
:uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
:body (json/encode-str data)}
- response (http/req! cfg request)]
+ response (http/req cfg request)]
(when (> (:status response) 206)
(ex/raise :type :internal
:code :invalid-response
diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj
new file mode 100644
index 0000000000..676d95aea3
--- /dev/null
+++ b/backend/src/app/util/nio.clj
@@ -0,0 +1,91 @@
+;; 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
+
+(ns app.util.nio
+ "NIO helpers for working with files and byte arrays.
+
+ These are thin wrappers around java.nio that provide a
+ Clojure-idiomatic API. Candidates for porting to datoteka."
+ (:import
+ java.nio.ByteBuffer
+ java.nio.channels.FileChannel
+ java.nio.file.Files
+ java.nio.file.OpenOption
+ java.nio.file.Path
+ java.nio.file.StandardOpenOption))
+
+(set! *warn-on-reflection* true)
+
+;; ----------------------------------------------------------------
+;; File operations (via java.nio.file.Files)
+;; ----------------------------------------------------------------
+
+(defn read-bytes
+ "Read all bytes from a file at `path`. Returns a byte array."
+ ^bytes [^Path path]
+ (Files/readAllBytes path))
+
+(defn write-bytes
+ "Write `data` (byte array) to a file at `path`, replacing existing
+ content. Returns `path`."
+ [^Path path ^bytes data]
+ (Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption []))
+ path)
+
+(defn append-bytes
+ "Append `data` (byte array) to the end of the file at `path`.
+ Creates the file if it does not exist. Returns `path`."
+ [^Path path ^bytes data]
+ (Files/write path data
+ ^"[Ljava.nio.file.OpenOption;"
+ (into-array OpenOption
+ [StandardOpenOption/CREATE
+ StandardOpenOption/APPEND]))
+ path)
+
+;; ----------------------------------------------------------------
+;; FileChannel operations (internal API)
+;; ----------------------------------------------------------------
+
+(def ^:private read-write-opts
+ (into-array OpenOption
+ [StandardOpenOption/READ StandardOpenOption/WRITE]))
+
+(defn open-channel
+ "Open a FileChannel for read/write on the given path."
+ ^FileChannel [^Path path]
+ (FileChannel/open path read-write-opts))
+
+(defn channel-size
+ "Return the size of the file backed by the channel."
+ ^long [^FileChannel channel]
+ (.size channel))
+
+(defn read-at
+ "Read `length` bytes from `channel` starting at `position` into a
+ new byte array. Returns the byte array.
+ Loops until the ByteBuffer is fully populated to guard against OS
+ partial reads, which would otherwise cause BufferUnderflowException
+ when copying from the buffer into the result array."
+ ^bytes [^FileChannel channel ^long position ^long length]
+ (let [buf (ByteBuffer/allocate (int length))]
+ (.position channel position)
+ (loop []
+ (when (.hasRemaining buf)
+ (let [n (.read channel buf)]
+ (when (pos? n)
+ (recur)))))
+ (.flip buf)
+ (let [remaining (.remaining buf)
+ arr (byte-array remaining)]
+ (.get buf arr)
+ arr)))
+
+(defn truncate
+ "Truncate the file to the given size. Returns the channel."
+ [^FileChannel channel ^long size]
+ (.truncate channel size)
+ channel)
diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj
new file mode 100644
index 0000000000..5348f1eaf7
--- /dev/null
+++ b/backend/src/app/util/ssrf.clj
@@ -0,0 +1,229 @@
+;; 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
+
+(ns app.util.ssrf
+ "URL/host validation to prevent Server-Side Request Forgery."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [app.config :as cf]
+ [cuerdas.core :as str])
+ (:import
+ com.google.common.net.InetAddresses
+ java.net.InetAddress
+ java.net.UnknownHostException
+ java.net.URI))
+
+(def ^:private allowed-schemes
+ #{"http" "https"})
+
+(def ^:private cloud-metadata-ips
+ "Exact IP addresses for cloud metadata services."
+ #{"169.254.169.254"
+ "fd00:ec2::254"})
+
+(def ^:private extra-blocked-ranges
+ "CIDR ranges not covered by standard JDK InetAddress predicates.
+ Each entry is [base-address prefix-length]."
+ ;; Carrier-grade NAT
+ [[100 64 0 0 10]
+ ;; RFC 6890 / documentation / reserved
+ [192 0 0 0 24]
+ [192 0 2 0 24]
+ [198 18 0 0 15]
+ [198 51 100 0 24]
+ [203 0 113 0 24]
+ ;; Reserved / future-use (broadcast and above)
+ [240 0 0 0 4]])
+
+(defn- ip4-to-long
+ "Convert a 4-element byte array (IPv4) to a 32-bit long."
+ ^long [^bytes bs]
+ (bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24)
+ (bit-shift-left (bit-and (aget bs 1) 0xFF) 16)
+ (bit-shift-left (bit-and (aget bs 2) 0xFF) 8)
+ (bit-and (aget bs 3) 0xFF)))
+
+(defn- prefix-mask
+ "Return a 32-bit mask for the given prefix length."
+ ^long [^long prefix-len]
+ (if (zero? prefix-len)
+ 0
+ (bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len))))
+
+(defn- in-cidr4?
+ "Check if an IPv4 address (as byte array) falls within a CIDR range
+ specified as [a b c d prefix-len]."
+ [^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]]
+ (let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24)
+ (bit-shift-left (bit-and b 0xFF) 16)
+ (bit-shift-left (bit-and c 0xFF) 8)
+ (bit-and d 0xFF))
+ mask (prefix-mask prefix-len)
+ ip-val (ip4-to-long addr)]
+ (= (bit-and ip-val mask) (bit-and base mask))))
+
+(defn- parse-cidr*
+ "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input."
+ [^String cidr]
+ (let [parts (str/split cidr #"/" 2)
+ prefix-len (when (= 2 (count parts))
+ (parse-long (nth parts 1)))]
+ (when-not prefix-len
+ (ex/raise :type :internal
+ :code :invalid-cidr
+ :hint (str "invalid CIDR notation: " cidr)))
+ (let [octets (str/split (first parts) #"\.")]
+ (when (not= 4 (count octets))
+ (ex/raise :type :internal
+ :code :invalid-cidr
+ :hint (str "invalid CIDR notation (expected IPv4): " cidr)))
+ (let [[a b c d] (map parse-long octets)]
+ (when (or (nil? a) (nil? b) (nil? c) (nil? d)
+ (not (<= 0 a 255)) (not (<= 0 b 255))
+ (not (<= 0 c 255)) (not (<= 0 d 255))
+ (not (<= 0 prefix-len 32)))
+ (ex/raise :type :internal
+ :code :invalid-cidr
+ :hint (str "invalid CIDR notation: " cidr)))
+ [a b c d prefix-len]))))
+
+(defn parse-cidr
+ "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len].
+ Returns nil and logs a warning on invalid input."
+ [^String cidr]
+ (try
+ (parse-cidr* cidr)
+ (catch Exception _
+ (l/warn :hint "ignoring invalid CIDR" :cidr cidr)
+ nil)))
+
+(defonce ^:dynamic extra-blocked-cidrs
+ (into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{})))
+
+(defn- ipv6-ula?
+ "Check if an IPv6 address is in the Unique Local Address range (fc00::/7)."
+ [^InetAddress addr]
+ (let [bs (.getAddress addr)]
+ (and (>= (alength bs) 16)
+ (= (bit-and (aget bs 0) 0xFE) 0xFC))))
+
+(defn- ipv4-mapped-loopback?
+ "Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)."
+ [^InetAddress addr]
+ (let [bs (.getAddress addr)]
+ (and (= (alength bs) 16)
+ ;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x
+ (= (aget bs 10) (byte -1)) ;; 0xFF
+ (= (aget bs 11) (byte -1)) ;; 0xFF
+ ;; Check the embedded IPv4 is loopback (127.x.x.x)
+ (= (bit-and (aget bs 12) 0xFF) 127))))
+
+(defn- blocked-address?
+ "Check if an InetAddress should be blocked. Returns true if blocked."
+ [^InetAddress addr]
+ (or
+ (.isAnyLocalAddress addr) ;; 0.0.0.0 or ::
+ (.isLoopbackAddress addr) ;; 127/8 or ::1
+ (.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10
+ (.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16
+ (.isMulticastAddress addr)
+
+ ;; IPv6 ULA (fc00::/7)
+ (ipv6-ula? addr)
+
+ ;; IPv4-mapped loopback
+ (ipv4-mapped-loopback? addr)
+
+ ;; Cloud metadata IPs (exact match)
+ (contains? cloud-metadata-ips (.getHostAddress addr))
+
+ ;; Extra blocked CIDRs (IPv4 only)
+ (let [bs (.getAddress addr)]
+ (if (= (alength bs) 4)
+ (or (some #(in-cidr4? bs %) extra-blocked-ranges)
+ (some #(in-cidr4? bs %) extra-blocked-cidrs))
+ false))))
+
+(defn resolve-host
+ "Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
+ so it can be stubbed in tests."
+ [^String hostname]
+ (try
+ (InetAddress/getAllByName hostname)
+ (catch UnknownHostException _
+ nil)))
+
+(defn validate-uri
+ "Validates `uri-or-string`:
+ - scheme must be http or https,
+ - host must resolve to at least one address, and
+ - **every** resolved address must NOT be in the blocklist
+ (loopback, link-local, site-local, multicast, any-local,
+ cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
+ IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
+ When the host is an IP literal (decimal/octal/hex/IPv6) it is
+ normalized via `com.google.common.net.InetAddresses` before the
+ check.
+ Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass
+ the IP check.
+ Throws `ex/raise :type :validation :code :ssrf-blocked-target` with
+ a hint that does NOT echo the resolved IP (avoid info leak)."
+ [uri-or-string]
+ (let [uri (if (instance? URI uri-or-string)
+ uri-or-string
+ (URI. (str uri-or-string)))
+ scheme (.getScheme uri)
+ host (.getHost uri)]
+
+ ;; Validate scheme
+ (when (or (nil? scheme)
+ (not (contains? allowed-schemes (str/lower scheme))))
+ (ex/raise :type :validation
+ :code :ssrf-blocked-target
+ :hint "url scheme is not allowed"))
+
+ ;; Validate host presence
+ (when (or (nil? host) (str/blank? host))
+ (ex/raise :type :validation
+ :code :ssrf-blocked-target
+ :hint "url host is missing"))
+
+ ;; Check allowlist
+ (let [allowed-hosts (cf/get :ssrf-allowed-hosts #{})
+ host-lower (str/lower host)]
+
+ (when-not (contains? allowed-hosts host-lower)
+ ;; Normalize the host: if it looks like an IP literal, normalize it
+ ;; via Guava to catch decimal/octal/hex encodings
+ (let [normalized (if (InetAddresses/isInetAddress host)
+ (InetAddresses/forString host)
+ nil)
+ host-to-resolve (if normalized
+ (.getHostAddress ^InetAddress normalized)
+ host)
+ addresses (resolve-host host-to-resolve)]
+
+ (when (or (nil? addresses) (zero? (alength addresses)))
+ (ex/raise :type :validation
+ :code :ssrf-blocked-target
+ :hint "url host could not be resolved"))
+
+ ;; All-or-nothing: if ANY resolved address is blocked, reject
+ (when (some blocked-address? (seq addresses))
+ (ex/raise :type :validation
+ :code :ssrf-blocked-target
+ :hint "url target is not allowed")))))
+ (str uri)))
+
+(defn safe-url?
+ "Predicate version of `validate-uri`. Returns `true` if safe."
+ [uri-or-string]
+ (try
+ (validate-uri uri-or-string)
+ true
+ (catch Exception _
+ false)))
diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj
index c34df71543..d77b3b6be0 100644
--- a/backend/test/backend_tests/loggers_webhooks_test.clj
+++ b/backend/test/backend_tests/loggers_webhooks_test.clj
@@ -41,7 +41,7 @@
(t/is (nil? res)))))
(t/deftest run-webhook-handler-1
- (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}]
+ (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})
whk (th/create-webhook* {:team-id (:default-team-id prof)})
evt {:type "command"
@@ -63,7 +63,7 @@
(t/is (nil? (:error-code whk')))))))
(t/deftest run-webhook-handler-2
- (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 400}}]
+ (with-mocks [http-mock {:target 'app.http.client/req :return {:status 400}}]
(let [prof (th/create-profile* 1 {:is-active true})
whk (th/create-webhook* {:team-id (:default-team-id prof)})
evt {:type "command"
diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj
new file mode 100644
index 0000000000..0f8629a029
--- /dev/null
+++ b/backend/test/backend_tests/media_sanitize_test.clj
@@ -0,0 +1,501 @@
+;; 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
+
+(ns backend-tests.media-sanitize-test
+ (:require
+ [app.media.sanitize :as sanitize]
+ [app.storage.tmp :as tmp]
+ [app.util.nio :as nio]
+ [clojure.test :as t]
+ [datoteka.fs :as fs]
+ [datoteka.io :as io]))
+
+(defn- resource-path
+ "Return a URL to a test resource file."
+ [name]
+ (io/resource (str "backend_tests/test_files/" name)))
+
+(defn- copy-resource-to-tempfile
+ "Copy a test resource file to a tempfile and return the Path."
+ [resource-name suffix]
+ (tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix))
+
+;; ----------------------------------------------------------------
+;; Crafted test data
+;; ----------------------------------------------------------------
+
+;; PNG test data
+(def ^:private png-signature
+ (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A]))
+
+(def ^:private png-iend-chunk
+ (byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]))
+
+(def ^:private png-ihdr-chunk
+ (byte-array [0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
+ 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
+ 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE]))
+
+(defn- make-png [^bytes extra-bytes]
+ (let [parts (if extra-bytes
+ [png-signature png-ihdr-chunk png-iend-chunk extra-bytes]
+ [png-signature png-ihdr-chunk png-iend-chunk])
+ total (reduce + 0 (map alength parts))
+ result (byte-array total)
+ offset (volatile! 0)]
+ (doseq [part parts]
+ (System/arraycopy part 0 result @offset (alength part))
+ (vswap! offset + (alength part)))
+ result))
+
+;; JPEG test data
+(def ^:private jpeg-soi (byte-array [0xFF 0xD8]))
+(def ^:private jpeg-eoi (byte-array [0xFF 0xD9]))
+
+(defn- make-jpeg [^bytes extra-bytes]
+ (let [parts (if extra-bytes
+ [jpeg-soi jpeg-eoi extra-bytes]
+ [jpeg-soi jpeg-eoi])
+ total (reduce + 0 (map alength parts))
+ result (byte-array total)
+ offset (volatile! 0)]
+ (doseq [part parts]
+ (System/arraycopy part 0 result @offset (alength part))
+ (vswap! offset + (alength part)))
+ result))
+
+;; GIF test data
+(def ^:private gif-header
+ (byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a"
+ 0x01 0x00 0x01 0x00 ;; 1x1 canvas
+ 0x00 ;; no GCT
+ 0x00])) ;; background color
+
+(def ^:private gif-trailer (byte-array [0x3B]))
+
+;; WebP test data
+(defn- make-webp [^long total-size]
+ (let [riff-size (- total-size 8)
+ data (byte-array total-size)]
+ (aset data 0 (byte 0x52)) ;; 'R'
+ (aset data 1 (byte 0x49)) ;; 'I'
+ (aset data 2 (byte 0x46)) ;; 'F'
+ (aset data 3 (byte 0x46)) ;; 'F'
+ (aset data 4 (byte (bit-and riff-size 0xFF)))
+ (aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF)))
+ (aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF)))
+ (aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF)))
+ (aset data 8 (byte 0x57)) ;; 'W'
+ (aset data 9 (byte 0x45)) ;; 'E'
+ (aset data 10 (byte 0x42)) ;; 'B'
+ (aset data 11 (byte 0x50)) ;; 'P'
+ data))
+
+(defn- write-data-to-tempfile
+ "Write byte array to a tempfile and return the Path."
+ [^bytes data suffix]
+ (let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)]
+ (nio/write-bytes path data)
+ path))
+
+;; ----------------------------------------------------------------
+;; Tests with crafted data
+;; ----------------------------------------------------------------
+
+(t/deftest png-with-appended-secret-truncated
+ (let [secret (.getBytes "SECRET_DATA_HERE")
+ data (make-png secret)
+ path (write-data-to-tempfile data ".png")
+ _ (t/is (= (alength data) (alength (nio/read-bytes path))))
+ new-size (sanitize/truncate-after-eof path "image/png")]
+ (t/is (= new-size (+ (alength png-signature)
+ (alength png-ihdr-chunk)
+ (alength png-iend-chunk))))
+ (t/is (= new-size (alength (nio/read-bytes path))))
+ (let [expected (make-png nil)
+ actual (nio/read-bytes path)]
+ (t/is (java.util.Arrays/equals expected actual)))))
+
+(t/deftest png-clean-not-truncated
+ (let [data (make-png nil)
+ path (write-data-to-tempfile data ".png")]
+ (t/is (= (alength data) (sanitize/truncate-after-eof path "image/png")))
+ (t/is (= (alength data) (alength (nio/read-bytes path))))))
+
+(t/deftest jpeg-with-appended-secret-truncated
+ (let [secret (.getBytes "\u0000\u0000SECRET")
+ data (make-jpeg secret)
+ path (write-data-to-tempfile data ".jpg")
+ _ (t/is (= (alength data) (alength (nio/read-bytes path))))
+ new-size (sanitize/truncate-after-eof path "image/jpeg")]
+ (t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi))))
+ (let [expected (make-jpeg nil)
+ actual (nio/read-bytes path)]
+ (t/is (java.util.Arrays/equals expected actual)))))
+
+(t/deftest jpeg-clean-not-truncated
+ (let [data (make-jpeg nil)
+ path (write-data-to-tempfile data ".jpg")]
+ (t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg")))
+ (t/is (= (alength data) (alength (nio/read-bytes path))))))
+
+(t/deftest gif-trailer-already-correct
+ (let [parts [gif-header gif-trailer]
+ total (reduce + 0 (map alength parts))
+ data (byte-array total)
+ offset (volatile! 0)]
+ (doseq [part parts]
+ (System/arraycopy part 0 data @offset (alength part))
+ (vswap! offset + (alength part)))
+ (let [path (write-data-to-tempfile data ".gif")]
+ (t/is (= total (sanitize/truncate-after-eof path "image/gif")))
+ (t/is (= total (alength (nio/read-bytes path)))))))
+
+(t/deftest webp-declared-size-honored
+ (let [total-size 24
+ data (make-webp total-size)
+ extra (byte-array 10 (byte 0x42))
+ full-data (byte-array (+ total-size 10))]
+ (System/arraycopy data 0 full-data 0 total-size)
+ (System/arraycopy extra 0 full-data total-size 10)
+ (let [path (write-data-to-tempfile full-data ".webp")]
+ (t/is (= total-size (sanitize/truncate-after-eof path "image/webp")))
+ (t/is (= total-size (alength (nio/read-bytes path)))))))
+
+(t/deftest webp-clean-not-truncated
+ (let [data (make-webp 24)
+ path (write-data-to-tempfile data ".webp")]
+ (t/is (= 24 (sanitize/truncate-after-eof path "image/webp")))
+ (t/is (= 24 (alength (nio/read-bytes path))))))
+
+(t/deftest non-webp-riff-rejected-as-invalid-image
+ ;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be
+ ;; rejected so it cannot bypass sanitization by pretending to be WebP.
+ (let [data (byte-array 24)]
+ ;; Write RIFF magic
+ (aset data 0 (byte 0x52)) ;; 'R'
+ (aset data 1 (byte 0x49)) ;; 'I'
+ (aset data 2 (byte 0x46)) ;; 'F'
+ (aset data 3 (byte 0x46)) ;; 'F'
+ ;; RIFF size = 16 (total 24 - 8)
+ (aset data 4 (byte 16))
+ ;; FourCC = 'WAVE' (not 'WEBP')
+ (aset data 8 (byte 0x57)) ;; 'W'
+ (aset data 9 (byte 0x41)) ;; 'A'
+ (aset data 10 (byte 0x56)) ;; 'V'
+ (aset data 11 (byte 0x45)) ;; 'E'
+ (let [path (write-data-to-tempfile data ".webp")]
+ (try
+ (sanitize/truncate-after-eof path "image/webp")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :invalid-image (:code (ex-data e)))))))))
+
+(t/deftest svg-is-no-op
+ (let [data (.getBytes "")
+ path (write-data-to-tempfile data ".svg")]
+ (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
+ (t/is (= (alength data) (alength (nio/read-bytes path))))))
+
+(t/deftest unknown-mtype-is-no-op
+ (let [data (.getBytes "some binary data")
+ path (write-data-to-tempfile data ".bin")]
+ (t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream")))
+ (t/is (= (alength data) (alength (nio/read-bytes path))))))
+
+(t/deftest png-missing-iend-raises-error
+ (let [data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
+ 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
+ 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
+ 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE])
+ path (write-data-to-tempfile data ".png")]
+ (try
+ (sanitize/truncate-after-eof path "image/png")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :validation (:type (ex-data e))))
+ (t/is (= :invalid-image (:code (ex-data e))))))))
+
+;; ----------------------------------------------------------------
+;; Tests with real files from test_files/
+;; ----------------------------------------------------------------
+
+(t/deftest real-png-clean-not-truncated
+ (let [path (copy-resource-to-tempfile "sample.png" ".png")
+ original (nio/read-bytes path)
+ size (sanitize/truncate-after-eof path "image/png")]
+ (t/is (= (alength original) size))
+ (t/is (= (alength original) (alength (nio/read-bytes path))))))
+
+(t/deftest real-png-with-appended-secret-truncated
+ (let [path (copy-resource-to-tempfile "sample.png" ".png")
+ original (nio/read-bytes path)
+ orig-size (alength original)
+ secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
+ _ (nio/append-bytes path secret)
+ _ (t/is (= (+ orig-size (alength secret))
+ (alength (nio/read-bytes path))))
+ new-size (sanitize/truncate-after-eof path "image/png")]
+ (t/is (= orig-size new-size))
+ (t/is (= orig-size (alength (nio/read-bytes path))))
+ (t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
+
+(t/deftest real-jpg-clean-not-truncated
+ (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
+ original (nio/read-bytes path)
+ size (sanitize/truncate-after-eof path "image/jpeg")]
+ (t/is (= (alength original) size))
+ (t/is (= (alength original) (alength (nio/read-bytes path))))))
+
+(t/deftest real-jpg-with-appended-secret-truncated
+ (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
+ original (nio/read-bytes path)
+ orig-size (alength original)
+ secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
+ _ (nio/append-bytes path secret)
+ _ (t/is (= (+ orig-size (alength secret))
+ (alength (nio/read-bytes path))))
+ new-size (sanitize/truncate-after-eof path "image/jpeg")]
+ (t/is (= orig-size new-size))
+ (t/is (= orig-size (alength (nio/read-bytes path))))
+ (t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
+
+(t/deftest real-webp-clean-not-truncated
+ (let [path (copy-resource-to-tempfile "sample.webp" ".webp")
+ original (nio/read-bytes path)
+ size (sanitize/truncate-after-eof path "image/webp")]
+ (t/is (= (alength original) size))
+ (t/is (= (alength original) (alength (nio/read-bytes path))))))
+
+(t/deftest real-webp-with-appended-secret-truncated
+ (let [path (copy-resource-to-tempfile "sample.webp" ".webp")
+ original (nio/read-bytes path)
+ orig-size (alength original)
+ secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
+ _ (nio/append-bytes path secret)
+ _ (t/is (= (+ orig-size (alength secret))
+ (alength (nio/read-bytes path))))
+ new-size (sanitize/truncate-after-eof path "image/webp")]
+ (t/is (= orig-size new-size))
+ (t/is (= orig-size (alength (nio/read-bytes path))))
+ (t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
+
+;; ----------------------------------------------------------------
+;; Edge cases and boundary conditions
+;; ----------------------------------------------------------------
+
+(t/deftest empty-file-returns-zero
+ (let [path (write-data-to-tempfile (byte-array 0) ".png")]
+ (t/is (zero? (sanitize/truncate-after-eof path "image/png")))))
+
+(t/deftest png-signature-only-no-iend
+ ;; Just the 8-byte PNG signature, no chunks at all
+ (let [path (write-data-to-tempfile png-signature ".png")]
+ (try
+ (sanitize/truncate-after-eof path "image/png")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :invalid-image (:code (ex-data e))))))))
+
+(t/deftest jpeg-soi-only-no-eoi
+ ;; Just the 2-byte SOI marker, no EOI
+ (let [path (write-data-to-tempfile jpeg-soi ".jpg")]
+ (try
+ (sanitize/truncate-after-eof path "image/jpeg")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :invalid-image (:code (ex-data e))))))))
+
+(t/deftest jpeg-multiple-eoi-uses-last
+ ;; Progressive JPEGs can have multiple EOI markers; we want the last one
+ (let [data (byte-array (concat [0xFF 0xD8] ;; SOI
+ [0x00 0x01 0x02] ;; some data
+ [0xFF 0xD9] ;; first EOI
+ [0x03 0x04 0x05] ;; more data
+ [0xFF 0xD9] ;; second (last) EOI
+ [0xDE 0xAD])) ;; secret
+ path (write-data-to-tempfile data ".jpg")
+ new-size (sanitize/truncate-after-eof path "image/jpeg")]
+ ;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2)
+ (t/is (= 12 new-size))
+ (let [result (nio/read-bytes path)]
+ (t/is (= 12 (alength result)))
+ ;; Verify it ends with the second FFD9
+ (t/is (= (unchecked-byte 0xFF) (aget result 10)))
+ (t/is (= (unchecked-byte 0xD9) (aget result 11))))))
+
+(t/deftest png-iend-with-nonzero-length-rejected
+ ;; IEND chunk with non-zero length field (malformed)
+ (let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0)
+ 0x49 0x45 0x4E 0x44 ;; "IEND"
+ 0xAE 0x42 0x60 0x82]) ;; CRC
+ data (byte-array (concat png-signature png-ihdr-chunk bad-iend))
+ path (write-data-to-tempfile data ".png")]
+ (try
+ (sanitize/truncate-after-eof path "image/png")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :invalid-image (:code (ex-data e))))))))
+
+(t/deftest png-iend-length-read-as-big-endian
+ ;; Verify the IEND length field is interpreted as big-endian (PNG spec).
+ ;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]:
+ ;; big-endian = 256 (non-zero → rejected)
+ ;; little-endian = 65536 (also non-zero, but the code must still use BE)
+ ;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly
+ ;; read as 65536 in BE (not 256 as LE would give).
+ (let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE)
+ 0x49 0x45 0x4E 0x44 ;; "IEND"
+ 0xAE 0x42 0x60 0x82]) ;; CRC
+ data (byte-array (concat png-signature png-ihdr-chunk be-iend))
+ path (write-data-to-tempfile data ".png")]
+ (try
+ (sanitize/truncate-after-eof path "image/png")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :invalid-image (:code (ex-data e))))))))
+
+(t/deftest png-iend-in-chunk-data-not-falsely-matched
+ ;; When "IEND" bytes appear inside chunk data (not as a chunk type),
+ ;; the scanner must not falsely match them as the IEND chunk.
+ ;; Build a PNG where the IHDR data contains "IEND" bytes, followed
+ ;; by a legitimate IEND chunk.
+ (let [ihdr-with-iend-in-data
+ (byte-array [0x00 0x00 0x00 0x0D ;; length=13
+ 0x49 0x48 0x44 0x52 ;; "IHDR"
+ 0x00 0x00 0x00 0x01 ;; width=1
+ 0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload)
+ 0x00 0x00 0x01 ;; remaining IHDR data bytes
+ 0x90 0x77 0x53 0xDE]) ;; CRC
+
+ valid-iend png-iend-chunk
+ data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend))
+ path (write-data-to-tempfile data ".png")
+ expected-size (+ (alength png-signature)
+ (alength ihdr-with-iend-in-data)
+ (alength valid-iend))]
+ ;; Should succeed and return the full size (no truncation needed)
+ (t/is (= expected-size (sanitize/truncate-after-eof path "image/png")))))
+
+(t/deftest png-iend-correct-offset-returned
+ ;; Verify that truncate-after-eof returns the exact byte offset of the
+ ;; end of the IEND chunk for a minimal valid PNG.
+ (let [data (make-png nil)
+ path (write-data-to-tempfile data ".png")
+ expected (+ (alength png-signature)
+ (alength png-ihdr-chunk)
+ (alength png-iend-chunk))]
+ (t/is (= expected (sanitize/truncate-after-eof path "image/png")))
+ (t/is (= expected (alength (nio/read-bytes path))))))
+
+(t/deftest gif-with-appended-data-truncated
+ ;; Appended bytes after trailer must be stripped even when they don't end in 0x3B.
+ (let [valid-size (+ (alength gif-header) (alength gif-trailer))
+ parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])]
+ total (reduce + 0 (map alength parts))
+ data (byte-array total)
+ offset (volatile! 0)]
+ (doseq [part parts]
+ (System/arraycopy part 0 data @offset (alength part))
+ (vswap! offset + (alength part)))
+ (let [path (write-data-to-tempfile data ".gif")
+ new-size (sanitize/truncate-after-eof path "image/gif")]
+ (t/is (= valid-size new-size))
+ (t/is (= valid-size (alength (nio/read-bytes path)))))))
+
+(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated
+ ;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer.
+ ;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload;
+ ;; since that byte is AFTER the real trailer the truncation still drops the garbage.
+ ;; Actually the scan finds the last 0x3B overall — if the appended section ends
+ ;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B.
+ ;; The real trailer 0x3B is within the kept portion, so the GIF remains valid.
+ (let [valid-size (+ (alength gif-header) (alength gif-trailer))
+ ;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B
+ parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])]
+ total (reduce + 0 (map alength parts))
+ data (byte-array total)
+ offset (volatile! 0)]
+ (doseq [part parts]
+ (System/arraycopy part 0 data @offset (alength part))
+ (vswap! offset + (alength part)))
+ (let [path (write-data-to-tempfile data ".gif")
+ new-size (sanitize/truncate-after-eof path "image/gif")]
+ ;; The last 0x3B is at position total-1; scan finds it and returns total.
+ ;; No truncation occurs but the 0x01 0x02 garbage bytes still remain.
+ ;; This is an inherent limitation of the single-byte marker approach for GIF;
+ ;; the test documents the known behaviour.
+ (t/is (= total new-size)))))
+
+(t/deftest webp-riff-size-larger-than-file
+ ;; RIFF declares size larger than actual file - should return declared end
+ ;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file)
+ (let [data (make-webp 24)]
+ ;; Manually set RIFF size to 100 (so declared end = 108)
+ (aset data 4 (byte 100))
+ (aset data 5 (byte 0))
+ (aset data 6 (byte 0))
+ (aset data 7 (byte 0))
+ (let [path (write-data-to-tempfile data ".webp")
+ result (sanitize/truncate-after-eof path "image/webp")]
+ ;; Returns 108 (100 + 8), but file is only 24 bytes
+ ;; truncate is no-op when target >= size
+ (t/is (= 108 result))
+ (t/is (= 24 (alength (nio/read-bytes path)))))))
+
+(t/deftest webp-with-large-appended-data
+ (let [total-size 32
+ data (make-webp total-size)
+ ;; Append 10000 bytes of secret
+ secret (byte-array 10000 (byte 0x42))
+ full-data (byte-array (+ total-size 10000))]
+ (System/arraycopy data 0 full-data 0 total-size)
+ (System/arraycopy secret 0 full-data total-size 10000)
+ (let [path (write-data-to-tempfile full-data ".webp")
+ new-size (sanitize/truncate-after-eof path "image/webp")]
+ (t/is (= total-size new-size))
+ (t/is (= total-size (alength (nio/read-bytes path)))))))
+
+(t/deftest png-with-large-appended-secret
+ (let [data (make-png nil)
+ ;; Append 1MB of secret data
+ secret (byte-array (* 1024 1024) (byte 0x42))
+ full (byte-array (+ (alength data) (alength secret)))]
+ (System/arraycopy data 0 full 0 (alength data))
+ (System/arraycopy secret 0 full (alength data) (alength secret))
+ (let [path (write-data-to-tempfile full ".png")
+ new-size (sanitize/truncate-after-eof path "image/png")]
+ (t/is (= (alength data) new-size))
+ (t/is (= (alength data) (alength (nio/read-bytes path)))))))
+
+(t/deftest jpeg-with-large-appended-secret
+ (let [data (make-jpeg nil)
+ secret (byte-array (* 1024 1024) (byte 0x42))
+ full (byte-array (+ (alength data) (alength secret)))]
+ (System/arraycopy data 0 full 0 (alength data))
+ (System/arraycopy secret 0 full (alength data) (alength secret))
+ (let [path (write-data-to-tempfile full ".jpg")
+ new-size (sanitize/truncate-after-eof path "image/jpeg")]
+ (t/is (= (alength data) new-size))
+ (t/is (= (alength data) (alength (nio/read-bytes path)))))))
+
+(t/deftest png-with-appended-png-signature
+ ;; Appended data contains PNG signature bytes - should still find IEND
+ (let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment
+ [0xDE 0xAD 0xBE 0xEF]))
+ data (make-png extra)
+ path (write-data-to-tempfile data ".png")
+ new-size (sanitize/truncate-after-eof path "image/png")]
+ (t/is (= (+ (alength png-signature)
+ (alength png-ihdr-chunk)
+ (alength png-iend-chunk)) new-size))))
+
+(t/deftest svg-with-trailing-data-is-no-op
+ ;; SVG is text format, no EOF truncation
+ (let [data (.getBytes "")
+ path (write-data-to-tempfile data ".svg")]
+ (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
+ (t/is (= (alength data) (alength (nio/read-bytes path))))))
diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj
index 070a105a1b..2c2bea19e9 100644
--- a/backend/test/backend_tests/rpc_media_test.clj
+++ b/backend/test/backend_tests/rpc_media_test.clj
@@ -286,11 +286,11 @@
(t/deftest download-image-connection-error
(t/testing "connection refused raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.ConnectException. "Connection refused")}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://unreachable.invalid/image.png")
+ (media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -298,11 +298,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "connection timeout raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://unreachable.invalid/image.png")
+ (media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -310,11 +310,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "request timeout raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://unreachable.invalid/image.png")
+ (media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -322,11 +322,11 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "I/O error raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.io.IOException. "Stream closed")}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://unreachable.invalid/image.png")
+ (media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -336,14 +336,14 @@
(t/deftest download-image-status-code-error
(t/testing "404 status raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://example.com/not-found.png")
+ (media/download-image cfg "https://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -351,14 +351,14 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "500 status raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 500
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://example.com/server-error.png")
+ (media/download-image cfg "https://example.com/server-error.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
@@ -366,14 +366,14 @@
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "302 status raises validation error"
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 302
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
- (media/download-image cfg "http://example.com/redirect.png")
+ (media/download-image cfg "https://example.com/redirect.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj
index bc1da4c64f..703c9b0643 100644
--- a/backend/test/backend_tests/rpc_webhooks_test.clj
+++ b/backend/test/backend_tests/rpc_webhooks_test.clj
@@ -37,7 +37,7 @@
(t/is (contains? result :mtype))))
(t/deftest webhook-crud
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})
@@ -151,7 +151,7 @@
(t/is (= (:code error-data) :object-not-found))))))))
(t/deftest webhooks-permissions-crud-viewer-only
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
@@ -214,7 +214,7 @@
(th/reset-mock! http-mock))))
(t/deftest webhooks-permissions-crud-viewer-owner
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
@@ -269,7 +269,7 @@
(t/is (= (:code error-data) :object-not-found)))))))
(t/deftest webhooks-quotes
- (with-mocks [http-mock {:target 'app.http.client/req!
+ (with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [prof (th/create-profile* 1 {:is-active true})
diff --git a/backend/test/backend_tests/test_files/sample.png b/backend/test/backend_tests/test_files/sample.png
new file mode 100644
index 0000000000..586da94a96
Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.png differ
diff --git a/backend/test/backend_tests/test_files/sample.webp b/backend/test/backend_tests/test_files/sample.webp
new file mode 100644
index 0000000000..be1eb528b5
Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.webp differ
diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj
new file mode 100644
index 0000000000..04e86291fd
--- /dev/null
+++ b/backend/test/backend_tests/util_ssrf_test.clj
@@ -0,0 +1,176 @@
+;; 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
+
+(ns backend-tests.util-ssrf-test
+ (:require
+ [app.common.exceptions :as ex]
+ [app.config :as cf]
+ [app.http.client :as http]
+ [app.util.ssrf :as ssrf]
+ [clojure.test :as t]))
+
+(t/deftest validate-url-allows-public-https
+ (t/is (true? (ssrf/safe-url? "https://example.com/foo")))
+ (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
+
+(t/deftest validate-url-allows-public-http
+ (t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
+
+(t/deftest validate-url-blocks-disallowed-schemes
+ (t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
+ (t/is (false? (ssrf/safe-url? "gopher://example.com")))
+ (t/is (false? (ssrf/safe-url? "ftp://example.com")))
+ (t/is (false? (ssrf/safe-url? "dict://example.com")))
+ (t/is (false? (ssrf/safe-url? "data:text/html,
hi
")))
+ (t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo")))
+ (t/is (false? (ssrf/safe-url? "javascript:alert(1)"))))
+
+(t/deftest validate-url-blocks-loopback
+ (t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo")))
+ (t/is (false? (ssrf/safe-url? "http://[::1]/foo"))))
+
+(t/deftest validate-url-blocks-any-local
+ (t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo")))
+ (t/is (false? (ssrf/safe-url? "http://[::]/foo"))))
+
+(t/deftest validate-url-blocks-link-local
+ (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/")))
+ (t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo"))))
+
+(t/deftest validate-url-blocks-site-local
+ (t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo"))))
+
+(t/deftest validate-url-blocks-cloud-metadata
+ (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role")))
+ (t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo"))))
+
+(t/deftest validate-url-blocks-carrier-grade-nat
+ (t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo")))
+ ;; Just outside the range should be allowed (but may be blocked by DNS resolution failing)
+ ;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10
+ ;; But we can't easily test the "allowed" side without DNS, so we test the blocked side.
+
+ ;; Test RFC reserved ranges
+ (t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo")))
+ (t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo"))))
+
+(t/deftest validate-url-blocks-ipv6-ula
+ (t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
+ (t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
+
+(t/deftest validate-url-blocks-encoded-loopback
+ ;; Decimal encoding of 127.0.0.1 = 2130706433
+ ;; InetAddress normalizes this to 127.0.0.1
+ (t/is (false? (ssrf/safe-url? "http://2130706433/foo")))
+ ;; Hex encoding 0x7f000001
+ (t/is (false? (ssrf/safe-url? "http://0x7f000001/foo"))))
+
+(t/deftest validate-url-blocks-ipv4-mapped-loopback
+ (t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo"))))
+
+(t/deftest validate-url-blocks-multicast
+ (t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo"))))
+
+(t/deftest validate-url-blocks-missing-scheme
+ (t/is (false? (ssrf/safe-url? "example.com/foo")))
+ (t/is (false? (ssrf/safe-url? ""))))
+
+(t/deftest validate-url-blocks-missing-host
+ (t/is (false? (ssrf/safe-url? "http:///path")))
+ (t/is (false? (ssrf/safe-url? "http://"))))
+
+(t/deftest validate-url-resolves-dns
+ ;; DNS-resolved internal: we use with-redefs to simulate
+ (let [original ssrf/resolve-host]
+ (with-redefs [ssrf/resolve-host
+ (fn [hostname]
+ (if (= hostname "evil.internal")
+ (into-array java.net.InetAddress
+ [(java.net.InetAddress/getByName "127.0.0.1")])
+ (original hostname)))]
+ (t/is (false? (ssrf/safe-url? "http://evil.internal/foo")))
+ ;; A hostname that fails DNS resolution
+ (t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo"))))))
+
+(t/deftest validate-url-dns-all-addresses-must-be-safe
+ ;; If a hostname resolves to both a public and a private IP, it must be blocked
+ (let [original ssrf/resolve-host]
+ (with-redefs [ssrf/resolve-host
+ (fn [hostname]
+ (if (= hostname "split-brain.example")
+ (into-array java.net.InetAddress
+ [(java.net.InetAddress/getByName "1.1.1.1")
+ (java.net.InetAddress/getByName "127.0.0.1")])
+ (original hostname)))]
+ (t/is (false? (ssrf/safe-url? "http://split-brain.example/foo"))))))
+
+(t/deftest validate-url-allowlist-override
+ (let [original-get cf/get]
+ (with-redefs [cf/get (fn [key & args]
+ (if (= key :ssrf-allowed-hosts)
+ #{"localhost"}
+ (apply original-get key args)))]
+ ;; localhost resolves to 127.0.0.1 which would normally be blocked
+ (t/is (true? (ssrf/safe-url? "http://localhost:6060/foo"))))))
+
+(t/deftest validate-url-extra-cidrs
+ (binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}]
+ (t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo")))))
+
+(t/deftest validate-url-throw-on-blocked
+ (try
+ (ssrf/validate-uri "http://127.0.0.1/foo")
+ (t/is false "should have thrown")
+ (catch Exception e
+ (t/is (= :validation (:type (ex-data e))))
+ (t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
+
+;; ---------------------------------------------------------------------------
+;; http/req automatic SSRF validation
+;; ---------------------------------------------------------------------------
+
+(t/deftest http-req-validates-ssrf-by-default
+ ;; `http/req` should invoke ssrf/validate-uri before sending the request.
+ ;; We verify this by checking that a blocked URI raises an SSRF error
+ ;; without ever reaching the network (validate-uri throws first).
+ (try
+ (http/req {} {:method :get :uri "http://127.0.0.1/secret"})
+ (t/is false "should have thrown an SSRF error")
+ (catch Exception e
+ (t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
+
+(t/deftest http-req-skip-ssrf-check-bypasses-validation
+ ;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be
+ ;; called. We verify by patching validate-uri to record whether it was called.
+ (let [called? (atom false)]
+ (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
+ ;; The request will fail at the network level (no real server), but that's
+ ;; fine — we only care that validate-uri was not called beforehand.
+ (try
+ (http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true})
+ (catch Exception _))
+ (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
+
+(t/deftest http-req-with-redirects-validates-ssrf-by-default
+ ;; req-with-redirects must also validate the initial URI automatically.
+ (try
+ (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"})
+ (t/is false "should have thrown an SSRF error")
+ (catch Exception e
+ (t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
+
+(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation
+ (let [called? (atom false)]
+ (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
+ (try
+ (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true})
+ (catch Exception _))
+ (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc
index 7d23edeafe..87b4f72ad5 100644
--- a/common/src/app/common/buffer.cljc
+++ b/common/src/app/common/buffer.cljc
@@ -279,3 +279,10 @@
[o]
#?(:cljs (.-byteLength ^js o)
:clj (.capacity ^ByteBuffer o)))
+
+#?(:clj
+ (defn set-order
+ "Set the byte order on a ByteBuffer. Returns the buffer."
+ [^ByteBuffer buffer ^ByteOrder order]
+ (.order buffer order)
+ buffer))
diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc
index ab7c7e2a76..4d240cddb9 100644
--- a/common/src/app/common/colors.cljc
+++ b/common/src/app/common/colors.cljc
@@ -486,4 +486,3 @@
a (+ (* ah 100) (* av 10))
b (+ (* bh 100) (* bv 10))]
(compare a b)))
-
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index cc1247dd8e..e0ad3cab15 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -379,7 +379,7 @@
:else
(assoc object key value)))
object))
- changes)))
+ (without-nils changes))))
(defn remove-at-index
"Takes a vector and returns a vector with an element in the
diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc
index f626e48ae3..396f914645 100644
--- a/common/src/app/common/types/color.cljc
+++ b/common/src/app/common/types/color.cljc
@@ -192,6 +192,9 @@
(def ^:const background-quaternary "#2e3434")
(def ^:const background-quaternary-light "#eef0f2")
(def ^:const canvas "#E8E9EA")
+(def ^:const default-pixel-grid-color "#0070E4")
+
+(def ^:const default-pixel-grid-opacity 0.2)
(def names
{"aliceblue" "#f0f8ff"
diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc
index ef52ccefd8..b68a9d3775 100644
--- a/common/src/app/common/types/stroke.cljc
+++ b/common/src/app/common/types/stroke.cljc
@@ -6,7 +6,7 @@
(ns app.common.types.stroke
(:require
- [app.common.colors :as clr]))
+ [app.common.types.color :as clr]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMAS
diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc
index 7d6b0f0e3d..5ed2fdeb83 100644
--- a/common/test/common_tests/colors_test.cljc
+++ b/common/test/common_tests/colors_test.cljc
@@ -113,7 +113,7 @@
(t/deftest ac-rgb-to-hsl
;; Black: h=0, s=0.0, l=0.0 (s is 0.0 not 0 on JVM, and ##NaN for white)
- (let [[h s l] (c/rgb->hsl [0 0 0])]
+ (let [[h _s l] (c/rgb->hsl [0 0 0])]
(t/is (= 0 h))
(t/is (mth/close? l 0.0)))
;; White: h=0, s=##NaN (achromatic), l=1.0
diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc
index dbe015cf89..56b43078dc 100644
--- a/common/test/common_tests/data_test.cljc
+++ b/common/test/common_tests/data_test.cljc
@@ -532,7 +532,12 @@
(t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil})))
;; transducer arity (1-arg returns a fn)
(let [f (d/patch-object {:a 99})]
- (t/is (= {:a 99 :b 2} (f {:a 1 :b 2})))))
+ (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))
+ ;; when object is nil, nil values in changes are stripped (not preserved)
+ (t/is (= {} (d/patch-object nil {:a nil})))
+ (t/is (= {:a 1} (d/patch-object nil {:a 1 :b nil})))
+ ;; nested path: patching a key that doesn't exist creates a new map without nils
+ (t/is (= {:b {:y 2}} (d/patch-object {:b nil} {:b {:x nil :y 2}}))))
(t/deftest without-obj-test
(t/is (= [1 3] (d/without-obj [1 2 3] 2)))
diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs
index d9d733dd10..b3d94db385 100644
--- a/frontend/src/app/main/data/team.cljs
+++ b/frontend/src/app/main/data/team.cljs
@@ -615,9 +615,10 @@
(defn create-webhook
[{:keys [uri mtype is-active] :as params}]
- (dm/assert! (contains? valid-mtypes mtype))
- (dm/assert! (boolean? is-active))
- (dm/assert! (u/uri? uri))
+
+ (assert (contains? valid-mtypes mtype))
+ (assert (boolean? is-active))
+ (assert (u/uri? uri))
(ptk/reify ::create-webhook
ptk/WatchEvent
diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs
index bbff39663a..83454294d6 100644
--- a/frontend/src/app/main/data/workspace/shape_layout.cljs
+++ b/frontend/src/app/main/data/workspace/shape_layout.cljs
@@ -555,10 +555,9 @@
;; change parent to fixed
(and row? auto-height? (every? ctl/fill-height? all-children))
(assoc :layout-item-v-sizing :fix))))
-
(defn update-layout-child
- ([ids changes] (update-layout-child ids changes nil))
- ([ids changes options]
+ ([ids attrs] (update-layout-child ids attrs nil))
+ ([ids attrs options]
(ptk/reify ::update-layout-child
ptk/WatchEvent
(watch [_ state _]
@@ -568,20 +567,20 @@
children-ids (->> ids (mapcat #(cfh/get-children-ids objects %)))
parent-ids (->> ids (map #(cfh/get-parent-id objects %)))
undo-id (js/Symbol)
- margin-attrs (-> (get changes :layout-item-margin)
+ margin-attrs (-> (get attrs :layout-item-margin)
keys
set)]
(rx/of (dwu/start-undo-transaction undo-id)
- (dwsh/update-shapes ids (d/patch-object changes)
+ (dwsh/update-shapes ids (d/patch-object attrs)
(cond-> options
(seq margin-attrs)
(assoc :changed-sub-attr margin-attrs)))
- (dwsh/update-shapes children-ids (partial fix-child-sizing objects changes) options)
+ (dwsh/update-shapes children-ids (partial fix-child-sizing objects attrs) options)
(dwsh/update-shapes
parent-ids
(fn [parent objects]
(-> parent
- (fix-parent-sizing objects (set ids) changes)
+ (fix-parent-sizing objects (set ids) attrs)
(cond-> (ctl/grid-layout? parent)
(ctl/assign-cells objects))))
(merge options {:with-objects? true}))
diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs
index 824c58e528..0b627d31db 100644
--- a/frontend/src/app/main/ui/dashboard/team.cljs
+++ b/frontend/src/app/main/ui/dashboard/team.cljs
@@ -1100,7 +1100,31 @@
(defn- extract-status
[error-code]
- (-> error-code (str/split #":") second))
+ (-> error-code (str/split #":") second str/trim))
+
+(defn- translate-error-hint
+ [hint]
+ (cond
+ (= hint "invalid-uri")
+ (tr "errors.webhooks.invalid-uri")
+
+ (= hint "ssl-validation-error")
+ (tr "errors.webhooks.ssl-validation")
+
+ (= hint "timeout")
+ (tr "errors.webhooks.timeout")
+
+ (= hint "connection-error")
+ (tr "errors.webhooks.connection")
+
+ (str/starts-with? hint "unexpected-status")
+ (tr "errors.webhooks.unexpected-status" (extract-status hint))
+
+ (str/starts-with? hint "blocked-request")
+ (tr "errors.webhooks.connection")
+
+ :else
+ (tr "errors.webhooks.unexpected")))
(mf/defc webhook-modal
{::mf/register modal/components
@@ -1109,7 +1133,7 @@
(let [initial (mf/with-memo []
(or (some-> webhook (update :uri str))
- {:is-active false :mtype "application/json"}))
+ {:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}))
form (fm/use-form :schema schema:webhook-form
:initial initial)
on-success
@@ -1121,25 +1145,14 @@
on-error
(mf/use-fn
- (fn [form error]
- (let [{:keys [type code hint]} (ex-data error)]
+ (fn [form cause]
+ (let [{:keys [type code hint] :as error} (ex-data cause)]
(if (and (= type :validation)
(= code :webhook-validation))
- (let [message (cond
- (= hint "unknown")
- (tr "errors.webhooks.unexpected")
- (= hint "invalid-uri")
- (tr "errors.webhooks.invalid-uri")
- (= hint "ssl-validation-error")
- (tr "errors.webhooks.ssl-validation")
- (= hint "timeout")
- (tr "errors.webhooks.timeout")
- (= hint "connection-error")
- (tr "errors.webhooks.connection")
- (str/starts-with? hint "unexpected-status")
- (tr "errors.webhooks.unexpected-status" (extract-status hint)))]
- (swap! form assoc-in [:errors :uri] {:message message}))
- (rx/throw error)))))
+ (let [message (translate-error-hint hint)]
+ (swap! form assoc-in [:extra-errors :uri] {:message message})
+ (rx/empty))
+ (rx/throw cause)))))
on-create-submit
(mf/use-fn
@@ -1169,6 +1182,7 @@
(if (:id data)
(on-update-submit form)
(on-create-submit form)))))]
+
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form :on-submit on-submit}
@@ -1291,8 +1305,10 @@
(dm/str " " (tr "errors.webhooks.ssl-validation"))
(str/starts-with? error-code "unexpected-status")
- (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))]
+ (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code)))
+ :else
+ (dm/str " " (tr "errors.webhooks.unexpected")))))]
[:div {:class (stl/css :table-row :webhook-row)}
[:div {:class (stl/css :table-field :last-delivery)
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs
index c8e43e7f1c..e98e294810 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs
@@ -147,6 +147,7 @@
:icon i/margin-top-bottom
:min 0
:attr :m1
+ :default nil
:input-type :vertical-margin
:property "Vertical margin "
:nillable true
@@ -178,6 +179,7 @@
:min 0
:attr :m2
:align :right
+ :default nil
:input-type :horizontal-margin
:property "Horizontal margin"
:nillable true
@@ -270,6 +272,7 @@
:icon i/margin-top
:class (stl/css :top-margin-wrapper)
:min 0
+ :default nil
:attr :m1
:input-type :vertical-margin
:property "Top margin"
@@ -298,6 +301,7 @@
:icon i/margin-right
:class (stl/css :right-margin-wrapper)
:min 0
+ :default nil
:attr :m2
:align :right
:input-type :horizontal-margin
@@ -329,6 +333,7 @@
:class (stl/css :bottom-margin-wrapper)
:min 0
:attr :m3
+ :default nil
:align :right
:input-type :vertical-margin
:property "Bottom margin"
@@ -358,6 +363,7 @@
:icon i/margin-left
:class (stl/css :left-margin-wrapper)
:min 0
+ :default nil
:attr :m4
:property "Left margin"
:input-type :horizontal-margin
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs
index c5876940b8..737d204640 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs
@@ -32,10 +32,6 @@
(-> (l/key :pixel-grid-opacity)
(l/derived refs/workspace-page)))
-;; Default pixel grid color shown in the picker when the user hasn't
-;; set a custom one. Matches the legacy hardcoded CSS variable.
-(def ^:private default-pixel-grid-color "#0070E4")
-
(mf/defc options*
{::mf/wrap [mf/memo]}
[]
@@ -54,35 +50,42 @@
{:color (d/nilv background clr/canvas)
:opacity 1})
- grid (mf/with-memo [grid-color grid-alpha]
- {:color (d/nilv grid-color default-pixel-grid-color)
- :opacity (d/nilv grid-alpha 0.2)})]
+ grid-color (mf/with-memo [grid-color grid-alpha]
+ {:color (d/nilv grid-color clr/default-pixel-grid-color)
+ :opacity (d/nilv grid-alpha clr/default-pixel-grid-opacity)})]
- [:div {:class (stl/css :element-set)}
- [:div {:class (stl/css :element-title)}
- [:> title-bar* {:collapsable false
- :title (tr "workspace.options.canvas-background")
- :class (stl/css :title-spacing-page)}]]
- [:div {:class (stl/css :element-content)}
+ [:* [:div {:class (stl/css :element-set)}
+ [:div {:class (stl/css :element-title)}
+ [:> title-bar* {:collapsable false
+ :title (tr "workspace.options.canvas-background")
+ :class (stl/css :title-spacing-page)}]]
+ [:div {:class (stl/css :element-content)}
- [:> color-row*
- {:disable-gradient true
- :disable-opacity true
- :disable-image true
- :title (tr "workspace.options.canvas-background")
- :color color
- :on-change on-change
- :origin :canvas
- :on-open on-open
- :on-close on-close}]
+ [:> color-row*
+ {:disable-gradient true
+ :disable-opacity true
+ :disable-image true
+ :title (tr "workspace.options.canvas-background")
+ :color color
+ :on-change on-change
+ :origin :canvas
+ :on-open on-open
+ :on-close on-close}]]]
- [:> color-row*
- {:disable-gradient true
- :disable-image true
- :title "Pixel grid color"
- :color grid
- :on-change on-grid-change
- :origin :pixel-grid
- :on-open on-open
- :on-close on-close}]]]))
+ [:div {:class (stl/css :element-set)}
+ [:div {:class (stl/css :element-title)}
+ [:> title-bar* {:collapsable false
+ :title (tr "workspace.options.pixel grid-color")
+ :class (stl/css :title-spacing-page)}]]
+ [:div {:class (stl/css :element-content)}
+
+ [:> color-row*
+ {:disable-gradient true
+ :disable-image true
+ :title (tr "workspace.options.pixel grid-color")
+ :color grid-color
+ :on-change on-grid-change
+ :origin :pixel-grid
+ :on-open on-open
+ :on-close on-close}]]]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss
index 39f56950d8..c2d6485284 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss
@@ -65,6 +65,7 @@
block-size: $sz-32;
min-inline-size: 0;
inline-size: 100%;
+ max-inline-size: 100%;
padding: 0;
margin-inline-end: 0;
border: $b-1 solid var(--color-name-wrapper-boder-color);
diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs
index 1cd082dcbf..3defb347d3 100644
--- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs
+++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs
@@ -7,7 +7,6 @@
(ns app.main.ui.workspace.tokens.management.forms.controls.color-input
(:require-macros [app.main.style :as stl])
(:require
- [app.common.colors :as color]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
@@ -171,8 +170,8 @@
default-bullet-color
(case (:theme profile)
"light"
- color/background-quaternary-light
- color/background-quaternary)
+ cl/background-quaternary-light
+ cl/background-quaternary)
hex
(if valid-color
(tinycolor/->hex-string (tinycolor/valid-color valid-color))
@@ -337,8 +336,8 @@
default-bullet-color
(case (:theme profile)
"light"
- color/background-quaternary-light
- color/background-quaternary)
+ cl/background-quaternary-light
+ cl/background-quaternary)
hex
(if valid-color
diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs
index 194575231c..1f8bf2566d 100644
--- a/frontend/src/app/main/ui/workspace/viewport.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport.cljs
@@ -200,7 +200,7 @@
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
- on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
+ on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
comp-inst-ref (mf/use-ref false)
on-drag-enter (actions/on-drag-enter comp-inst-ref)
diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
index 0eac02f9a3..fb6837b70c 100644
--- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
@@ -196,10 +196,10 @@
(st/emit! (dw/increase-zoom pt)))))))))
(defn on-double-click
- [hover hover-ids hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?]
+ [hover hover-ids selected hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?]
(mf/use-callback
- (mf/deps @hover @hover-ids @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?)
+ (mf/deps @hover @hover-ids selected @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?)
(fn [event]
(dom/stop-propagation event)
(when-not @z?
@@ -208,7 +208,16 @@
alt? (kbd/alt? event)
meta? (kbd/meta? event)
- {:keys [id type] :as shape} (or @hover (get objects (first @hover-ids)))
+ selected-id-under-cursor
+ (->> @hover-ids
+ (filter selected)
+ last)
+
+ {:keys [id type] :as shape}
+ (or (when selected-id-under-cursor
+ (get objects selected-id-under-cursor))
+ @hover
+ (get objects (first @hover-ids)))
editable? (contains? #{:text :rect :path :image :circle} type)
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index 86efd4d067..c28a90cd86 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -257,7 +257,7 @@
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
- on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
+ on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?)
comp-inst-ref (mf/use-ref false)
on-drag-enter (actions/on-drag-enter comp-inst-ref)
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 5ce9687fd9..88b34b9c0b 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -6377,6 +6377,10 @@ msgstr "Toggle blur"
msgid "workspace.options.canvas-background"
msgstr "Canvas background"
+#: src/app/main/ui/workspace/sidebar/options/page.cljs
+msgid "workspace.options.pixel grid-color"
+msgstr "Pixel grid color"
+
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567
msgid "workspace.options.clip-content"
msgstr "Clip content"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 546801aae6..7d5d03411c 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -6259,6 +6259,10 @@ msgstr "Mostrar/ocultar desenfoque"
msgid "workspace.options.canvas-background"
msgstr "Color de fondo"
+#: src/app/main/ui/workspace/sidebar/options/page.cljs
+msgid "workspace.options.pixel grid-color"
+msgstr "Color de la rejilla de pixeles"
+
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567
msgid "workspace.options.clip-content"
msgstr "Truncar contenido"
diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
index 85ed5a32d1..b464c599ef 100644
--- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
+++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
@@ -236,6 +236,13 @@ export class ExecuteCodeTaskHandler extends TaskHandler {
console.log("Code execution result:", result);
+ // transform a top-level Uint8Array result into a compact base64 envelope to avoid the
+ // ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects
+ // with numeric string keys (see penpot/penpot#9420)
+ if (result instanceof Uint8Array) {
+ result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result);
+ }
+
// return result and captured log
let resultData: ExecuteCodeTaskResultData = {
result: result,
@@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler {
};
task.sendSuccess(resultData);
}
+
+ /**
+ * Base64-encodes the given bytes and wraps the result in a tagged envelope that the
+ * server side recognizes (see `ImageContent.byteData`).
+ *
+ * @param bytes - the raw binary data to encode
+ * @returns an envelope of the form `{ __type: "base64", data: }`
+ */
+ private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } {
+ // build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly
+ // would overflow the call stack for large arrays
+ const chunkSize = 0x8000;
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ const chunk = bytes.subarray(i, i + chunkSize);
+ binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
+ }
+ return { __type: "base64", data: btoa(binary) };
+ }
}
diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts
index ae95724a09..47a3ee3355 100644
--- a/mcp/packages/server/src/PenpotMcpServer.ts
+++ b/mcp/packages/server/src/PenpotMcpServer.ts
@@ -92,7 +92,7 @@ export class PenpotMcpServer {
this.tools = this.initTools();
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
- this.replServer = new ReplServer(this.pluginBridge, this.replPort);
+ this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
}
/**
diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts
index 5147d361fd..35d39aa728 100644
--- a/mcp/packages/server/src/PluginBridge.ts
+++ b/mcp/packages/server/src/PluginBridge.ts
@@ -10,6 +10,7 @@ const KEEP_ALIVE_TIME = 30000; // 30 seconds
interface ClientConnection {
socket: WebSocket;
userToken: string | null;
+ pingInterval: NodeJS.Timeout;
}
/**
@@ -40,8 +41,6 @@ export class PluginBridge {
* channel between the MCP mcpServer and Penpot plugin instances.
*/
private setupWebSocketHandlers(): void {
- let interval: NodeJS.Timeout | undefined;
-
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
// extract userToken from query parameters
const url = new URL(request.url!, `ws://${request.headers.host}`);
@@ -60,13 +59,19 @@ export class PluginBridge {
this.logger.info("New WebSocket connection established");
}
+ // start the per-connection keep-alive ping interval
+ const pingInterval = setInterval(() => {
+ ws.ping();
+ }, KEEP_ALIVE_TIME);
+
// register the client connection with both indexes
- const connection: ClientConnection = { socket: ws, userToken };
+ const connection: ClientConnection = { socket: ws, userToken, pingInterval };
this.connectedClients.set(ws, connection);
if (userToken) {
// ensure only one connection per userToken
if (this.clientsByToken.has(userToken)) {
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
+ this.removeConnection(ws);
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
return;
}
@@ -86,36 +91,39 @@ export class PluginBridge {
ws.on("close", () => {
this.logger.info("WebSocket connection closed");
- const connection = this.connectedClients.get(ws);
- this.connectedClients.delete(ws);
- if (connection?.userToken) {
- this.clientsByToken.delete(connection.userToken);
- }
- if (interval) {
- clearInterval(interval);
- }
+ this.removeConnection(ws);
});
ws.on("error", (error) => {
this.logger.error(error, "WebSocket connection error");
- const connection = this.connectedClients.get(ws);
- this.connectedClients.delete(ws);
- if (connection?.userToken) {
- this.clientsByToken.delete(connection.userToken);
- }
- if (interval) {
- clearInterval(interval);
- }
+ this.removeConnection(ws);
});
-
- interval = setInterval(() => {
- ws?.ping();
- }, KEEP_ALIVE_TIME);
});
this.logger.info("WebSocket mcpServer started on port %d", this.port);
}
+ /**
+ * Removes a client connection and releases all resources associated with it.
+ *
+ * Clears the per-connection keep-alive interval and removes the connection
+ * from both the socket-keyed and token-keyed indexes. Safe to call with a
+ * socket that is not (or no longer) registered.
+ *
+ * @param ws - The WebSocket whose connection state should be removed
+ */
+ private removeConnection(ws: WebSocket): void {
+ const connection = this.connectedClients.get(ws);
+ if (!connection) {
+ return;
+ }
+ clearInterval(connection.pingInterval);
+ this.connectedClients.delete(ws);
+ if (connection.userToken) {
+ this.clientsByToken.delete(connection.userToken);
+ }
+ }
+
/**
* Handles responses from the plugin for completed tasks.
*
diff --git a/mcp/packages/server/src/ReplServer.ts b/mcp/packages/server/src/ReplServer.ts
index 1496037521..1894ade79a 100644
--- a/mcp/packages/server/src/ReplServer.ts
+++ b/mcp/packages/server/src/ReplServer.ts
@@ -17,13 +17,16 @@ export class ReplServer {
private readonly logger = createLogger("ReplServer");
private readonly app: express.Application;
private readonly port: number;
+ private readonly host: string;
private server: any;
constructor(
private readonly pluginBridge: PluginBridge,
- port: number = 4403
+ port: number = 4403,
+ host: string = "localhost"
) {
this.port = port;
+ this.host = host;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
@@ -86,9 +89,9 @@ export class ReplServer {
*/
public async start(): Promise {
return new Promise((resolve) => {
- this.server = this.app.listen(this.port, () => {
+ this.server = this.app.listen(this.port, this.host, () => {
this.logger.info(`REPL server started on port ${this.port}`);
- this.logger.info(`REPL interface URL: http://${this.pluginBridge.mcpServer.host}:${this.port}`);
+ this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`);
resolve();
});
});
diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts
index 6055f24ccf..db5babe4db 100644
--- a/mcp/packages/server/src/ToolResponse.ts
+++ b/mcp/packages/server/src/ToolResponse.ts
@@ -37,19 +37,26 @@ export class ImageContent implements ImageItem {
/**
* Utility function for ensuring a consistent Uint8Array representation of byte data.
- * Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array
- * from the plugin).
+ * Input can be one of:
+ * - a `Uint8Array` (already in the desired form);
+ * - a base64 envelope `{ __type: "base64", data: }` produced by the plugin
+ * to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420);
+ * - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback).
*
- * @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array)
- * @return data as Uint8Array
+ * @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object
+ * @return data as `Uint8Array`
*/
public static byteData(data: Uint8Array | object): Uint8Array {
- if (typeof data === "object") {
- // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
- return new Uint8Array(Object.values(data) as number[]);
- } else {
+ if (data instanceof Uint8Array) {
return data;
}
+ // recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler
+ const envelope = data as { __type?: unknown; data?: unknown };
+ if (envelope.__type === "base64" && typeof envelope.data === "string") {
+ return new Uint8Array(Buffer.from(envelope.data, "base64"));
+ }
+ // legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array
+ return new Uint8Array(Object.values(data) as number[]);
}
}
diff --git a/package.json b/package.json
index 8062f6315e..176a30c5da 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,9 @@
"fmt": "./scripts/fmt"
},
"devDependencies": {
- "@github/copilot": "^1.0.36",
+ "@github/copilot": "^1.0.43",
"@types/node": "^25.6.0",
"esbuild": "^0.28.0",
- "opencode-ai": "^1.14.28"
+ "opencode-ai": "^1.14.40"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 647cecdd12..7699645c22 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@github/copilot':
- specifier: ^1.0.36
- version: 1.0.36
+ specifier: ^1.0.43
+ version: 1.0.43
'@types/node':
specifier: ^25.6.0
version: 25.6.0
@@ -18,8 +18,8 @@ importers:
specifier: ^0.28.0
version: 0.28.0
opencode-ai:
- specifier: ^1.14.28
- version: 1.14.28
+ specifier: ^1.14.40
+ version: 1.14.40
packages:
@@ -179,44 +179,44 @@ packages:
cpu: [x64]
os: [win32]
- '@github/copilot-darwin-arm64@1.0.36':
- resolution: {integrity: sha512-5qkb7frTS4K/LdTDLrzKo78VR4aw/EZ6JzLz4KfmaW4UYyPiNirExDFXa/By22X0o8YMfOp4MCA2KSCAxKdgTg==}
+ '@github/copilot-darwin-arm64@1.0.43':
+ resolution: {integrity: sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==}
cpu: [arm64]
os: [darwin]
hasBin: true
- '@github/copilot-darwin-x64@1.0.36':
- resolution: {integrity: sha512-AdsM8QtM5QSzMLpavLREh8HALO5G+VWzGNQqIHu4f0YQC/s1cGoiwo3wsgkpxRcLGBykFc+bDX3yK3MDQ8XvSw==}
+ '@github/copilot-darwin-x64@1.0.43':
+ resolution: {integrity: sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==}
cpu: [x64]
os: [darwin]
hasBin: true
- '@github/copilot-linux-arm64@1.0.36':
- resolution: {integrity: sha512-n7K1I6r0ggOJ4A9uAMS11USTvn6BKtAwvrOkzEaeRK89VNUJzpTe6p0mE13ItzRe5eot9WLBQOxvXLtL9f6E+g==}
+ '@github/copilot-linux-arm64@1.0.43':
+ resolution: {integrity: sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==}
cpu: [arm64]
os: [linux]
hasBin: true
- '@github/copilot-linux-x64@1.0.36':
- resolution: {integrity: sha512-wBtCdR3ITZcq07BJbkwHfwI6ayiwbH5pF1ex+Ycl4UI+Lf1vP9eQD6wJppPgsrjwFcdeWRThaYTPCRTkSGHv5g==}
+ '@github/copilot-linux-x64@1.0.43':
+ resolution: {integrity: sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==}
cpu: [x64]
os: [linux]
hasBin: true
- '@github/copilot-win32-arm64@1.0.36':
- resolution: {integrity: sha512-0GzZUZQn07alI8BgbzK0NlR5+ta/Rd0sWmd8kbRCns7oybAIkSALy6BKVwJmVHtXUi6h4iUE8oiFhkn0spymvw==}
+ '@github/copilot-win32-arm64@1.0.43':
+ resolution: {integrity: sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==}
cpu: [arm64]
os: [win32]
hasBin: true
- '@github/copilot-win32-x64@1.0.36':
- resolution: {integrity: sha512-UBX9qj0McCK/SLq93XIr1i80fj3b3XmE3befVFrzxQuTeOoxLURN35vi7W+4x+4ZfsDHQpRTlJNjZw9w0fPr+Q==}
+ '@github/copilot-win32-x64@1.0.43':
+ resolution: {integrity: sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==}
cpu: [x64]
os: [win32]
hasBin: true
- '@github/copilot@1.0.36':
- resolution: {integrity: sha512-x0N5wLzw+tANzb+vCFYLHn3BV3qii2oyn14wC20RO7SsS8/YeBH8olvwlDLJ4PB0mL17QOiytNCdkvjvprm28w==}
+ '@github/copilot@1.0.43':
+ resolution: {integrity: sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==}
hasBin: true
'@types/node@25.6.0':
@@ -227,67 +227,67 @@ packages:
engines: {node: '>=18'}
hasBin: true
- opencode-ai@1.14.28:
- resolution: {integrity: sha512-ZPukJNvujSVa+LVoXvj2ciUV57UcnuxmMtzpFQBYd6fbhjeT1vMC6jCurO/5mIp76fiPmGM7ilzRXVeY6bIwPw==}
+ opencode-ai@1.14.40:
+ resolution: {integrity: sha512-2Iwqe3wLdAGWxWGnTAPeOv7QNcm4stuWL2VQ7FM6OxKRE0h9zGoKtJ/bnX193hR6/QH81goOrocKfamRk4pM/A==}
hasBin: true
- opencode-darwin-arm64@1.14.28:
- resolution: {integrity: sha512-Gu2vZYACAeoewfPhgJDAaScwRo1K5YZq7tVpPKw2rpul34OpOPLk4oB4Pmr539iWiagK+DLuUxnbJIbRRYCS5g==}
+ opencode-darwin-arm64@1.14.40:
+ resolution: {integrity: sha512-dVItdZeaJw9xRtpVKQ0K9sigcaf8p/3nr3hW/NxJY3I/L+op29Wly5jnH0sdxq05gV/cGmJ0BTJzoYizXDDoKg==}
cpu: [arm64]
os: [darwin]
- opencode-darwin-x64-baseline@1.14.28:
- resolution: {integrity: sha512-/KsZkZh5oh6urHWwIHJudS6sedBil59E/4o7/7TuxPy/pOdRlSlSWVkMJd20AmqM4G/qILF/GthXy3D2+f99Tg==}
+ opencode-darwin-x64-baseline@1.14.40:
+ resolution: {integrity: sha512-ab2vqJmTPG48U2/6xZvPcZ+HdQBHFt6rpS0w2gURJobXHWgbW+uytfX4MCBprunS3K1DL7qoVWIgJRRu3YEMzQ==}
cpu: [x64]
os: [darwin]
- opencode-darwin-x64@1.14.28:
- resolution: {integrity: sha512-D6BnAXlSdQDRtZgAg6OxWT6CEzbbONnlYof9hdPbaIIaNyBLjqK+Er2O1rrbiXFhSbs7YHBiDoGd+nNUymx4Ng==}
+ opencode-darwin-x64@1.14.40:
+ resolution: {integrity: sha512-Mcfh2LP/QA3LJ03m6OF1FNCMO/O0PsT94Mw7NXbcmi6g+nQ6I2vHensIjKtTJM3LSmosOlTduxdv/b82AVFt+w==}
cpu: [x64]
os: [darwin]
- opencode-linux-arm64-musl@1.14.28:
- resolution: {integrity: sha512-7R1GHqSg/UuT9r77GF2skh8r66WkZcphmDWAWaV2dmptJlxEJeV9I2jbE2i8Ctp4BzPUexFqfSoBA82S9Dcf+A==}
+ opencode-linux-arm64-musl@1.14.40:
+ resolution: {integrity: sha512-5COW6bDCCbM/BZcrkiVR91VqrP9vD8J4uS7cHLPbdQHaDYETxOhHWgsYnp9JXWEbcDi6lBaxapj11RZPyUJziw==}
cpu: [arm64]
os: [linux]
- opencode-linux-arm64@1.14.28:
- resolution: {integrity: sha512-jdTrs4YpPGFGZOMLuiaSfOUzkjAA+lnIEaW6HYLvaey3WsBnu3S4utaBhXURincp20H1JPQcahDOe+jjGZH7xw==}
+ opencode-linux-arm64@1.14.40:
+ resolution: {integrity: sha512-nqsnGxV237XWCbaMtFXOiN1ndukNAhAmbe5dnDThSFOKcgwMs6VKuhHa+iOpQL1r6FZ5eE3SU6IKgscVM/eAAQ==}
cpu: [arm64]
os: [linux]
- opencode-linux-x64-baseline-musl@1.14.28:
- resolution: {integrity: sha512-GKxZXj8/Mbutfs1DW4v0/rEWcAQrD/RUI9kV9VhMoNA8vUt0nuA3H9UvbFXh9EJj2C+RBSPLlMGal++oCH4c4w==}
+ opencode-linux-x64-baseline-musl@1.14.40:
+ resolution: {integrity: sha512-l0xfAHVy2klD181UbKUjeMVltqEn+9DTP/bZ9ZyDuiFbVNeKfwiU7HMkPw5GFnSnlQTUrDHniXMwgBcI5yrRcA==}
cpu: [x64]
os: [linux]
- opencode-linux-x64-baseline@1.14.28:
- resolution: {integrity: sha512-Dtl+xjEAKaWNk2l3iC9ebwi79BkChHIdtx97ksZKTLjAeR424Zh3vnjuWjpMYk9YAnesVlwL8y4kHs2Y736Zpw==}
+ opencode-linux-x64-baseline@1.14.40:
+ resolution: {integrity: sha512-XmXJHicRNylnGz/nzJCmsXy6mozjmrrXHntrrS3DWgq8UiSdg9wB2Hsq+1EDbPjzF2NzQaVi6/9/AyERuxW2iA==}
cpu: [x64]
os: [linux]
- opencode-linux-x64-musl@1.14.28:
- resolution: {integrity: sha512-XyzWl35L8N6El/hxAM28bDUHLCY0aujMtprDTCYXVckeNxBkN3idM4EfdLtJaUHkE6bqMr+m6wXQl4oYDoOtpg==}
+ opencode-linux-x64-musl@1.14.40:
+ resolution: {integrity: sha512-AU8SAE4aOaGxzTDcv95GU8vrWeiYOzPVEt5LfcqhmOmhRiuN0jnDBO/gl62SITkGyu/sSes554L4UqkqkglC7g==}
cpu: [x64]
os: [linux]
- opencode-linux-x64@1.14.28:
- resolution: {integrity: sha512-XnpQrud15bvUBvOI58tOGUBTrwqKHl6bYQ3eoy5HhGa2spUnRv3B/HU8QiS6QuNbmkPxRPR+vuTGtBYQvtRGPw==}
+ opencode-linux-x64@1.14.40:
+ resolution: {integrity: sha512-Cb+keGDsjo1wyOJ2Kf+KOZWlJUs1fD/VmjYepl9Fv3KGqWQNhSOH/4kiwj3KHWwEMWroCtw5KnAFykiOgbsz8A==}
cpu: [x64]
os: [linux]
- opencode-windows-arm64@1.14.28:
- resolution: {integrity: sha512-emR1oEoLe6soASahJNX6IwR9x8rJkbwBXDnXNTWQcGdSxKBMD4/cLkq84k/5zqLfB7dbUChTw7eFz7u8Sa5VQw==}
+ opencode-windows-arm64@1.14.40:
+ resolution: {integrity: sha512-049+Z8G4o10OO7s5dGIVIZ31DMR+0JlAWCM/XR+VmBlbPeZYRKL/9Q2dM0Ljw11ekZ1qbZfGlK7xUEHkwvy8fQ==}
cpu: [arm64]
os: [win32]
- opencode-windows-x64-baseline@1.14.28:
- resolution: {integrity: sha512-ARKHTThHezib44QPLiivYI8c71iNE9iNDubwV5XxUhM2FtzMJkZGma+EgbcCsXwY5r0lAsarzzDMqYB0YfCZ1A==}
+ opencode-windows-x64-baseline@1.14.40:
+ resolution: {integrity: sha512-6JKSyc8oyCw4ZLMZ38l9/M7iQYfMF7qnc3d+P9dpfx80FiqHwWBxgYaGEaNHkQmoiTPVc39EsjaOiiBOLGZOQA==}
cpu: [x64]
os: [win32]
- opencode-windows-x64@1.14.28:
- resolution: {integrity: sha512-tEpblIEdmlJ7npo5Bq+1O7uup9jCOyqnnA63t+3JQiNQ1et3UTjNb5ruAjb7sudUer6i5MlQCwNXBjitjuU4Kg==}
+ opencode-windows-x64@1.14.40:
+ resolution: {integrity: sha512-0+La4i8fj+E+kj6hu8sQKfjbHm6VgbbSiZ6vo3fYqU4+Ex2UIyNa31A3rWOQ9EFbmKQ8jb2PmfBDvDE0ihJvRw==}
cpu: [x64]
os: [win32]
@@ -374,32 +374,32 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
- '@github/copilot-darwin-arm64@1.0.36':
+ '@github/copilot-darwin-arm64@1.0.43':
optional: true
- '@github/copilot-darwin-x64@1.0.36':
+ '@github/copilot-darwin-x64@1.0.43':
optional: true
- '@github/copilot-linux-arm64@1.0.36':
+ '@github/copilot-linux-arm64@1.0.43':
optional: true
- '@github/copilot-linux-x64@1.0.36':
+ '@github/copilot-linux-x64@1.0.43':
optional: true
- '@github/copilot-win32-arm64@1.0.36':
+ '@github/copilot-win32-arm64@1.0.43':
optional: true
- '@github/copilot-win32-x64@1.0.36':
+ '@github/copilot-win32-x64@1.0.43':
optional: true
- '@github/copilot@1.0.36':
+ '@github/copilot@1.0.43':
optionalDependencies:
- '@github/copilot-darwin-arm64': 1.0.36
- '@github/copilot-darwin-x64': 1.0.36
- '@github/copilot-linux-arm64': 1.0.36
- '@github/copilot-linux-x64': 1.0.36
- '@github/copilot-win32-arm64': 1.0.36
- '@github/copilot-win32-x64': 1.0.36
+ '@github/copilot-darwin-arm64': 1.0.43
+ '@github/copilot-darwin-x64': 1.0.43
+ '@github/copilot-linux-arm64': 1.0.43
+ '@github/copilot-linux-x64': 1.0.43
+ '@github/copilot-win32-arm64': 1.0.43
+ '@github/copilot-win32-x64': 1.0.43
'@types/node@25.6.0':
dependencies:
@@ -434,55 +434,55 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
- opencode-ai@1.14.28:
+ opencode-ai@1.14.40:
optionalDependencies:
- opencode-darwin-arm64: 1.14.28
- opencode-darwin-x64: 1.14.28
- opencode-darwin-x64-baseline: 1.14.28
- opencode-linux-arm64: 1.14.28
- opencode-linux-arm64-musl: 1.14.28
- opencode-linux-x64: 1.14.28
- opencode-linux-x64-baseline: 1.14.28
- opencode-linux-x64-baseline-musl: 1.14.28
- opencode-linux-x64-musl: 1.14.28
- opencode-windows-arm64: 1.14.28
- opencode-windows-x64: 1.14.28
- opencode-windows-x64-baseline: 1.14.28
+ opencode-darwin-arm64: 1.14.40
+ opencode-darwin-x64: 1.14.40
+ opencode-darwin-x64-baseline: 1.14.40
+ opencode-linux-arm64: 1.14.40
+ opencode-linux-arm64-musl: 1.14.40
+ opencode-linux-x64: 1.14.40
+ opencode-linux-x64-baseline: 1.14.40
+ opencode-linux-x64-baseline-musl: 1.14.40
+ opencode-linux-x64-musl: 1.14.40
+ opencode-windows-arm64: 1.14.40
+ opencode-windows-x64: 1.14.40
+ opencode-windows-x64-baseline: 1.14.40
- opencode-darwin-arm64@1.14.28:
+ opencode-darwin-arm64@1.14.40:
optional: true
- opencode-darwin-x64-baseline@1.14.28:
+ opencode-darwin-x64-baseline@1.14.40:
optional: true
- opencode-darwin-x64@1.14.28:
+ opencode-darwin-x64@1.14.40:
optional: true
- opencode-linux-arm64-musl@1.14.28:
+ opencode-linux-arm64-musl@1.14.40:
optional: true
- opencode-linux-arm64@1.14.28:
+ opencode-linux-arm64@1.14.40:
optional: true
- opencode-linux-x64-baseline-musl@1.14.28:
+ opencode-linux-x64-baseline-musl@1.14.40:
optional: true
- opencode-linux-x64-baseline@1.14.28:
+ opencode-linux-x64-baseline@1.14.40:
optional: true
- opencode-linux-x64-musl@1.14.28:
+ opencode-linux-x64-musl@1.14.40:
optional: true
- opencode-linux-x64@1.14.28:
+ opencode-linux-x64@1.14.40:
optional: true
- opencode-windows-arm64@1.14.28:
+ opencode-windows-arm64@1.14.40:
optional: true
- opencode-windows-x64-baseline@1.14.28:
+ opencode-windows-x64-baseline@1.14.40:
optional: true
- opencode-windows-x64@1.14.28:
+ opencode-windows-x64@1.14.40:
optional: true
undici-types@7.19.2: {}
diff --git a/render-wasm/_build_env b/render-wasm/_build_env
index 7f9c2e2486..b54af40d34 100644
--- a/render-wasm/_build_env
+++ b/render-wasm/_build_env
@@ -42,6 +42,8 @@ export EMCC_CFLAGS="--no-entry \
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
-sENVIRONMENT=web \
-sMODULARIZE=1 \
+ -sDISABLE_EXCEPTION_CATCHING=1 \
+ -sFILESYSTEM=0 \
-sEXPORT_ES6=1";
export EM_CACHE="/tmp/emsdk_cache";
@@ -80,6 +82,9 @@ function copy_artifacts {
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
+ if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then
+ cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
+ fi
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;
diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs
index ee53f8a98c..bd6e26d1fa 100644
--- a/render-wasm/src/main.rs
+++ b/render-wasm/src/main.rs
@@ -18,9 +18,11 @@ use std::collections::HashMap;
#[allow(unused_imports)]
use crate::error::{Error, Result};
+use crate::state::TextEditorState;
use macros::wasm_error;
use math::{Bounds, Matrix};
use mem::SerializableResult;
+use render::{gpu_state::GpuState, RenderState};
use shapes::{StructureEntry, StructureEntryType, TransformEntry};
use skia_safe as skia;
use state::State;
@@ -28,6 +30,37 @@ use utils::uuid_from_u32_quartet;
use uuid::Uuid;
pub(crate) static mut STATE: Option> = None;
+pub(crate) static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
+
+#[inline(always)]
+pub fn get_text_editor_state() -> &'static mut TextEditorState {
+ unsafe {
+ debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null");
+ &mut *TEXT_EDITOR_STATE
+ }
+}
+
+/// GPU State.
+static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
+
+#[inline(always)]
+pub(crate) fn get_gpu_state() -> &'static mut GpuState {
+ unsafe {
+ debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
+ &mut *GPU_STATE
+ }
+}
+
+/// Render State.
+static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
+
+#[inline(always)]
+pub(crate) fn get_render_state() -> &'static mut RenderState {
+ unsafe {
+ debug_assert!(!RENDER_STATE.is_null(), "Render State is null");
+ &mut *RENDER_STATE
+ }
+}
// FIXME: These with_state* macros should be using our CriticalError instead of expect.
// But to do that, we need to not use them at domain-level (i.e. in business logic), just
@@ -101,12 +134,32 @@ macro_rules! with_state_mut_current_shape {
};
}
+/// Initializes GPU.
+fn gpu_init() {
+ unsafe {
+ let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State");
+ GPU_STATE = Box::into_raw(Box::new(gpu_state));
+ }
+}
+
+/// Initializes RenderState.
+fn render_init(width: i32, height: i32) {
+ unsafe {
+ let render_state =
+ RenderState::try_new(width, height).expect("Cannot intialize RenderState");
+ RENDER_STATE = Box::into_raw(Box::new(render_state));
+ }
+}
+
#[no_mangle]
#[wasm_error]
pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
- let state_box = Box::new(State::try_new(width, height)?);
+ gpu_init();
+ render_init(width, height);
unsafe {
+ let state_box = Box::new(State::new());
STATE = Some(state_box);
+ TEXT_EDITOR_STATE = Box::into_raw(Box::new(TextEditorState::new()));
}
Ok(())
}
@@ -123,12 +176,10 @@ pub extern "C" fn set_browser(browser: u8) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
- with_state_mut!(state, {
- // Cancel the current animation frame if it exists so
- // it won't try to render without context
- let render_state = state.render_state_mut();
- render_state.cancel_animation_frame();
- });
+ // Cancel the current animation frame if it exists so
+ // it won't try to render without context
+ let render_state = get_render_state();
+ render_state.cancel_animation_frame();
unsafe { STATE = None }
mem::free_bytes()?;
Ok(())
@@ -137,11 +188,9 @@ pub extern "C" fn clean_up() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
- with_state_mut!(state, {
- let render_state = state.render_state_mut();
- render_state.set_debug_flags(debug);
- render_state.set_dpr(dpr)?;
- });
+ let render_state = get_render_state();
+ render_state.set_debug_flags(debug);
+ render_state.set_dpr(dpr)?;
Ok(())
}
@@ -150,61 +199,48 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
pub extern "C" fn set_viewport_interest_area_threshold(
viewport_interest_area_threshold: i32,
) -> Result<()> {
- with_state_mut!(state, {
- let render_state = state.render_state_mut();
- render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
- });
+ let render_state = get_render_state();
+ render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> {
- with_state_mut!(state, {
- let render_state = state.render_state_mut();
- render_state.set_max_blocking_time_ms(max_blocking_time_ms);
- });
+ let render_state = get_render_state();
+ render_state.set_max_blocking_time_ms(max_blocking_time_ms);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> {
- with_state_mut!(state, {
- let render_state = state.render_state_mut();
- render_state.set_node_batch_threshold(node_batch_threshold);
- });
+ let render_state = get_render_state();
+ render_state.set_node_batch_threshold(node_batch_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> {
- with_state_mut!(state, {
- let render_state = state.render_state_mut();
- render_state.set_blur_downscale_threshold(blur_downscale_threshold);
- });
+ let render_state = get_render_state();
+ render_state.set_blur_downscale_threshold(blur_downscale_threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
- with_state_mut!(state, {
- state.render_state_mut().set_antialias_threshold(threshold);
- });
+ get_render_state().set_antialias_threshold(threshold);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
- with_state_mut!(state, {
- state
- .render_state_mut()
- .surfaces
- .set_max_atlas_texture_size(max_px);
- });
+ get_render_state()
+ .surfaces
+ .set_max_atlas_texture_size(max_px);
Ok(())
}
@@ -222,7 +258,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
-pub extern "C" fn render(_: i32) -> Result<()> {
+pub extern "C" fn render(timestamp: i32) -> Result<()> {
with_state_mut!(state, {
state.rebuild_touched_tiles();
// Drain the throttled modifier-tile invalidation accumulated
@@ -230,14 +266,14 @@ pub extern "C" fn render(_: i32) -> Result<()> {
// interactive_transform; we do it once here, with the current
// modifier set, so the cost is paid once per rAF rather than
// once per pointer move.
- if state.render_state.options.is_interactive_transform() {
+ if get_render_state().options.is_interactive_transform() {
let ids = state.shapes.modifier_ids();
if !ids.is_empty() {
state.rebuild_modifier_tiles(ids)?;
}
}
state
- .start_render_loop(performance::get_time())
+ .start_render_loop(timestamp)
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
});
Ok(())
@@ -249,7 +285,7 @@ pub extern "C" fn render_sync() -> Result<()> {
with_state_mut!(state, {
state.rebuild_tiles();
state
- .render_sync(performance::get_time())
+ .render_sync(0)
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
});
Ok(())
@@ -275,7 +311,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
state.rebuild_tiles_from(Some(&id));
state
- .render_sync_shape(&id, performance::get_time())
+ .render_sync_shape(&id, 0)
.map_err(|e| Error::RecoverableError(e.to_string()))?;
});
Ok(())
@@ -300,9 +336,7 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {
- with_state_mut!(state, {
- state.render_state.set_preview_mode(enabled);
- });
+ get_render_state().set_preview_mode(enabled);
Ok(())
}
@@ -345,9 +379,7 @@ pub extern "C" fn end_loading() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_loading_overlay() -> Result<()> {
- with_state_mut!(state, {
- state.render_state.render_loading_overlay();
- });
+ get_render_state().render_loading_overlay();
Ok(())
}
@@ -364,30 +396,24 @@ pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn reset_canvas() -> Result<()> {
- with_state_mut!(state, {
- state.render_state_mut().reset_canvas();
- });
+ get_render_state().reset_canvas();
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
- with_state_mut!(state, {
- state.resize(width, height)?;
- });
+ get_render_state().resize(width, height)?;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
- with_state_mut!(state, {
- performance::begin_measure!("set_view");
- let render_state = state.render_state_mut();
- render_state.set_view(zoom, x, y);
- performance::end_measure!("set_view");
- });
+ performance::begin_measure!("set_view");
+ let render_state = get_render_state();
+ render_state.set_view(zoom, x, y);
+ performance::end_measure!("set_view");
Ok(())
}
@@ -397,15 +423,13 @@ static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_start() -> Result<()> {
- with_state_mut!(state, {
- #[cfg(feature = "profile-macros")]
- unsafe {
- VIEW_INTERACTION_START = performance::get_time();
- }
- performance::begin_measure!("set_view_start");
- state.render_state.options.set_fast_mode(true);
- performance::end_measure!("set_view_start");
- });
+ #[cfg(feature = "profile-macros")]
+ unsafe {
+ VIEW_INTERACTION_START = performance::get_time();
+ }
+ performance::begin_measure!("set_view_start");
+ get_render_state().options.set_fast_mode(true);
+ performance::end_measure!("set_view_start");
Ok(())
}
@@ -419,34 +443,33 @@ pub extern "C" fn set_view_start() -> Result<()> {
pub extern "C" fn set_view_end() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_view_end");
- state.render_state.options.set_fast_mode(false);
- state.render_state.cancel_animation_frame();
+ let render_state = get_render_state();
+ render_state.options.set_fast_mode(false);
+ render_state.cancel_animation_frame();
- let scale = state.render_state.get_scale();
- state
- .render_state
+ let scale = render_state.get_scale();
+ render_state
.tile_viewbox
- .update(state.render_state.viewbox, scale);
+ .update(render_state.viewbox, scale);
- if state.render_state.options.is_profile_rebuild_tiles() {
+ if render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
- } else if state.render_state.zoom_changed() {
+ } else if render_state.zoom_changed() {
// Zoom changed: tile sizes differ so all cached tile
// textures are invalid (wrong scale). Rebuild the tile
// index and clear the tile texture cache, but *preserve*
// the cache canvas so render_from_cache can show a scaled
// preview of the old content while new tiles render.
- state.render_state.rebuild_tile_index(&state.shapes);
- state.render_state.surfaces.invalidate_tile_cache();
+ render_state.rebuild_tile_index(&state.shapes);
+ render_state.surfaces.invalidate_tile_cache();
} else {
// Pure pan at the same zoom level: tile contents have not
// changed — only the viewport position moved. Update the
// tile index (which tiles are in the interest area) but
// keep cached tile textures so the render can blit them
// instead of re-drawing every visible tile from scratch.
- state.render_state.rebuild_tile_index(&state.shapes);
+ render_state.rebuild_tile_index(&state.shapes);
}
-
performance::end_measure!("set_view_end");
});
Ok(())
@@ -460,12 +483,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_modifiers_start() -> Result<()> {
- with_state_mut!(state, {
- performance::begin_measure!("set_modifiers_start");
- state.render_state.options.set_fast_mode(true);
- state.render_state.options.set_interactive_transform(true);
- performance::end_measure!("set_modifiers_start");
- });
+ performance::begin_measure!("set_modifiers_start");
+ let render_state = get_render_state();
+ render_state.options.set_fast_mode(true);
+ render_state.options.set_interactive_transform(true);
+ performance::end_measure!("set_modifiers_start");
Ok(())
}
@@ -476,13 +498,12 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_modifiers_end() -> Result<()> {
- with_state_mut!(state, {
- performance::begin_measure!("set_modifiers_end");
- state.render_state.options.set_fast_mode(false);
- state.render_state.options.set_interactive_transform(false);
- state.render_state.cancel_animation_frame();
- performance::end_measure!("set_modifiers_end");
- });
+ performance::begin_measure!("set_modifiers_end");
+ let render_state = get_render_state();
+ render_state.options.set_fast_mode(false);
+ render_state.options.set_interactive_transform(false);
+ render_state.cancel_animation_frame();
+ performance::end_measure!("set_modifiers_end");
Ok(())
}
@@ -798,11 +819,9 @@ pub extern "C" fn is_image_cached(
d: u32,
is_thumbnail: bool,
) -> Result {
- with_state_mut!(state, {
- let id = uuid_from_u32_quartet(a, b, c, d);
- let result = state.render_state().has_image(&id, is_thumbnail);
- Ok(result)
- })
+ let id = uuid_from_u32_quartet(a, b, c, d);
+ let result = get_render_state().has_image(&id, is_thumbnail);
+ Ok(result)
}
#[no_mangle]
@@ -951,15 +970,14 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
#[wasm_error]
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, {
+ let render_state = get_render_state();
let prev_modifier_ids = state.shapes.clean_all();
// Skip the tile-cache cleanup during interactive transform: the
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
// the same tiles for the active modifier set, so the eviction
// here is redundant and doubles the per-emission cost.
- if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
- state
- .render_state
- .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
+ if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() {
+ render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
}
});
Ok(())
@@ -985,7 +1003,7 @@ pub extern "C" fn set_modifiers() -> Result<()> {
with_state_mut!(state, {
state.set_modifiers(modifiers);
// TO CHECK
- if !state.render_state.options.is_interactive_transform() {
+ if !get_render_state().options.is_interactive_transform() {
state.rebuild_modifier_tiles(ids)?;
}
});
@@ -1051,9 +1069,7 @@ pub extern "C" fn render_shape_pixels(
#[no_mangle]
pub extern "C" fn render_stats() {
- with_state!(state, {
- state.render_state.print_stats();
- })
+ get_render_state().print_stats();
}
fn main() {
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index a6822c991e..3df32255b5 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -2,7 +2,7 @@ mod debug;
mod fills;
pub mod filters;
mod fonts;
-mod gpu_state;
+pub mod gpu_state;
pub mod grid_layout;
mod images;
mod options;
@@ -17,13 +17,10 @@ use skia_safe::{self as skia, Matrix, RRect, Rect};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
-use gpu_state::GpuState;
-
use options::RenderOptions;
pub use surfaces::{SurfaceId, Surfaces};
use crate::error::{Error, Result};
-use crate::performance;
use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, StrokeKind, TextContent, Type,
@@ -33,6 +30,7 @@ use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
use crate::wapi;
+use crate::{get_gpu_state, performance};
pub use fonts::*;
pub use images::*;
@@ -327,7 +325,6 @@ impl RenderStats {
}
pub(crate) struct RenderState {
- gpu_state: GpuState,
pub options: RenderOptions,
stats: RenderStats,
pub surfaces: Surfaces,
@@ -394,6 +391,12 @@ pub struct InteractiveDragCrop {
/// True if the captured crop bounds were fully inside the viewport at capture time.
/// Used to avoid serving partial/offscreen crops during interactive drag.
pub fits_viewport_at_capture: bool,
+ /// Viewbox origin (doc-space) at capture time.
+ pub capture_vb_left: f32,
+ pub capture_vb_top: f32,
+ /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits).
+ pub capture_src_left: i32,
+ pub capture_src_top: i32,
pub image: skia::Image,
}
@@ -486,13 +489,11 @@ impl RenderState {
pub fn try_new(width: i32, height: i32) -> Result {
// This needs to be done once per WebGL context.
- let mut gpu_state = GpuState::try_new()?;
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
let fonts = FontStore::try_new()?;
let surfaces = Surfaces::try_new(
- &mut gpu_state,
(width, height),
sampling_options,
tiles::get_tile_dimensions(),
@@ -505,15 +506,14 @@ impl RenderState {
let tiles = tiles::TileHashMap::new();
let options = RenderOptions::default();
- Ok(RenderState {
- gpu_state: gpu_state.clone(),
+ Ok(Self {
options,
stats: RenderStats::new(),
surfaces,
fonts,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
- images: ImageStore::new(gpu_state.context.clone()),
+ images: ImageStore::new(),
background_color: skia::Color::TRANSPARENT,
render_request_id: None,
render_in_progress: false,
@@ -525,10 +525,10 @@ impl RenderState {
tiles,
tile_viewbox: tiles::TileViewbox::new_with_interest(
viewbox,
- options.viewport_interest_area_threshold,
+ options.dpr_viewport_interest_area_threshold,
1.0,
),
- pending_tiles: PendingTiles::new_empty(),
+ pending_tiles: PendingTiles::new(),
nested_fills: vec![],
nested_blurs: vec![],
nested_shadows: vec![],
@@ -742,8 +742,11 @@ impl RenderState {
}
pub fn set_dpr(&mut self, dpr: f32) -> Result<()> {
- if Some(dpr) != self.options.dpr {
- self.options.dpr = Some(dpr);
+ // Only when this function returns true (it means the value
+ // was properly changed) the rest of the functions is called.
+ if self.options.set_dpr(dpr) {
+ self.tile_viewbox
+ .set_interest(self.options.dpr_viewport_interest_area_threshold);
self.resize(
self.viewbox.width.floor() as i32,
self.viewbox.height.floor() as i32,
@@ -758,11 +761,15 @@ impl RenderState {
}
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
- self.options.set_viewport_interest_area_threshold(value);
- // The TileViewbox stores its own copy of `interest` (set at
- // construction). Without propagating, options change wouldn't
- // affect pending_tiles generation.
- self.tile_viewbox.set_interest(value);
+ // Only when this function returns true (it means the value
+ // was changed properly) the tile_viewbox.set_interest is called.
+ if self.options.set_viewport_interest_area_threshold(value) {
+ // The TileViewbox stores its own copy of `interest` (set at
+ // construction). Without propagating, options change wouldn't
+ // affect pending_tiles generation.
+ self.tile_viewbox
+ .set_interest(self.options.dpr_viewport_interest_area_threshold);
+ }
}
pub fn set_node_batch_threshold(&mut self, value: i32) {
@@ -786,10 +793,9 @@ impl RenderState {
}
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
- let dpr_width = (width as f32 * self.options.dpr()).floor() as i32;
- let dpr_height = (height as f32 * self.options.dpr()).floor() as i32;
- self.surfaces
- .resize(&mut self.gpu_state, dpr_width, dpr_height)?;
+ let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
+ let dpr_height = (height as f32 * self.options.dpr).floor() as i32;
+ self.surfaces.resize(dpr_width, dpr_height)?;
self.viewbox.set_wh(width as f32, height as f32);
self.tile_viewbox.update(self.viewbox, self.get_scale());
@@ -797,8 +803,7 @@ impl RenderState {
}
pub fn flush_and_submit(&mut self) {
- self.surfaces
- .flush_and_submit(&mut self.gpu_state, SurfaceId::Target);
+ self.surfaces.flush_and_submit(SurfaceId::Target);
}
pub fn reset_canvas(&mut self) {
@@ -877,7 +882,6 @@ impl RenderState {
.as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
self.surfaces.cache_current_tile_texture(
- &mut self.gpu_state,
&self.tile_viewbox,
¤t_tile,
&tile_rect,
@@ -1721,6 +1725,10 @@ impl RenderState {
src_doc_bounds,
src_selrect: selrect,
fits_viewport_at_capture,
+ capture_vb_left: vb_left,
+ capture_vb_top: vb_top,
+ capture_src_left: src_irect.left,
+ capture_src_top: src_irect.top,
image,
},
);
@@ -1739,7 +1747,7 @@ impl RenderState {
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() {
self.surfaces
- .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color);
+ .draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color);
if self.options.is_debug_visible() {
debug::render(self);
@@ -1759,15 +1767,15 @@ impl RenderState {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
- let interest = self.options.viewport_interest_area_threshold;
+ let interest = self.options.dpr_viewport_interest_area_threshold;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox,
interest,
cached_scale,
);
- let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
- let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
+ let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr;
+ let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr;
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
@@ -1780,8 +1788,8 @@ impl RenderState {
let cache_h = cache_dim.height as f32;
// Viewport in target pixels.
- let vw = (self.viewbox.width * self.options.dpr()).max(1.0);
- let vh = (self.viewbox.height * self.options.dpr()).max(1.0);
+ let vw = (self.viewbox.width * self.options.dpr).max(1.0);
+ let vh = (self.viewbox.height * self.options.dpr).max(1.0);
// Inverse-map viewport corners into cache coordinates.
// target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
@@ -1809,7 +1817,7 @@ impl RenderState {
if self.surfaces.has_atlas() {
self.surfaces.draw_atlas_to_target(
self.viewbox,
- self.options.dpr(),
+ self.options.dpr,
bg_color,
);
@@ -1966,12 +1974,12 @@ impl RenderState {
let viewbox_cache_size = get_cache_size(
self.viewbox,
scale,
- self.options.viewport_interest_area_threshold,
+ self.options.dpr_viewport_interest_area_threshold,
);
let cached_viewbox_cache_size = get_cache_size(
self.cached_viewbox,
scale,
- self.options.viewport_interest_area_threshold,
+ self.options.dpr_viewport_interest_area_threshold,
);
// Only resize cache if the new size is larger than the cached size
// This avoids unnecessary surface recreations when the cache size decreases
@@ -1980,7 +1988,7 @@ impl RenderState {
{
self.surfaces.resize_cache(
viewbox_cache_size,
- self.options.viewport_interest_area_threshold,
+ self.options.dpr_viewport_interest_area_threshold,
)?;
}
@@ -2186,13 +2194,12 @@ impl RenderState {
// Clear export context so get_scale() returns to workspace zoom.
self.export_context = None;
- self.surfaces
- .flush_and_submit(&mut self.gpu_state, target_surface);
+ self.surfaces.flush_and_submit(target_surface);
let image = self.surfaces.snapshot(target_surface);
let data = image
.encode(
- &mut self.gpu_state.context,
+ Some(&mut get_gpu_state().context),
skia::EncodedImageFormat::PNG,
100,
)
@@ -3005,7 +3012,6 @@ impl RenderState {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
- let crop_src_doc_bounds = crop.src_doc_bounds;
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
let (dx, dy) = match cur_selrect {
@@ -3015,23 +3021,10 @@ impl RenderState {
),
None => (0.0, 0.0),
};
-
- let dst_doc_rect = Rect::new(
- crop_src_doc_bounds.left + dx,
- crop_src_doc_bounds.top + dy,
- crop_src_doc_bounds.right + dx,
- crop_src_doc_bounds.bottom + dy,
- );
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
- let dst_tile_rect = skia::Rect::from_xywh(
- (dst_doc_rect.left + translation.0) * scale,
- (dst_doc_rect.top + translation.1) * scale,
- dst_doc_rect.width() * scale,
- dst_doc_rect.height() * scale,
- );
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
@@ -3051,12 +3044,14 @@ impl RenderState {
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true);
}
}
- canvas.draw_image_rect(
- crop_image,
- None,
- dst_tile_rect,
- &skia::Paint::default(),
- );
+ let doc_left =
+ crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx;
+ let doc_top =
+ crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy;
+
+ let x = (doc_left + translation.0) * scale;
+ let y = (doc_top + translation.1) * scale;
+ canvas.draw_image(crop_image, (x, y), Some(&skia::Paint::default()));
canvas.restore();
}
@@ -3585,8 +3580,7 @@ impl RenderState {
}
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
- self.surfaces
- .remove_cached_tile_surface(&mut self.gpu_state, tile);
+ self.surfaces.remove_cached_tile_surface(tile);
}
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
@@ -3750,11 +3744,11 @@ impl RenderState {
if let Some((_, export_scale)) = self.export_context {
return export_scale;
}
- self.viewbox.zoom() * self.options.dpr()
+ self.viewbox.zoom() * self.options.dpr
}
pub fn get_cached_scale(&self) -> f32 {
- self.cached_viewbox.zoom() * self.options.dpr()
+ self.cached_viewbox.zoom() * self.options.dpr
}
pub fn zoom_changed(&self) -> bool {
@@ -3785,6 +3779,6 @@ impl RenderState {
impl Drop for RenderState {
fn drop(&mut self) {
- self.gpu_state.context.free_gpu_resources();
+ get_gpu_state().context.free_gpu_resources();
}
}
diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs
index f374e32af3..266a9043de 100644
--- a/render-wasm/src/render/debug.rs
+++ b/render-wasm/src/render/debug.rs
@@ -1,7 +1,11 @@
use super::{tiles, RenderState, SurfaceId};
-use crate::with_state_mut;
-use crate::STATE;
+
+#[cfg(target_arch = "wasm32")]
use macros::wasm_error;
+
+#[cfg(target_arch = "wasm32")]
+use crate::get_render_state;
+
use skia_safe::{self as skia, Rect};
#[cfg(target_arch = "wasm32")]
@@ -227,9 +231,7 @@ pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId,
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_cache_console() -> Result<()> {
- with_state_mut!(state, {
- console_debug_surface(state.render_state_mut(), SurfaceId::Cache);
- });
+ console_debug_surface(get_render_state(), SurfaceId::Cache);
Ok(())
}
@@ -237,9 +239,7 @@ pub extern "C" fn debug_cache_console() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_cache_base64() -> Result<()> {
- with_state_mut!(state, {
- console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache);
- });
+ console_debug_surface_base64(get_render_state(), SurfaceId::Cache);
Ok(())
}
@@ -247,9 +247,7 @@ pub extern "C" fn debug_cache_base64() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_console() -> Result<()> {
- with_state_mut!(state, {
- console_debug_surface(state.render_state_mut(), SurfaceId::Atlas);
- });
+ console_debug_surface(get_render_state(), SurfaceId::Atlas);
Ok(())
}
@@ -257,8 +255,6 @@ pub extern "C" fn debug_atlas_console() -> Result<()> {
#[wasm_error]
#[cfg(target_arch = "wasm32")]
pub extern "C" fn debug_atlas_base64() -> Result<()> {
- with_state_mut!(state, {
- console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas);
- });
+ console_debug_surface_base64(get_render_state(), SurfaceId::Atlas);
Ok(())
}
diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs
index 77f8816435..47acd7a26b 100644
--- a/render-wasm/src/render/gpu_state.rs
+++ b/render-wasm/src/render/gpu_state.rs
@@ -41,7 +41,7 @@ impl GpuState {
}
};
- Ok(GpuState {
+ Ok(Self {
context,
framebuffer_info,
})
diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs
index e1c66b2a51..b7a25388dc 100644
--- a/render-wasm/src/render/images.rs
+++ b/render-wasm/src/render/images.rs
@@ -3,6 +3,7 @@ use crate::shapes::ImageFill;
use crate::uuid::Uuid;
use crate::error::Result;
+use crate::get_gpu_state;
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
use skia_safe::{self as skia, Codec, ISize};
use std::collections::HashMap;
@@ -143,10 +144,12 @@ fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option Self {
+ pub fn new() -> Self {
+ let gpu_state = get_gpu_state();
+ let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
- context: Box::new(context),
+ context: Box::new(context.clone()),
}
}
diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs
index 40a3125ccd..0f80b28bcb 100644
--- a/render-wasm/src/render/options.rs
+++ b/render-wasm/src/render/options.rs
@@ -7,7 +7,7 @@ const SHOW_WASM_INFO: u32 = 0x08;
// Render performance options
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
-const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
+const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
@@ -15,7 +15,7 @@ const ANTIALIAS_THRESHOLD: f32 = 7.0;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct RenderOptions {
pub flags: u32,
- pub dpr: Option,
+ pub dpr: f32,
fast_mode: bool,
/// Active while the user is interacting with a shape (drag, resize,
/// rotate). Implies `fast_mode` semantics for expensive effects but
@@ -25,6 +25,7 @@ pub struct RenderOptions {
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
pub antialias_threshold: f32,
pub viewport_interest_area_threshold: i32,
+ pub dpr_viewport_interest_area_threshold: i32,
pub max_blocking_time_ms: i32,
pub node_batch_threshold: i32,
pub blur_downscale_threshold: f32,
@@ -34,11 +35,12 @@ impl Default for RenderOptions {
fn default() -> Self {
Self {
flags: 0,
- dpr: None,
+ dpr: 1.0,
fast_mode: false,
interactive_transform: false,
antialias_threshold: ANTIALIAS_THRESHOLD,
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
+ dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
node_batch_threshold: NODE_BATCH_THRESHOLD,
blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD,
@@ -64,6 +66,24 @@ impl RenderOptions {
self.fast_mode = enabled;
}
+ /// Updates the dpr viewport interest area threshold.
+ /// This function is updated when the dpr or the
+ /// viewport_interest_area_threshold is changed
+ fn update_dpr_viewport_interest_area_threshold(&mut self) {
+ self.dpr_viewport_interest_area_threshold =
+ (self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32;
+ }
+
+ /// Sets the devicePixelRatio.
+ pub fn set_dpr(&mut self, value: f32) -> bool {
+ if value > 0.0 && self.dpr != value {
+ self.dpr = value;
+ self.update_dpr_viewport_interest_area_threshold();
+ return true;
+ }
+ false
+ }
+
/// Interactive transform is ON while the user is dragging, resizing
/// or rotating a shape. Callers use it to keep per-frame flushing
/// enabled and to render visible tiles in a single frame so tiles
@@ -84,10 +104,6 @@ impl RenderOptions {
self.fast_mode && !self.interactive_transform
}
- pub fn dpr(&self) -> f32 {
- self.dpr.unwrap_or(1.0)
- }
-
pub fn is_text_editor_v3(&self) -> bool {
self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3
}
@@ -96,33 +112,44 @@ impl RenderOptions {
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
}
- pub fn set_antialias_threshold(&mut self, value: f32) {
+ pub fn set_antialias_threshold(&mut self, value: f32) -> bool {
if value.is_finite() && value > 0.0 {
self.antialias_threshold = value;
+ return true;
}
+ false
}
- pub fn set_blur_downscale_threshold(&mut self, value: f32) {
+ pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool {
if value.is_finite() && value > 0.0 {
self.blur_downscale_threshold = value;
+ return true;
}
+ false
}
- pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
- if value >= 0 {
+ pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool {
+ if value >= 0 && self.viewport_interest_area_threshold != value {
self.viewport_interest_area_threshold = value;
+ self.update_dpr_viewport_interest_area_threshold();
+ return true;
}
+ false
}
- pub fn set_node_batch_threshold(&mut self, value: i32) {
+ pub fn set_node_batch_threshold(&mut self, value: i32) -> bool {
if value > 0 {
self.node_batch_threshold = value;
+ return true;
}
+ false
}
- pub fn set_max_blocking_time_ms(&mut self, value: i32) {
+ pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool {
if value > 0 {
self.max_blocking_time_ms = value;
+ return true;
}
+ false
}
}
diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs
index 3a142b05e6..6f31573288 100644
--- a/render-wasm/src/render/strokes.rs
+++ b/render-wasm/src/render/strokes.rs
@@ -302,6 +302,12 @@ fn handle_stroke_caps(
return;
}
+ // When both ends share the same simple line cap, Skia already drew it
+ // natively via `PaintCap` on the stroke paint, so skip the manual overlay.
+ if stroke.to_skia_linecap().is_some() {
+ return;
+ }
+
// Curves can have duplicated points, so let's remove consecutive duplicated points
let mut points = path.points().to_vec();
points.dedup();
diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs
index 688428f5eb..f4a3456e9b 100644
--- a/render-wasm/src/render/surfaces.rs
+++ b/render-wasm/src/render/surfaces.rs
@@ -1,7 +1,7 @@
use crate::error::{Error, Result};
-use crate::performance;
use crate::shapes::Shape;
use crate::view::Viewbox;
+use crate::{get_gpu_state, performance};
use skia_safe::{self as skia, IRect, Paint, RRect};
@@ -101,11 +101,12 @@ pub struct Surfaces {
#[allow(dead_code)]
impl Surfaces {
pub fn try_new(
- gpu_state: &mut GpuState,
(width, height): (i32, i32),
sampling_options: skia::SamplingOptions,
tile_dims: skia::ISize,
) -> Result {
+ let gpu_state = get_gpu_state();
+
let extra_tile_dims = skia::ISize::new(
tile_dims.width * TILE_SIZE_MULTIPLIER,
tile_dims.height * TILE_SIZE_MULTIPLIER,
@@ -140,7 +141,7 @@ impl Surfaces {
atlas.canvas().clear(skia::Color::TRANSPARENT);
let tiles = TileTextureCache::new();
- Ok(Surfaces {
+ Ok(Self {
target,
filter,
cache,
@@ -445,12 +446,9 @@ impl Surfaces {
self.margins
}
- pub fn resize(
- &mut self,
- gpu_state: &mut GpuState,
- new_width: i32,
- new_height: i32,
- ) -> Result<()> {
+ pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> {
+ let gpu_state = get_gpu_state();
+
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?;
Ok(())
}
@@ -547,7 +545,8 @@ impl Surfaces {
self.dirty_surfaces = 0;
}
- pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) {
+ pub fn flush_and_submit(&mut self, id: SurfaceId) {
+ let gpu_state = get_gpu_state();
let surface = self.get_mut(id);
gpu_state.context.flush_and_submit_surface(surface, None);
}
@@ -946,13 +945,13 @@ impl Surfaces {
pub fn cache_current_tile_texture(
&mut self,
- gpu_state: &mut GpuState,
tile_viewbox: &TileViewbox,
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
tile_doc_rect: skia::Rect,
) {
+ let gpu_state = get_gpu_state();
let rect = IRect::from_xywh(
self.margins.width,
self.margins.height,
@@ -985,7 +984,8 @@ impl Surfaces {
self.tiles.has(tile)
}
- pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
+ pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
+ let gpu_state = get_gpu_state();
// Mark tile as invalid
// Old content stays visible until new tile overwrites it atomically,
// preventing flickering during tile re-renders.
diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs
index 10e6e11725..8c990942ec 100644
--- a/render-wasm/src/render/text_editor.rs
+++ b/render-wasm/src/render/text_editor.rs
@@ -21,7 +21,7 @@ pub fn render_overlay(
};
canvas.save();
- let zoom = viewbox.zoom * options.dpr();
+ let zoom = viewbox.zoom * options.dpr;
canvas.scale((zoom, zoom));
canvas.translate((-viewbox.area.left, -viewbox.area.top));
diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs
index bb7cc72c0f..243d775812 100644
--- a/render-wasm/src/render/ui.rs
+++ b/render-wasm/src/render/ui.rs
@@ -11,7 +11,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
canvas.save();
let viewbox = render_state.viewbox;
- let zoom = viewbox.zoom * render_state.options.dpr();
+ let zoom = viewbox.zoom * render_state.options.dpr;
canvas.scale((zoom, zoom));
diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs
index 4ca3b5f906..6b3f9443ed 100644
--- a/render-wasm/src/shapes/strokes.rs
+++ b/render-wasm/src/shapes/strokes.rs
@@ -293,6 +293,10 @@ impl Stroke {
}
}
+ if let Some(cap) = self.to_skia_linecap() {
+ paint.set_stroke_cap(cap);
+ }
+
paint
}
@@ -330,6 +334,19 @@ impl Stroke {
cap_margin_for_cap(self.cap_start, self.width)
.max(cap_margin_for_cap(self.cap_end, self.width))
}
+
+ /// Returns a Skia `PaintCap` to apply natively on the stroke paint when
+ /// both ends share the same simple line cap (`Round/Round` or
+ /// `Square/Square`). Skia only emits cap geometry at sub-path endpoints,
+ /// so this is a no-op on closed paths and avoids the extra fill draw the
+ /// manual caps would otherwise require on open paths.
+ pub fn to_skia_linecap(&self) -> Option {
+ match (self.cap_start, self.cap_end) {
+ (Some(StrokeCap::Round), Some(StrokeCap::Round)) => Some(skia::paint::Cap::Round),
+ (Some(StrokeCap::Square), Some(StrokeCap::Square)) => Some(skia::paint::Cap::Square),
+ _ => None,
+ }
+ }
}
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 1a99000692..fcc9066777 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -199,6 +199,12 @@ pub struct TextContentLayout {
cached_extrect: Cell