Compare commits

...

1 Commits

Author SHA1 Message Date
Edward Paget
ef50a41135 feat: add route for getting setting key as binary
Converts data urls with base64 encoded data (such as images in
whitelabelled metabse) to bytes and sets the content type to the type
given in the data URL.

Serves these reponses from the same route that application serves images
so this looks like any other resource
2025-05-13 13:41:33 -05:00
8 changed files with 180 additions and 21 deletions

View File

@@ -12,6 +12,7 @@
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.server.auth-wrapper :as auth-wrapper]
[metabase.server.routes.index :as index]
[metabase.settings.core :as setting]
[metabase.system.core :as system]
[metabase.util :as u]
[metabase.util.log :as log]
@@ -72,6 +73,7 @@
;; ^/app/ -> static files under frontend_client/app
(context "/app" []
(route/resources "/" {:root "frontend_client/app"})
(GET "/img/:key" [key] (setting/image-response key))
;; return 404 for anything else starting with ^/app/ that doesn't exist
(route/not-found {:status 404, :body "Not found."}))
;; ^/public/ -> Public frontend and download routes

View File

@@ -7,22 +7,6 @@
[metabase.settings.models.setting :as setting]
[metabase.util :as u]))
(defn- do-with-setting-access-control
[thunk]
(try
(binding [setting/*enforce-setting-access-checks* true]
(thunk))
(catch clojure.lang.ExceptionInfo e
;; Throw a generic 403 for non-admins, so as to not reveal details about settings
(api/check-superuser)
(throw e))))
(defmacro ^:private with-setting-access-control
"Executes the given body with setting access enforcement enabled, and adds some exception handling to make sure we
return generic 403s to non-admins who try to read or write settings they don't have access to."
[& body]
`(do-with-setting-access-control (fn [] ~@body)))
;; TODO: deprecate /api/session/properties and have a single endpoint for listing settings
(api.macros/defendpoint :get "/"
"Get all `Settings` and their values. You must be a superuser or have `setting` permission to do this.
@@ -40,7 +24,7 @@
[_route-params
_query-params
settings :- [:map-of kebab-cased-keyword :any]]
(with-setting-access-control
(setting/with-setting-access-control
(setting/set-many! settings))
api/generic-204-no-content)
@@ -48,7 +32,7 @@
"Fetch a single `Setting`."
[{:keys [key]} :- [:map
[:key kebab-cased-keyword]]]
(with-setting-access-control
(setting/with-setting-access-control
(setting/user-facing-value key)))
(api.macros/defendpoint :put "/:key"
@@ -58,6 +42,6 @@
[:key kebab-cased-keyword]]
_query-params
{:keys [value]}]
(with-setting-access-control
(setting/with-setting-access-control
(setting/set! key value))
api/generic-204-no-content)

View File

@@ -87,6 +87,7 @@
(p/import-vars
[metabase.settings.models.setting
as-binary
admin-writable-site-wide-settings
can-read-setting?
current-user-readable-visibilities
@@ -119,7 +120,8 @@
define-multi-setting
define-multi-setting-impl]
[metabase.settings.settings
application-name-for-setting-descriptions])
application-name-for-setting-descriptions
image-response])
(defn database-local-values
"Database-local Settings values (as a map of Setting name -> already-deserialized value). This comes from the value of

View File

@@ -1565,3 +1565,36 @@
(if (= "encryption-check" (:key setting))
setting
(update setting :value encryption/maybe-decrypt)))
(mu/defn as-binary :- [:maybe [:tuple :string bytes?]]
"Parses a data URL string associated with a given setting key and returns a vector containing the content type and the decoded binary data.
Returns nil if the setting value is not a valid data URL or cannot be parsed."
[setting-key :- [:or :string :keyword]]
(let [data-url-string (get-value-of-type :string setting-key)]
(when (and (string? data-url-string) (str/starts-with? data-url-string "data:"))
(let [[_protocol rest] (str/split data-url-string #":" 2)
[header body] (str/split rest #"," 2)]
(when-not (nil? body)
(let [[content-type & headers-rest] (str/split header #";")]
(when-not (empty? content-type)
(case (last headers-rest)
"base64" [content-type (u/decode-base64-to-bytes body)]
;; otherwise assume this is UTF-8 string data (or no encoding specified)
[content-type (.getBytes ^String body "UTF-8")]))))))))
(defn do-with-setting-access-control
"Impl for with-setting-access-control"
[thunk]
(try
(binding [*enforce-setting-access-checks* true]
(thunk))
(catch clojure.lang.ExceptionInfo e
;; Throw a generic 403 for non-admins, so as to not reveal details about settings
(api/check-superuser)
(throw e))))
(defmacro with-setting-access-control
"Executes the given body with setting access enforcement enabled, and adds some exception handling to make sure we
return generic 403s to non-admins who try to read or write settings they don't have access to."
[& body]
`(do-with-setting-access-control (fn [] ~@body)))

View File

@@ -1,6 +1,8 @@
(ns metabase.settings.settings
(:require
[metabase.config :as config]
[metabase.settings.models.setting :as setting]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(mu/defn application-name-for-setting-descriptions
@@ -18,3 +20,17 @@
"Metabase"
(binding [config/*disable-setting-cache* true]
(getter))))
(defn image-response
"Returns a the requested setting as a binary ring response if the setting is a data-url encoded string, such as an image."
[key]
(try
(setting/with-setting-access-control
(when-let [[content-type body] (setting/as-binary key)]
{:status 200
:headers {"content-type" content-type}
:body body}))
(catch Throwable e
(log/errorf e "Setting %s not found" key)
;; Return nil on failure to try the next handler
nil)))

View File

@@ -2,7 +2,11 @@
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase.http-client :as client]))
[metabase.http-client :as client]
[metabase.test :as mt]
[metabase.util :as u]))
(set! *warn-on-reflection* true)
(deftest test-public-routes
(binding [client/*url-prefix* ""]
@@ -25,3 +29,21 @@
:headers
(get "Location"))
"/api/embed/card/token-string/query/csv?"))))
(deftest test-app-img-route
(testing "GET /app/img/:key"
(binding [client/*url-prefix* ""]
(testing "should return 404 if the setting key does not correspond to a valid image setting"
(let [response (client/client-full-response :get 404 "app/img/this-is-not-a-valid-image-setting-key" {})]
(is (str/includes? (:body response) "Not found."))))
(testing "should return 200 with image data if the setting is a valid image"
(let [base64-data "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" ; 1x1 black PNG
content-type "image/png"
image-setting-value (str "data:" content-type ";base64," base64-data)
expected-bytes (u/decode-base64 base64-data)]
(mt/with-temporary-setting-values [no-data-illustration-custom image-setting-value]
(let [response (client/client-full-response :get 200 "app/img/no-data-illustration-custom" {})]
(is (= content-type
(get-in response [:headers "content-type"])))
(is (= expected-bytes (:body response))
"Response body should match the expected image bytes"))))))))

View File

@@ -37,6 +37,12 @@
:visibility :settings-manager
:encryption :when-encryption-key-set)
(defsetting test-api-setting-data-url
(deferred-tru "Test setting for data URL handling in API")
:visibility :public
:type :string
:encryption :no)
;; ## Helper Fns
(defn- fetch-test-settings
"Fetch the provided settings using the API. Settings not present in the response are ignored."

View File

@@ -22,6 +22,8 @@
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
;; ## TEST SETTINGS DEFINITIONS
@@ -1640,3 +1642,95 @@
(mt/with-temporary-setting-values [test-setting-that-is-not-included-when-listing-in-api "fun-times"]
(is (= ::not-present
(f :test-setting-that-is-not-included-when-listing-in-api)))))))))
(defsetting test-as-binary-setting
"Temporary setting for testing as-binary"
:type :string
:visibility :internal
:encryption :no)
(deftest as-binary-test
(testing "Valid data URL with image content type"
(mt/with-temporary-setting-values [test-as-binary-setting "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"]
(let [decoded-expected-data (u/decode-base64-to-bytes "iVBORw0KGgoAAAANSUhEUgAAAAUA")]
(is (= ["image/png" (seq decoded-expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Valid data URL with text content type"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain;base64,SGVsbG8gV29ybGQ="] ; "Hello World"
(let [expected-data (.getBytes "Hello World" "UTF-8")]
(is (= ["text/plain" (seq expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Valid data URL with content type parameters"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain;charset=UTF-8;base64,SGVsbG8gV29ybGQh"] ; "Hello World!"
(let [expected-data (.getBytes "Hello World!" "UTF-8")]
(is (= ["text/plain" (seq expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Valid data URL with empty content type (just ';base64')"
(mt/with-temporary-setting-values [test-as-binary-setting "data:;base64,SGVsbG8="] ; "Hello"
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Valid data URL with content type but empty body"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain;base64,"]
(let [expected-data (.getBytes "" "UTF-8")]
(is (= ["text/plain" (seq expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Setting value is not a data URL - does not start with 'data:'"
(mt/with-temporary-setting-values [test-as-binary-setting "http://example.com/image.png"]
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Setting value is an invalid data URL - missing comma separator"
(mt/with-temporary-setting-values [test-as-binary-setting "data:image/png;base64iVBORw0KGgoAAAANSUhEUgAAAAUA"]
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Setting value is an invalid data URL - malformed header (no comma, but body is present)"
(mt/with-temporary-setting-values [test-as-binary-setting "data:SGVsbG8="]
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Setting value is nil"
(mt/with-temporary-setting-values [test-as-binary-setting nil]
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Setting value is an empty string"
(mt/with-temporary-setting-values [test-as-binary-setting ""]
(is (nil? (setting/as-binary :test-as-binary-setting)))))
(testing "Data URL with only 'data:,'"
(mt/with-temporary-setting-values [test-as-binary-setting "data:,"]
(let [expected-data (.getBytes "" "UTF-8")]
(is (= [nil (seq expected-data)] ; content-type becomes nil
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Data URL with 'data:text/plain,'"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain,"]
(let [expected-data (.getBytes "" "UTF-8")]
(is (= ["text/plain" (seq expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Data URL with non-base64 data in body (assumed UTF-8)"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain,ThisIsNotBase64"]
(let [expected-data (.getBytes "ThisIsNotBase64" "UTF-8")]
(is (= ["text/plain" (seq expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)]))))))
(testing "Data URL with base64 encoding specified but body is not valid base64"
(mt/with-temporary-setting-values [test-as-binary-setting "data:text/plain;base64,Hello World"] ; "Hello World" is not valid Base64
(is (thrown-with-msg? java.lang.IllegalArgumentException #"Illegal base64 character"
(setting/as-binary :test-as-binary-setting)))))
(testing "Data URL with special characters in base64 string"
(mt/with-temporary-setting-values [test-as-binary-setting "data:application/octet-stream;base64,+//="] ; Valid base64 for 3 bytes: 251, 255, 61
(let [decoded-expected-data (u/decode-base64-to-bytes "+//=")]
(is (= ["application/octet-stream" (seq decoded-expected-data)]
(let [[ct body] (setting/as-binary :test-as-binary-setting)]
[ct (seq body)])))))))