mirror of
https://github.com/penpot/penpot.git
synced 2026-07-23 06:28:14 +00:00
✨ 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:
parent
ff3587ca2d
commit
5638e92e51
@ -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]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
168
backend/src/app/rpc/commands/exports.clj
Normal file
168
backend/src/app/rpc/commands/exports.clj
Normal 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))
|
||||
189
backend/test/backend_tests/rpc_commands_exports_test.clj
Normal file
189
backend/test/backend_tests/rpc_commands_exports_test.clj
Normal 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)))))))
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user