diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 3b04c92b49..200c02afc5 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -90,7 +90,9 @@ ;; SSRF protection :ssrf-allowed-hosts #{} - :ssrf-extra-blocked-cidrs #{}}) + :ssrf-extra-blocked-cidrs #{} + + :exporter-uri "http://localhost:6061"}) (def schema:config (do #_sm/optional-keys @@ -244,6 +246,7 @@ [:executor-threads {:optional true} ::sm/int] [:nitrate-backend-uri {:optional true} ::sm/uri] + [:exporter-uri {:optional true} ::sm/uri] ;; DEPRECATED [:assets-storage-backend {:optional true} :keyword] diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index c3ec302e45..f513d3da42 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -336,3 +336,28 @@ (l/dbg :hint "initializing session gc task" :max-age max-age) (fn [_] (db/tx-run! cfg collect-expired-tasks))) + +(defn create-session-token + "Creates a session in the database and returns a map with :id (the session + DB row id) and :token (the raw JWE token string). Used for service-to-service + auth where we need a valid Bearer token without going through the full + login/cookie flow (e.g., proxying to the exporter from an access-token RPC call). + + Unlike `create-fn`, this does NOT set a Set-Cookie header. Caller is + responsible for using and cleaning up the token." + [cfg profile-id & {:keys [sso-provider-id sso-session-id user-agent]}] + (let [params (d/without-nils + {:profile-id profile-id + :user-agent (or user-agent "penpot-exporter-proxy") + :sso-provider-id sso-provider-id + :sso-session-id sso-session-id}) + session (create-session (::manager cfg) params) + session (assign-token cfg session)] + {:id (:id session) + :token (:token session)})) + +(defn delete-session-by-id + "Deletes a session row from the database by its session-id. + Used for cleaning up temp sessions created via `create-session-token`." + [cfg session-id] + (delete-session (::manager cfg) session-id)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 3ce16ef6ee..609fc9af47 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -257,6 +257,7 @@ 'app.rpc.commands.binfile 'app.rpc.commands.comments 'app.rpc.commands.demo + 'app.rpc.commands.exports 'app.rpc.commands.files 'app.rpc.commands.files-create 'app.rpc.commands.files-share diff --git a/backend/src/app/rpc/commands/exports.clj b/backend/src/app/rpc/commands/exports.clj new file mode 100644 index 0000000000..68439349fb --- /dev/null +++ b/backend/src/app/rpc/commands/exports.clj @@ -0,0 +1,168 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.rpc.commands.exports + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.transit :as t] + [app.common.uri :as u] + [app.config :as cf] + [app.http :as-alias http] + [app.http.client :as httpc] + [app.http.session :as session] + [app.rpc :as-alias rpc] + [app.rpc.doc :as-alias doc] + [app.util.services :as sv]) + (:import + java.time.Duration)) + +(def ^:private exporter-timeout + "Timeout for exporter HTTP calls. 5 minutes for large renders." + (Duration/ofMinutes 5)) + +(defn- call-exporter + "Send a POST to the exporter service with Transit-encoded body. + auth-token — JWE token string (cookie or temp session) + Returns the transit-decoded response body map." + [{:as cfg} auth-token exporter-params] + (let [uri (str (u/ensure-path-slash + (cf/get :exporter-uri "http://localhost:6061"))) + body (t/encode-str exporter-params {:type :json}) + headers {"content-type" "application/transit+json" + "accept" "application/transit+json" + "cookie" (str "auth-token=" auth-token)} + request {:method :post + :uri uri + :headers headers + :body body + :timeout exporter-timeout} + response (httpc/req cfg request {:skip-ssrf-check? true})] + (when (not= 200 (:status response)) + (ex/raise :type :internal + :code :exporter-request-failed + :hint (str "exporter returned status " (:status response)) + :http-status (:status response))) + (t/decode-str (:body response)))) + +(defn- resolve-auth-token + "Returns [auth-token temporary-session? session-id] where: + - auth-token is the JWE token string to forward to the exporter + - temporary-session? is true if a temp session was created (needs cleanup) + - session-id is the temp session DB id (nil if using cookie)" + [cfg {:keys [::rpc/profile-id] :as params}] + (let [request (-> params meta ::http/request) + auth-token (get-in request [:cookies "auth-token" :value])] + (if auth-token + [auth-token false nil] + ;; No cookie — must be access-token auth. Create a temp session. + (let [{:keys [id token]} (session/create-session-token cfg profile-id)] + [token true id])))) + +;; ─── Schemas ───────────────────────────────────────────────────────────── + +(def ^:private schema:export-entry + [:map {:title "export-entry"} + [:file-id ::sm/uuid] + [:page-id ::sm/uuid] + [:object-id ::sm/uuid] + [:type [:keyword {:enum #{:png :jpeg :webp :svg :pdf}}]] + [:scale ::sm/number] + [:suffix :string] + [:name :string] + [:share-id {:optional true} ::sm/uuid]]) + +(def ^:private schema:export-shapes-params + [:map {:title "export-shapes-params"} + [:exports [:vector {:min 1} schema:export-entry]] + [:wait {:optional true} :boolean] + [:name {:optional true} :string] + [:is-wasm {:optional true} :boolean]]) + +(def ^:private schema:export-frames-entry + [:map {:title "export-frames-entry"} + [:file-id ::sm/uuid] + [:page-id ::sm/uuid] + [:object-id ::sm/uuid] + [:name :string]]) + +(def ^:private schema:export-frames-params + [:map {:title "export-frames-params"} + [:exports [:vector {:min 1} schema:export-frames-entry]] + [:wait {:optional true} :boolean] + [:name {:optional true} :string] + [:is-wasm {:optional true} :boolean]]) + +(def ^:private schema:export-result + "Result for wait:true. When wait:false, :uri is absent." + [:map {:title "export-result"} + [:id ::sm/uuid] + [:name :string] + [:filename :string] + [:uri {:optional true} :string] + [:mtype :string]]) + +;; ─── RPC Methods ───────────────────────────────────────────────────────── + +(sv/defmethod ::export-shapes + "Export one or more shapes to image files (PNG, JPEG, WebP, SVG). + + Set :wait true for synchronous export (blocks until rendering and upload + complete, returns download URI). Set :wait false for asynchronous export + (returns immediately with a resource ID; progress updates flow via the + existing WebSocket path). + + When exporting a single shape, the response contains a direct download + URI. When exporting multiple shapes, the response contains a ZIP file + download URI." + {::doc/added "2.15" + ::sm/params schema:export-shapes-params + ::sm/result schema:export-result} + [cfg {:keys [exports wait name is-wasm] :as params}] + (let [[auth-token temp-session? session-id] + (resolve-auth-token cfg params) + + profile-id (::rpc/profile-id params) + exporter-params (cond-> {:cmd :export-shapes + :profile-id profile-id + :wait (boolean wait) + :exports exports + :is-wasm (boolean is-wasm)} + (some? name) (assoc :name name)) + result (call-exporter cfg auth-token exporter-params)] + + ;; Clean up temp session for sync exports only. + ;; For wait:false, the exporter still needs the token for async upload. + (when (and temp-session? (true? wait)) + (session/delete-session-by-id cfg session-id)) + + result)) + +(sv/defmethod ::export-frames + "Export one or more frames/boards to a single PDF file. + + Set :wait true for synchronous export (blocks until rendering and upload + complete, returns download URI). Set :wait false for asynchronous export + (returns immediately with a resource ID; progress via WebSocket)." + {::doc/added "2.15" + ::sm/params schema:export-frames-params + ::sm/result schema:export-result} + [cfg {:keys [exports wait name is-wasm] :as params}] + (let [[auth-token temp-session? session-id] + (resolve-auth-token cfg params) + + profile-id (::rpc/profile-id params) + exporter-params (cond-> {:cmd :export-frames + :profile-id profile-id + :exports exports + :is-wasm (boolean is-wasm)} + (some? name) (assoc :name name)) + result (call-exporter cfg auth-token exporter-params)] + + (when (and temp-session? (true? wait)) + (session/delete-session-by-id cfg session-id)) + + result)) diff --git a/backend/test/backend_tests/rpc_commands_exports_test.clj b/backend/test/backend_tests/rpc_commands_exports_test.clj new file mode 100644 index 0000000000..fbc5d7bf36 --- /dev/null +++ b/backend/test/backend_tests/rpc_commands_exports_test.clj @@ -0,0 +1,189 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-commands-exports-test + (:require + [app.common.transit :as tr] + [app.common.uuid :as uuid] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; ─── Helpers ────────────────────────────────────────────────────────────── + +(defn- transit-encode-resource + "Return a Transit-encoded JSON string for a mock exporter response." + [resource-map] + (tr/encode-str resource-map {:type :json})) + +(defn- mock-exporter-response + "Build a mock HTTP response map as returned by app.http.client/req." + [body status] + {:status (or status 200) + :body body}) + +(defn- call-export-method + "Invoke an export RPC method directly (bypassing command!) to allow + metadata on params." + [method-kw params] + (let [[_ method-fn] (get-in th/*system* [:app.rpc/methods method-kw])] + (method-fn params))) + +;; ─── Tests ──────────────────────────────────────────────────────────────── + +(t/deftest export-shapes-wait-true-with-access-token + (with-mocks + [http-mock {:target 'app.http.client/req + :return (mock-exporter-response + (transit-encode-resource + {:id (uuid/next) + :name "test.png" + :filename "test.png" + :uri "http://localhost/assets/test.png" + :mtype "image/png"}) + 200)} + session-create-mock {:target 'app.http.session/create-session-token + :return {:id (uuid/next) :token "test-jwt-token"}} + session-delete-mock {:target 'app.http.session/delete-session-by-id + :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + params {::rpc/profile-id (:id profile) + :exports [{:file-id (uuid/next) + :page-id (uuid/next) + :object-id (uuid/next) + :type :png + :scale 1 + :suffix "" + :name "test"}] + :wait true} + result (call-export-method :export-shapes params)] + + (t/is (some? result)) + (t/is (contains? result :uri)) + (t/is (contains? result :id)) + (t/is (contains? result :name)) + (t/is (contains? result :filename)) + (t/is (contains? result :mtype)) + (t/is (= 1 (:call-count @session-create-mock))) + (t/is (= 1 (:call-count @session-delete-mock))) + (t/is (= 1 (:call-count @http-mock)))))) + +(t/deftest export-shapes-wait-false-with-access-token + (with-mocks + [http-mock {:target 'app.http.client/req + :return (mock-exporter-response + (transit-encode-resource + {:id (uuid/next) + :name "test.png" + :filename "test.png" + :mtype "image/png"}) + 200)} + session-create-mock {:target 'app.http.session/create-session-token + :return {:id (uuid/next) :token "test-jwt-token"}} + session-delete-mock {:target 'app.http.session/delete-session-by-id + :return nil}] + (let [profile (th/create-profile* 2 {:is-active true}) + params {::rpc/profile-id (:id profile) + :exports [{:file-id (uuid/next) + :page-id (uuid/next) + :object-id (uuid/next) + :type :jpeg + :scale 2 + :suffix "" + :name "test"}] + :wait false} + result (call-export-method :export-shapes params)] + + (t/is (some? result)) + (t/is (contains? result :id)) + (t/is (= 1 (:call-count @session-create-mock))) + ;; IMPORTANT: session must NOT be deleted for wait:false + (t/is (= 0 (:call-count @session-delete-mock))) + (t/is (= 1 (:call-count @http-mock)))))) + +(t/deftest export-shapes-exporter-http-error + (with-mocks + [http-mock {:target 'app.http.client/req + :return (mock-exporter-response "Internal Server Error" 500)} + session-create-mock {:target 'app.http.session/create-session-token + :return {:id (uuid/next) :token "test-jwt-token"}} + session-delete-mock {:target 'app.http.session/delete-session-by-id + :return nil}] + (let [profile (th/create-profile* 3 {:is-active true}) + params {::rpc/profile-id (:id profile) + :exports [{:file-id (uuid/next) + :page-id (uuid/next) + :object-id (uuid/next) + :type :png + :scale 1 + :suffix "" + :name "test"}] + :wait true}] + + (t/is (thrown? Exception + (call-export-method :export-shapes params))) + (t/is (= 1 (:call-count @http-mock)))))) + +(t/deftest export-frames-wait-true-with-access-token + (with-mocks + [http-mock {:target 'app.http.client/req + :return (mock-exporter-response + (transit-encode-resource + {:id (uuid/next) + :name "frames.pdf" + :filename "frames.pdf" + :uri "http://localhost/assets/frames.pdf" + :mtype "application/pdf"}) + 200)} + session-create-mock {:target 'app.http.session/create-session-token + :return {:id (uuid/next) :token "test-jwt-token"}} + session-delete-mock {:target 'app.http.session/delete-session-by-id + :return nil}] + (let [profile (th/create-profile* 4 {:is-active true}) + params {::rpc/profile-id (:id profile) + :exports [{:file-id (uuid/next) + :page-id (uuid/next) + :object-id (uuid/next) + :name "Frame 1"}] + :wait true} + result (call-export-method :export-frames params)] + + (t/is (some? result)) + (t/is (contains? result :uri)) + (t/is (= 1 (:call-count @session-create-mock))) + (t/is (= 1 (:call-count @session-delete-mock))) + (t/is (= 1 (:call-count @http-mock)))))) + +(t/deftest export-shapes-params-validation + (let [profile (th/create-profile* 5 {:is-active true})] + + ;; Missing required fields + (t/testing "missing exports" + (let [result (th/command! {::th/type :export-shapes + ::rpc/profile-id (:id profile)})] + (t/is (some? (:error result))))) + + (t/testing "empty exports" + (let [result (th/command! {::th/type :export-shapes + ::rpc/profile-id (:id profile) + :exports []})] + (t/is (some? (:error result))))) + + (t/testing "invalid export type" + (let [result (th/command! {::th/type :export-shapes + ::rpc/profile-id (:id profile) + :exports [{:file-id (uuid/next) + :page-id (uuid/next) + :object-id (uuid/next) + :type :invalid + :scale 1 + :suffix "" + :name "test"}]})] + (t/is (some? (:error result))))))) diff --git a/docker/devenv/docker-compose.yaml b/docker/devenv/docker-compose.yaml index 6963b18d8a..a919ef3f75 100644 --- a/docker/devenv/docker-compose.yaml +++ b/docker/devenv/docker-compose.yaml @@ -74,6 +74,9 @@ services: - PENPOT_SMTP_SSL=false - PENPOT_SMTP_TLS=false + # Exporter URI for RPC proxy + - PENPOT_EXPORTER_URI=http://localhost:6061 + # LDAP setup - PENPOT_LDAP_HOST=ldap - PENPOT_LDAP_PORT=10389 diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index cd5c9bf113..fcf4315e53 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -142,6 +142,8 @@ services: PENPOT_REDIS_URI: redis://penpot-valkey/0 + PENPOT_EXPORTER_URI: http://penpot-exporter:6061 + ## Default configuration for assets storage: using filesystem based with all files ## stored in a docker volume.