Add RPC proxy methods for exporter service

Add two new RPC commands (export-shapes and export-frames) that act as
authenticated gateway proxies to the internal exporter service. This enables
API consumers using access-token authentication to trigger exports
programmatically, which was previously only possible via the nginx-proxied
cookie-based /api/export endpoint.

- New RPC commands: ::export-shapes, ::export-frames with Transit-over-JSON proxy
- Both sync (wait:true) and async (wait:false) modes supported
- Cookie-based auth forwarded transparently; access-token auth creates temp sessions
- Session helpers: create-session-token and delete-session-by-id
- New config key: :exporter-uri (default: http://localhost:6061)
- Docker compose files updated with PENPOT_EXPORTER_URI

Closes #10008
This commit is contained in:
Andrey Antukh 2026-06-03 11:39:46 +00:00
parent ff3587ca2d
commit 5638e92e51
7 changed files with 392 additions and 1 deletions

View File

@ -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]

View File

@ -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))

View File

@ -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

View File

@ -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))

View File

@ -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)))))))

View File

@ -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

View File

@ -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.