Skip to content
Snippets Groups Projects
Unverified Commit 502a09c9 authored by Cam Saul's avatar Cam Saul
Browse files

Switch to async web server :race_car:

parent ba25b11c
Branches
Tags
No related merge requests found
Showing
with 822 additions and 617 deletions
......@@ -178,7 +178,7 @@
[[lein-ring "0.12.5" :exclusions [org.clojure/clojure]]]
:ring
{:handler metabase.core/app
{:handler metabase.handler/app
:init metabase.core/init!
:destroy metabase.core/destroy
:reload-paths ["src"]}}]
......
......@@ -34,26 +34,28 @@
[tiles :as tiles]
[user :as user]
[util :as util]]
[metabase.middleware :as middleware]
[metabase.middleware
[auth :as middleware.auth]
[exceptions :as middleware.exceptions]]
[metabase.util.i18n :refer [tru]]))
(def ^:private +generic-exceptions
"Wrap ROUTES so any Exception thrown is just returned as a generic 400, to prevent details from leaking in public
endpoints."
middleware/genericize-exceptions)
middleware.exceptions/genericize-exceptions)
(def ^:private +message-only-exceptions
"Wrap ROUTES so any Exception thrown is just returned as a 400 with only the message from the original
Exception (i.e., remove the original stacktrace), to prevent details from leaking in public endpoints."
middleware/message-only-exceptions)
middleware.exceptions/message-only-exceptions)
(def ^:private +apikey
"Wrap ROUTES so they may only be accessed with proper apikey credentials."
middleware/enforce-api-key)
middleware.auth/enforce-api-key)
(def ^:private +auth
"Wrap ROUTES so they may only be accessed with proper authentiaction credentials."
middleware/enforce-authentication)
middleware.auth/enforce-authentication)
(defroutes ^{:doc "Ring routes for API endpoints."} routes
(context "/activity" [] (+auth activity/routes))
......
;; -*- comment-column: 35; -*-
(ns metabase.core
(:gen-class)
(:require [cheshire.core :as json]
[clojure.pprint :as pprint]
[clojure.tools.logging :as log]
[medley.core :as m]
(:require [clojure.tools.logging :as log]
[metabase
[config :as config]
[db :as mdb]
[events :as events]
[handler :as handler]
[metabot :as metabot]
[middleware :as mb-middleware]
[plugins :as plugins]
[routes :as routes]
[sample-data :as sample-data]
[server :as server]
[setup :as setup]
[task :as task]
[util :as u]]
......@@ -23,92 +20,7 @@
[setting :as setting]
[user :refer [User]]]
[metabase.util.i18n :refer [set-locale trs]]
[puppetlabs.i18n.core :refer [locale-negotiator]]
[ring.adapter.jetty :as ring-jetty]
[ring.middleware
[cookies :refer [wrap-cookies]]
[gzip :refer [wrap-gzip]]
[json :refer [wrap-json-body]]
[keyword-params :refer [wrap-keyword-params]]
[params :refer [wrap-params]]
[session :refer [wrap-session]]]
[ring.util
[io :as rui]
[response :as rr]]
[toucan.db :as db])
(:import [java.io BufferedWriter OutputStream OutputStreamWriter]
[java.nio.charset Charset StandardCharsets]
org.eclipse.jetty.server.Server
org.eclipse.jetty.util.thread.QueuedThreadPool))
;;; CONFIG
;; TODO - why not just put this in `metabase.middleware` with *all* of our other custom middleware. Also, what's the
;; difference between this and `streaming-json-response`?
(defn- streamed-json-response
"Write `RESPONSE-SEQ` to a PipedOutputStream as JSON, returning the connected PipedInputStream"
[response-seq opts]
(rui/piped-input-stream
(fn [^OutputStream output-stream]
(with-open [output-writer (OutputStreamWriter. ^OutputStream output-stream ^Charset StandardCharsets/UTF_8)
buffered-writer (BufferedWriter. output-writer)]
(json/generate-stream response-seq buffered-writer opts)))))
(defn- wrap-streamed-json-response
"Similar to ring.middleware/wrap-json-response in that it will serialize the response's body to JSON if it's a
collection. Rather than generating a string it will stream the response using a PipedOutputStream.
Accepts the following options (same as `wrap-json-response`):
:pretty - true if the JSON should be pretty-printed
:escape-non-ascii - true if non-ASCII characters should be escaped with \\u"
[handler & [{:as opts}]]
(fn [request]
(let [response (handler request)]
(if-let [json-response (and (coll? (:body response))
(update-in response [:body] streamed-json-response opts))]
(if (contains? (:headers json-response) "Content-Type")
json-response
(rr/content-type json-response "application/json; charset=utf-8"))
response))))
(def ^:private jetty-instance
(atom nil))
(defn- jetty-stats []
(when-let [^Server jetty-server @jetty-instance]
(let [^QueuedThreadPool pool (.getThreadPool jetty-server)]
{:min-threads (.getMinThreads pool)
:max-threads (.getMaxThreads pool)
:busy-threads (.getBusyThreads pool)
:idle-threads (.getIdleThreads pool)
:queue-size (.getQueueSize pool)})))
(def ^:private app
"The primary entry point to the Ring HTTP server."
;; ▼▼▼ POST-PROCESSING ▼▼▼ happens from TOP-TO-BOTTOM
(-> #'routes/routes ; the #' is to allow tests to redefine endpoints
mb-middleware/catch-api-exceptions ; catch exceptions and return them in our expected format
(mb-middleware/log-api-call
jetty-stats)
mb-middleware/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached
(wrap-json-body ; extracts json POST body and makes it avaliable on request
{:keywords? true})
wrap-streamed-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
mb-middleware/bind-current-user ; Binds *current-user* and *current-user-id* if :metabase-user-id is non-nil
mb-middleware/wrap-current-user-id ; looks for :metabase-session-id and sets :metabase-user-id if Session ID is valid
mb-middleware/wrap-api-key ; looks for a Metabase API Key on the request and assocs as :metabase-api-key
mb-middleware/wrap-session-id ; looks for a Metabase Session ID and assoc as :metabase-session-id
mb-middleware/maybe-set-site-url ; set the value of `site-url` if it hasn't been set yet
locale-negotiator ; Binds *locale* for i18n
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key
mb-middleware/add-content-type ; Adds a Content-Type header for any response that doesn't already have one
wrap-gzip)) ; GZIP response if client can handle it
;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP
[toucan.db :as db]))
;;; --------------------------------------------------- Lifecycle ----------------------------------------------------
......@@ -195,64 +107,18 @@
(init-status/set-complete!)
(log/info (trs "Metabase Initialization COMPLETE")))
;;; ## ---------------------------------------- Jetty (Web) Server ----------------------------------------
(defn- jetty-ssl-config []
(m/filter-vals identity {:ssl-port (config/config-int :mb-jetty-ssl-port)
:keystore (config/config-str :mb-jetty-ssl-keystore)
:key-password (config/config-str :mb-jetty-ssl-keystore-password)
:truststore (config/config-str :mb-jetty-ssl-truststore)
:trust-password (config/config-str :mb-jetty-ssl-truststore-password)}))
(defn- jetty-config []
(cond-> (m/filter-vals identity {:port (config/config-int :mb-jetty-port)
:host (config/config-str :mb-jetty-host)
:max-threads (config/config-int :mb-jetty-maxthreads)
:min-threads (config/config-int :mb-jetty-minthreads)
:max-queued (config/config-int :mb-jetty-maxqueued)
:max-idle-time (config/config-int :mb-jetty-maxidletime)})
(config/config-str :mb-jetty-daemon) (assoc :daemon? (config/config-bool :mb-jetty-daemon))
(config/config-str :mb-jetty-ssl) (-> (assoc :ssl? true)
(merge (jetty-ssl-config)))))
(defn- log-config [jetty-config]
(log/info (trs "Launching Embedded Jetty Webserver with config:")
"\n"
(with-out-str (pprint/pprint (m/filter-keys #(not (re-matches #".*password.*" (str %)))
jetty-config)))))
(defn start-jetty!
"Start the embedded Jetty web server."
[]
(when-not @jetty-instance
(let [jetty-config (jetty-config)]
(log-config jetty-config)
;; NOTE: we always start jetty w/ join=false so we can start the server first then do init in the background
(->> (ring-jetty/run-jetty app (assoc jetty-config :join? false))
(reset! jetty-instance)))))
(defn stop-jetty!
"Stop the embedded Jetty web server."
[]
(when @jetty-instance
(log/info (trs "Shutting Down Embedded Jetty Webserver"))
(.stop ^Server @jetty-instance)
(reset! jetty-instance nil)))
;;; -------------------------------------------------- Normal Start --------------------------------------------------
(defn- start-normally []
(log/info (trs "Starting Metabase in STANDALONE mode"))
(try
;; launch embedded webserver async
(start-jetty!)
(server/start-web-server! handler/app)
;; run our initialization process
(init!)
;; Ok, now block forever while Jetty does its thing
(when (config/config-bool :mb-jetty-join)
(.join ^Server @jetty-instance))
(.join (server/instance)))
(catch Throwable e
(log/error e (trs "Metabase Initialization FAILED"))
(System/exit 1))))
......
(ns metabase.handler
"Top-level Metabase Ring handler."
(:require [metabase.middleware
[auth :as mw.auth]
[exceptions :as mw.exceptions]
[json :as mw.json]
[log :as mw.log]
[misc :as mw.misc]
[security :as mw.security]
[session :as mw.session]]
[metabase.routes :as routes]
[ring.middleware
[cookies :refer [wrap-cookies]]
[keyword-params :refer [wrap-keyword-params]]
[params :refer [wrap-params]]]))
(def app
"The primary entry point to the Ring HTTP server."
;; ▼▼▼ POST-PROCESSING ▼▼▼ happens from TOP-TO-BOTTOM
(->
#'routes/routes ; the #' is to allow tests to redefine endpoints
mw.exceptions/catch-uncaught-exceptions ; catch any Exceptions that weren't passed to `raise`
mw.exceptions/catch-api-exceptions ; catch exceptions and return them in our expected format
mw.log/log-api-call
mw.security/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached
mw.json/wrap-json-body ; extracts json POST body and makes it avaliable on request
mw.json/wrap-streamed-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
mw.session/bind-current-user ; Binds *current-user* and *current-user-id* if :metabase-user-id is non-nil
mw.session/wrap-current-user-id ; looks for :metabase-session-id and sets :metabase-user-id if Session ID is valid
mw.session/wrap-session-id ; looks for a Metabase Session ID and assoc as :metabase-session-id
mw.auth/wrap-api-key ; looks for a Metabase API Key on the request and assocs as :metabase-api-key
mw.misc/maybe-set-site-url ; set the value of `site-url` if it hasn't been set yet
mw.misc/bind-user-locale ; Binds *locale* for i18n
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
#_wrap-session ; reads in current HTTP session and sets :session key TODO - don't think we need this
mw.misc/add-content-type ; Adds a Content-Type header for any response that doesn't already have one
mw.misc/wrap-gzip ; GZIP response if client can handle it
))
;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP
This diff is collapsed.
(ns metabase.middleware.auth
"Middleware related to enforcing authentication/API keys (when applicable). Unlike most other middleware most of this
is not used as part of the normal `app`; it is instead added selectively to appropriate routes."
(:require [metabase.config :as config]
[metabase.middleware.util :as middleware.u]))
(def ^:private ^:const ^String metabase-api-key-header "x-metabase-apikey")
(defn enforce-authentication
"Middleware that returns a 401 response if REQUEST has no associated `:metabase-user-id`."
[handler]
(fn [{:keys [metabase-user-id] :as request} respond raise]
(if metabase-user-id
(handler request respond raise)
(respond middleware.u/response-unauthentic))))
(defn- wrap-api-key* [{:keys [headers], :as request}]
(if-let [api-key (headers metabase-api-key-header)]
(assoc request :metabase-api-key api-key)
request))
(defn wrap-api-key
"Middleware that sets the `:metabase-api-key` keyword on the request if a valid API Key can be found. We check the
request headers for `X-METABASE-APIKEY` and if it's not found then then no keyword is bound to the request."
[handler]
(fn [request respond raise]
(handler (wrap-api-key* request) respond raise)))
(defn enforce-api-key
"Middleware that enforces validation of the client via API Key, cancelling the request processing if the check fails.
Validation is handled by first checking for the presence of the `:metabase-api-key` on the request. If the api key
is available then we validate it by checking it against the configured `:mb-api-key` value set in our global config.
If the request `:metabase-api-key` matches the configured `:mb-api-key` value then the request continues, otherwise
we reject the request and return a 403 Forbidden response."
[handler]
(fn [{:keys [metabase-api-key], :as request} respond raise]
(if (= (config/config-str :mb-api-key) metabase-api-key)
(handler request respond raise)
;; default response is 403
(respond middleware.u/response-forbidden))))
(ns metabase.middleware.exceptions
"Ring middleware for handling Exceptions thrown in API request handler functions."
(:require [clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.tools.logging :as log]
[metabase.middleware.security :as middleware.security]
[metabase.util :as u]
[metabase.util.i18n :as ui18n :refer [trs]])
(:import java.sql.SQLException))
(defn genericize-exceptions
"Catch any exceptions thrown in the request handler body and rethrow a generic 400 exception instead. This minimizes
information available to bad actors when exceptions occur on public endpoints."
[handler]
(fn [request respond _]
(let [raise (fn [e]
(log/warn e (trs "Exception in API call"))
(respond {:status 400, :body "An error occurred."}))]
(try
(handler request respond raise)
(catch Throwable e
(raise e))))))
(defn message-only-exceptions
"Catch any exceptions thrown in the request handler body and rethrow a 400 exception that only has the message from
the original instead (i.e., don't rethrow the original stacktrace). This reduces the information available to bad
actors but still provides some information that will prove useful in debugging errors."
[handler]
(fn [request respond _]
(let [raise (fn [^Throwable e]
(respond {:status 400, :body (.getMessage e)}))]
(try
(handler request respond raise)
(catch Throwable e
(raise e))))))
(defn- api-exception-response
"Convert an exception from an API endpoint into an appropriate HTTP response."
[^Throwable e]
(let [{:keys [status-code], :as info}
(ex-data e)
other-info
(dissoc info :status-code :schema :type)
message
(.getMessage e)
body
(cond
;; Exceptions that include a status code *and* other info are things like
;; Field validation exceptions. Return those as is
(and status-code
(seq other-info))
(ui18n/localized-strings->strings other-info)
;; If status code was specified but other data wasn't, it's something like a
;; 404. Return message as the (plain-text) body.
status-code
(str message)
;; Otherwise it's a 500. Return a body that includes exception & filtered
;; stacktrace for debugging purposes
:else
(let [stacktrace (u/filtered-stacktrace e)]
(merge
(assoc other-info
:message message
:type (class e)
:stacktrace stacktrace)
(when (instance? SQLException e)
{:sql-exception-chain
(str/split (with-out-str (jdbc/print-sql-exception-chain e))
#"\s*\n\s*")}))))]
{:status (or status-code 500)
:headers (middleware.security/security-headers)
:body body}))
(defn catch-api-exceptions
"Middleware that catches API Exceptions and returns them in our normal-style format rather than the Jetty 500
Stacktrace page, which is not so useful for our frontend."
[handler]
(fn [request respond raise]
(handler
request
respond
(comp respond api-exception-response))))
(defn catch-uncaught-exceptions
"Middleware that catches any unexpected Exceptions that reroutes them thru `raise` where they can be handled
appropriately."
[handler]
(fn [request response raise]
(try
(handler request response raise)
(catch Throwable e
(raise e)))))
(ns metabase.middleware.json
"Middleware related to parsing JSON requests and generating JSON responses."
(:require [cheshire
[core :as json]
[generate :refer [add-encoder encode-str]]]
[metabase.util :as u]
[ring.middleware.json :as ring.json]
[ring.util
[io :as rui]
[response :as rr]])
(:import com.fasterxml.jackson.core.JsonGenerator
[java.io BufferedWriter OutputStream OutputStreamWriter]
[java.nio.charset Charset StandardCharsets]))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | JSON SERIALIZATION CONFIG |
;;; +----------------------------------------------------------------------------------------------------------------+
;; Tell the JSON middleware to use a date format that includes milliseconds (why?)
(def ^:private default-date-format "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
(intern 'cheshire.factory 'default-date-format default-date-format)
(intern 'cheshire.generate '*date-format* default-date-format)
;; ## Custom JSON encoders
;; Always fall back to `.toString` instead of barfing. In some cases we should be able to improve upon this behavior;
;; `.toString` may just return the Class and address, e.g. `some.Class@72a8b25e`
;; The following are known few classes where `.toString` is the optimal behavior:
;; * `org.postgresql.jdbc4.Jdbc4Array` (Postgres arrays)
;; * `org.bson.types.ObjectId` (Mongo BSON IDs)
;; * `java.sql.Date` (SQL Dates -- .toString returns YYYY-MM-DD)
(add-encoder Object encode-str)
(defn- encode-jdbc-clob [clob, ^JsonGenerator json-generator]
(.writeString json-generator (u/jdbc-clob->str clob)))
;; stringify JDBC clobs
(add-encoder org.h2.jdbc.JdbcClob encode-jdbc-clob) ; H2
(add-encoder org.postgresql.util.PGobject encode-jdbc-clob) ; Postgres
;; Binary arrays ("[B") -- hex-encode their first four bytes, e.g. "0xC42360D7"
(add-encoder (Class/forName "[B") (fn [byte-ar, ^JsonGenerator json-generator]
(.writeString json-generator ^String (apply str "0x" (for [b (take 4 byte-ar)]
(format "%02X" b))))))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Parsing JSON Requests |
;;; +----------------------------------------------------------------------------------------------------------------+
(defn wrap-json-body
"Middleware that parses JSON in the body of a request. (This is basically a copy of `ring-json-middleware`, but
tweaked to handle async-style calls.)"
;; TODO - we should really just fork ring-json-middleware and put these changes in the fork, or submit this as a PR
[handler]
(fn
[request respond raise]
(if-let [[valid? json] (#'ring.json/read-json request {:keywords? true})]
(if valid?
(handler (assoc request :body json) respond raise)
(respond ring.json/default-malformed-response))
(handler request respond raise))))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Streaming JSON Responses |
;;; +----------------------------------------------------------------------------------------------------------------+
(defn- streamed-json-response
"Write `RESPONSE-SEQ` to a PipedOutputStream as JSON, returning the connected PipedInputStream"
[response-seq opts]
(rui/piped-input-stream
(fn [^OutputStream output-stream]
(with-open [output-writer (OutputStreamWriter. ^OutputStream output-stream ^Charset StandardCharsets/UTF_8)
buffered-writer (BufferedWriter. output-writer)]
(json/generate-stream response-seq buffered-writer opts)))))
(defn- wrap-streamed-json-response* [opts response]
(if-let [json-response (and (coll? (:body response))
(update-in response [:body] streamed-json-response opts))]
(if (contains? (:headers json-response) "Content-Type")
json-response
(rr/content-type json-response "application/json; charset=utf-8"))
response))
(defn wrap-streamed-json-response
"Similar to ring.middleware/wrap-json-response in that it will serialize the response's body to JSON if it's a
collection. Rather than generating a string it will stream the response using a PipedOutputStream.
Accepts the following options (same as `wrap-json-response`):
:pretty - true if the JSON should be pretty-printed
:escape-non-ascii - true if non-ASCII characters should be escaped with \\u"
[handler & [{:as opts}]]
(fn [request respond raise]
(handler
request
(comp respond (partial wrap-streamed-json-response* opts))
raise)))
(ns metabase.middleware.log
"Ring middleware for logging API requests/responses."
(:require [clojure.tools.logging :as log]
[metabase
[server :as server]
[util :as u]]
[metabase.middleware.util :as middleware.u]
[metabase.util.date :as du]
[toucan.db :as db])
(:import org.eclipse.jetty.util.thread.QueuedThreadPool))
(def ^:private jetty-stats-coll
(juxt :min-threads :max-threads :busy-threads :idle-threads :queue-size))
(defn- jetty-stats []
(when-let [jetty-server (server/instance)]
(let [^QueuedThreadPool pool (.getThreadPool jetty-server)]
{:min-threads (.getMinThreads pool)
:max-threads (.getMaxThreads pool)
:busy-threads (.getBusyThreads pool)
:idle-threads (.getIdleThreads pool)
:queue-size (.getQueueSize pool)})))
(defn- log-response [{:keys [uri request-method]} {:keys [status body]} elapsed-time db-call-count]
(let [log-error #(log/error %) ; these are macros so we can't pass by value :sad:
log-debug #(log/debug %)
log-warn #(log/warn %)
;; stats? here is to avoid incurring the cost of collecting the Jetty stats and concatenating the extra
;; strings when they're just going to be ignored. This is automatically handled by the macro , but is bypassed
;; once we wrap it in a function
[error? color log-fn stats?] (cond
(>= status 500) [true 'red log-error false]
(= status 403) [true 'red log-warn false]
(>= status 400) [true 'red log-debug false]
:else [false 'green log-debug true])]
(log-fn (str (apply u/format-color color (str "%s %s %d (%s) (%d DB calls)."
(when stats?
" Jetty threads: %s/%s (%s busy, %s idle, %s queued)"))
(.toUpperCase (name request-method)) uri status elapsed-time db-call-count
(when stats?
(jetty-stats-coll (jetty-stats))))
;; only print body on error so we don't pollute our environment by over-logging
(when (and error?
(or (string? body) (coll? body)))
(str "\n" (u/pprint-to-str body)))))))
(defn- should-log-request? [{:keys [uri], :as request}]
;; don't log calls to /health or /util/logs because they clutter up the logs (especially the window in admin) with
;; useless lines
(and (middleware.u/api-call? request)
(not (#{"/api/health" "/api/util/logs"} uri))))
(defn log-api-call
"Logs info about request such as status code, number of DB calls, and time taken to complete."
[handler]
(fn [request respond raise]
(if-not (should-log-request? request)
;; non-API call or health or logs call, don't log it
(handler request respond raise)
;; API call, log info about it
(let [start-time (System/nanoTime)]
(db/with-call-counting [call-count]
(let [respond (fn [response]
(log-response request response (du/format-nanoseconds (- (System/nanoTime) start-time)) (call-count))
(respond response))]
(handler request respond raise)))))))
(ns metabase.middleware.misc
"Misc Ring middleware."
(:require [clojure.tools.logging :as log]
[metabase
[db :as mdb]
[public-settings :as public-settings]]
[metabase.middleware.util :as middleware.u]
[puppetlabs.i18n.core :as puppet-i18n]
[ring.middleware.gzip :as ring.gzip])
(:import [java.io File InputStream]))
(defn- add-content-type* [request response]
(update-in
response
[:headers "Content-Type"]
(fn [content-type]
(or content-type
(when (middleware.u/api-call? request)
(if (string? (:body response))
"text/plain"
"application/json; charset=utf-8"))))))
(defn add-content-type
"Add an appropriate Content-Type header to response if it doesn't already have one. Most responses should already
have one, so this is a fallback for ones that for one reason or another do not."
[handler]
(fn [request respond raise]
(handler
request
(comp respond (partial add-content-type* request))
raise)))
;;; ------------------------------------------------ SETTING SITE-URL ------------------------------------------------
;; It's important for us to know what the site URL is for things like returning links, etc. this is stored in the
;; `site-url` Setting; we can set it automatically by looking at the `Origin` or `Host` headers sent with a request.
;; Effectively the very first API request that gets sent to us (usually some sort of setup request) ends up setting
;; the (initial) value of `site-url`
(defn- maybe-set-site-url* [{{:strs [origin host] :as headers} :headers, :as request}]
(when (mdb/db-is-setup?)
(when-not (public-settings/site-url)
(when-let [site-url (or origin host)]
(log/info "Setting Metabase site URL to" site-url)
(public-settings/site-url site-url)))))
(defn maybe-set-site-url
"Middleware to set the `site-url` Setting if it's unset the first time a request is made."
[handler]
(fn [request respond raise]
(maybe-set-site-url* request)
(handler request respond raise)))
;;; ------------------------------------------------------ i18n ------------------------------------------------------
(defn bind-user-locale
"Middleware that binds locale info for the current User. (This is basically a copy of the
`puppetlabs.i18n.core/locale-negotiator`, but reworked to handle async-style requests as well.)"
;; TODO - We should really just fork puppet i18n and put these changes there, or PR
[handler]
(fn [request respond raise]
(let [headers (:headers request)
parsed (puppet-i18n/parse-http-accept-header (get headers "accept-language"))
wanted (mapv first parsed)
negotiated ^java.util.Locale (puppet-i18n/negotiate-locale wanted (puppet-i18n/available-locales))]
(puppet-i18n/with-user-locale negotiated
(handler request respond raise)))))
;;; ------------------------------------------------------ GZIP ------------------------------------------------------
(defn- wrap-gzip* [request {:keys [body status] :as resp}]
(if (and (= status 200)
(not (get-in resp [:headers "Content-Encoding"]))
(or
(and (string? body) (> (count body) 200))
(and (seq? body) @@#'ring.gzip/flushable-gzip?)
(instance? InputStream body)
(instance? File body)))
(let [accepts (get-in request [:headers "accept-encoding"] "")
match (re-find #"(gzip|\*)(;q=((0|1)(.\d+)?))?" accepts)]
(if (and match (not (contains? #{"0" "0.0" "0.00" "0.000"}
(match 3))))
(ring.gzip/gzipped-response resp)
resp))
resp))
(defn wrap-gzip
"Middleware that GZIPs response if client can handle it. This is basically the same as the version in
`ring.middleware.gzip`, but handles async requests as well."
;; TODO - we should really just fork the dep in question and put these changes there, or PR
[handler]
(fn [request respond raise]
(handler
request
(comp respond (partial wrap-gzip* request))
raise)))
(ns metabase.middleware.security
"Ring middleware for adding security-related headers to API responses."
(:require [clojure.string :as str]
[metabase.config :as config]
[metabase.middleware.util :as middleware.u]
[metabase.models.setting :refer [defsetting]]
[metabase.util
[date :as du]
[i18n :as ui18n :refer [tru]]]))
(defn- cache-prevention-headers
"Headers that tell browsers not to cache a response."
[]
{"Cache-Control" "max-age=0, no-cache, must-revalidate, proxy-revalidate"
"Expires" "Tue, 03 Jul 2001 06:00:00 GMT"
"Last-Modified" (du/format-date :rfc822)})
(defn- cache-far-future-headers
"Headers that tell browsers to cache a static resource for a long time."
[]
{"Cache-Control" "public, max-age=31536000"})
(def ^:private ^:const strict-transport-security-header
"Tell browsers to only access this resource over HTTPS for the next year (prevent MTM attacks). (This only applies if
the original request was HTTPS; if sent in response to an HTTP request, this is simply ignored)"
{"Strict-Transport-Security" "max-age=31536000"})
(def ^:private content-security-policy-header
"`Content-Security-Policy` header. See https://content-security-policy.com for more details."
{"Content-Security-Policy"
(str/join
(for [[k vs] {:default-src ["'none'"]
:script-src ["'unsafe-inline'"
"'unsafe-eval'"
"'self'"
"https://maps.google.com"
"https://apis.google.com"
"https://www.google-analytics.com" ; Safari requires the protocol
"https://*.googleapis.com"
"*.gstatic.com"
(when config/is-dev?
"localhost:8080")]
:child-src ["'self'"
;; TODO - double check that we actually need this for Google Auth
"https://accounts.google.com"]
:style-src ["'unsafe-inline'"
"'self'"
"fonts.googleapis.com"]
:font-src ["'self'"
"fonts.gstatic.com"
"themes.googleusercontent.com"
(when config/is-dev?
"localhost:8080")]
:img-src ["*"
"'self' data:"]
:connect-src ["'self'"
"metabase.us10.list-manage.com"
(when config/is-dev?
"localhost:8080 ws://localhost:8080")]}]
(format "%s %s; " (name k) (apply str (interpose " " vs)))))})
(defsetting ssl-certificate-public-key
(str (tru "Base-64 encoded public key for this site's SSL certificate.")
(tru "Specify this to enable HTTP Public Key Pinning.")
(tru "See {0} for more information." "http://mzl.la/1EnfqBf")))
;; TODO - it would be nice if we could make this a proper link in the UI; consider enabling markdown parsing
#_(defn- public-key-pins-header []
(when-let [k (ssl-certificate-public-key)]
{"Public-Key-Pins" (format "pin-sha256=\"base64==%s\"; max-age=31536000" k)}))
(defn security-headers
"Fetch a map of security headers that should be added to a response based on the passed options."
[& {:keys [allow-iframes? allow-cache?]
:or {allow-iframes? false, allow-cache? false}}]
(merge
(if allow-cache?
(cache-far-future-headers)
(cache-prevention-headers))
strict-transport-security-header
content-security-policy-header
#_(public-key-pins-header)
(when-not allow-iframes?
;; Tell browsers not to render our site as an iframe (prevent clickjacking)
{"X-Frame-Options" "DENY"})
{ ;; Tell browser to block suspected XSS attacks
"X-XSS-Protection" "1; mode=block"
;; Prevent Flash / PDF files from including content from site.
"X-Permitted-Cross-Domain-Policies" "none"
;; Tell browser not to use MIME sniffing to guess types of files -- protect against MIME type confusion attacks
"X-Content-Type-Options" "nosniff"}))
(defn- add-security-headers* [request response]
(update response :headers merge (security-headers
:allow-iframes? ((some-fn middleware.u/public? middleware.u/embed?) request)
:allow-cache? (middleware.u/cacheable? request))))
(defn add-security-headers
"Add HTTP security and cache-busting headers."
[handler]
(fn [request respond raise]
(handler
request
(comp respond (partial add-security-headers* request))
raise)))
(ns metabase.middleware.session
"Ring middleware related to session (binding current user and permissions)."
(:require [metabase
[config :as config]
[db :as mdb]]
[metabase.api.common :refer [*current-user* *current-user-id* *current-user-permissions-set* *is-superuser?*]]
[metabase.core.initialization-status :as init-status]
[metabase.models
[session :refer [Session]]
[user :as user :refer [User]]]
[toucan.db :as db]))
(def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID")
(def ^:private ^:const ^String metabase-session-header "x-metabase-session")
(defn- wrap-session-id* [{:keys [cookies headers] :as request}]
(if-let [session-id (or (get-in cookies [metabase-session-cookie :value])
(headers metabase-session-header))]
(assoc request :metabase-session-id session-id)
request))
(defn wrap-session-id
"Middleware that sets the `:metabase-session-id` keyword on the request if a session id can be found.
We first check the request :cookies for `metabase.SESSION_ID`, then if no cookie is found we look in the
http headers for `X-METABASE-SESSION`. If neither is found then then no keyword is bound to the request."
[handler]
(fn [request respond raise]
(handler (wrap-session-id* request) respond raise)))
(defn- session-with-id
"Fetch a session with SESSION-ID, and include the User ID and superuser status associated with it."
[session-id]
(db/select-one [Session :created_at :user_id (db/qualify User :is_superuser)]
(mdb/join [Session :user_id] [User :id])
(db/qualify User :is_active) true
(db/qualify Session :id) session-id))
(defn- session-age-ms [session]
(- (System/currentTimeMillis) (or (when-let [^java.util.Date created-at (:created_at session)]
(.getTime created-at))
0)))
(defn- session-age-minutes [session]
(quot (session-age-ms session) 60000))
(defn- session-expired? [session]
(> (session-age-minutes session)
(config/config-int :max-session-age)))
(defn- current-user-info-for-session
"Return User ID and superuser status for Session with SESSION-ID if it is valid and not expired."
[session-id]
(when (and session-id (init-status/complete?))
(when-let [session (session-with-id session-id)]
(when-not (session-expired? session)
{:metabase-user-id (:user_id session)
:is-superuser? (:is_superuser session)}))))
(defn- wrap-current-user-id* [{:keys [metabase-session-id], :as request}]
(merge request (current-user-info-for-session metabase-session-id)))
(defn wrap-current-user-id
"Add `:metabase-user-id` to the request if a valid session token was passed."
[handler]
(fn [request respond raise]
(handler (wrap-current-user-id* request) respond raise)))
(def ^:private current-user-fields
(into [User] user/admin-or-self-visible-columns))
(defn- find-user [user-id]
(db/select-one current-user-fields, :id user-id))
(defn- do-with-current-user [request f]
(if-let [current-user-id (:metabase-user-id request)]
(binding [*current-user-id* current-user-id
*is-superuser?* (:is-superuser? request)
*current-user* (delay (find-user current-user-id))
*current-user-permissions-set* (delay (user/permissions-set current-user-id))]
(f))
(f)))
(defmacro ^:private with-current-user [request & body]
`(do-with-current-user ~request (fn [] ~@body)))
(defn bind-current-user
"Middleware that binds `metabase.api.common/*current-user*`, `*current-user-id*`, `*is-superuser?*`, and
`*current-user-permissions-set*`.
* `*current-user-id*` int ID or nil of user associated with request
* `*current-user*` delay that returns current user (or nil) from DB
* `*is-superuser?*` Boolean stating whether current user is a superuser.
* `current-user-permissions-set*` delay that returns the set of permissions granted to the current user from DB"
[handler]
(fn [request respond raise]
(with-current-user request
(handler request respond raise))))
(ns metabase.middleware.util
"Ring middleware utility functions."
(:require [clojure.string :as str]))
(def response-unauthentic "Generic `401 (Unauthenticated)` Ring response map." {:status 401, :body "Unauthenticated"})
(def response-forbidden "Generic `403 (Forbidden)` Ring response map." {:status 403, :body "Forbidden"})
(defn api-call?
"Is this ring request an API call (does path start with `/api`)?"
[{:keys [^String uri]}]
(str/starts-with? uri "/api"))
(defn public?
"Is this ring request one that will serve `public.html`?"
[{:keys [uri]}]
(re-matches #"^/public/.*$" uri))
(defn embed?
"Is this ring request one that will serve `public.html`?"
[{:keys [uri]}]
(re-matches #"^/embed/.*$" uri))
(defn cacheable?
"Can the ring request be permanently cached?"
[{:keys [uri query-string]}]
;; match requests that are js/css and have a cache-busting query string
(and query-string (re-matches #"^/app/dist/.*\.(js|css)$" uri)))
......@@ -51,19 +51,24 @@
(fallback-localization *locale*)))
(fallback-localization *locale*)))
(defn- load-entrypoint-template [entrypoint-name embeddable? uri]
(load-template
(str "frontend_client/" entrypoint-name ".html")
{:bootstrap_json (escape-script (json/generate-string (public-settings/public-settings)))
:localization_json (escape-script (load-localization))
:uri (escape-script (json/generate-string uri))
:base_href (escape-script (json/generate-string (base-href)))
:embed_code (when embeddable? (embed/head uri))}))
(defn- entrypoint
"Repsonse that serves up an entrypoint into the Metabase application, e.g. `index.html`."
[entry embeddable? {:keys [uri]}]
(-> (if (init-status/complete?)
(load-template (str "frontend_client/" entry ".html")
{:bootstrap_json (escape-script (json/generate-string (public-settings/public-settings)))
:localization_json (escape-script (load-localization))
:uri (escape-script (json/generate-string uri))
:base_href (escape-script (json/generate-string (base-href)))
:embed_code (when embeddable? (embed/head uri))})
(load-file-at-path "frontend_client/init.html"))
resp/response
(resp/content-type "text/html; charset=utf-8")))
[entrypoint-name embeddable? {:keys [uri]} respond raise]
(respond
(-> (if (init-status/complete?)
(load-entrypoint-template entrypoint-name embeddable? uri)
(load-file-at-path "frontend_client/init.html"))
resp/response
(resp/content-type "text/html; charset=utf-8"))))
(def ^:private index (partial entrypoint "index" (not :embeddable)))
(def ^:private public (partial entrypoint "public" :embeddable))
......@@ -73,8 +78,8 @@
"Like `resp/redirect`, but passes along query string URL params as well. This is important because the public and
embedding routes below pass query params (such as template tags) as part of the URL."
[url]
(fn [{:keys [query-string]}]
(resp/redirect (str url "?" query-string))))
(fn [{:keys [query-string]} respond _]
(respond (resp/redirect (str url "?" query-string)))))
;; /public routes. /public/question/:uuid.:export-format redirects to /api/public/card/:uuid/query/:export-format
(defroutes ^:private public-routes
......
(ns metabase.server
(:require [clojure
[core :as core]
[string :as str]]
[clojure.tools.logging :as log]
[medley.core :as m]
[metabase
[config :as config]
[util :as u]]
[metabase.util.i18n :refer [trs]]
[ring.adapter.jetty :as ring-jetty])
(:import org.eclipse.jetty.server.Server))
(defn- jetty-ssl-config []
(m/filter-vals
some?
{:ssl-port (config/config-int :mb-jetty-ssl-port)
:keystore (config/config-str :mb-jetty-ssl-keystore)
:key-password (config/config-str :mb-jetty-ssl-keystore-password)
:truststore (config/config-str :mb-jetty-ssl-truststore)
:trust-password (config/config-str :mb-jetty-ssl-truststore-password)}))
(defn- jetty-config []
(cond-> (m/filter-vals
some?
{:async? true
:port (config/config-int :mb-jetty-port)
:host (config/config-str :mb-jetty-host)
:max-threads (config/config-int :mb-jetty-maxthreads)
:min-threads (config/config-int :mb-jetty-minthreads)
:max-queued (config/config-int :mb-jetty-maxqueued)
:max-idle-time (config/config-int :mb-jetty-maxidletime)})
(config/config-str :mb-jetty-daemon) (assoc :daemon? (config/config-bool :mb-jetty-daemon))
(config/config-str :mb-jetty-ssl) (-> (assoc :ssl? true)
(merge (jetty-ssl-config)))))
(defn- log-config [jetty-config]
(log/info (trs "Launching Embedded Jetty Webserver with config:")
"\n"
(u/pprint-to-str (m/filter-keys
#(not (str/includes? % "password"))
jetty-config))))
(defonce ^:private instance*
(atom nil))
(defn instance
"*THE* instance of our Jetty web server, if there currently is one."
^Server []
@instance*)
(defn- create-server
^Server [handler options]
(doto ^Server (#'ring-jetty/create-server options)
(.setHandler (#'ring-jetty/async-proxy-handler handler 0))))
(defn start-web-server!
"Start the embedded Jetty web server. Returns `:started` if a new server was started; `nil` if there was already a
running server."
[handler]
(when-not (instance)
;; NOTE: we always start jetty w/ join=false so we can start the server first then do init in the background
(let [config (jetty-config)
new-server (create-server handler config)]
(log-config config)
;; Only start the server if the newly created server becomes the official new server
;; Don't JOIN yet -- we're doing other init in the background; we can join later
(when (compare-and-set! instance* nil new-server)
(.start new-server)
:started))))
(defn stop-web-server!
"Stop the embedded Jetty web server. Returns `:stopped` if a server was stopped, `nil` if there was nothing to stop."
[]
(let [[^Server old-server] (reset-vals! instance* nil)]
(when old-server
(log/info (trs "Shutting Down Embedded Jetty Webserver"))
(.stop old-server)
:stopped)))
......@@ -325,7 +325,8 @@
[futur timeout-ms]
(let [result (deref futur timeout-ms ::timeout)]
(when (= result ::timeout)
(throw (TimeoutException. (format "Timed out after %d milliseconds." timeout-ms))))
(future-cancel futur)
(throw (TimeoutException. (str (tru "Timed out after {0} milliseconds." timeout-ms)))))
result))
(defmacro with-timeout
......
......@@ -4,8 +4,8 @@
[metabase
[email-test :as et]
[http-client :as http]
[middleware :as middleware]
[util :as u]]
[metabase.middleware.util :as middleware.u]
[metabase.models
[card :refer [Card]]
[collection :refer [Collection]]
......@@ -122,8 +122,8 @@
;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same
;; authentication test on every single individual endpoint
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "alert"))
(expect (get middleware/response-unauthentic :body) (http/client :put 401 "alert/13"))
(expect (get middleware.u/response-unauthentic :body) (http/client :get 401 "alert"))
(expect (get middleware.u/response-unauthentic :body) (http/client :put 401 "alert/13"))
;;; +----------------------------------------------------------------------------------------------------------------+
......
......@@ -7,10 +7,10 @@
[metabase
[email-test :as et]
[http-client :as http :refer :all]
[middleware :as middleware]
[util :as u]]
[metabase.api.card :as card-api]
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
[metabase.middleware.util :as middleware.u]
[metabase.models
[card :refer [Card]]
[card-favorite :refer [CardFavorite]]
......@@ -161,8 +161,8 @@
3 (card-returned? :database db card-2)))))
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "card"))
(expect (get middleware/response-unauthentic :body) (http/client :put 401 "card/13"))
(expect (get middleware.u/response-unauthentic :body) (http/client :get 401 "card"))
(expect (get middleware.u/response-unauthentic :body) (http/client :put 401 "card/13"))
;; Make sure `model_id` is required when `f` is :database
......
(ns metabase.api.common-test
(:require [clojure.core.async :as async]
[expectations :refer :all]
[expectations :refer [expect]]
[metabase.api.common :as api :refer :all]
[metabase.api.common.internal :refer :all]
[metabase.middleware :as mb-middleware]
[metabase.middleware
[exceptions :as mw.exceptions]
[misc :as mw.misc]
[security :as mw.security]]
[metabase.test.data :refer :all]
[metabase.util.schema :as su]))
......@@ -14,7 +17,7 @@
{:status 404
:body "Not found."
:headers {"Cache-Control" "max-age=0, no-cache, must-revalidate, proxy-revalidate"
"Content-Security-Policy" (-> @#'mb-middleware/content-security-policy-header vals first)
"Content-Security-Policy" (-> @#'mw.security/content-security-policy-header vals first)
"Content-Type" "text/plain"
"Expires" "Tue, 03 Jul 2001 06:00:00 GMT"
"Last-Modified" true ; this will be current date, so do update-in ... string?
......@@ -25,10 +28,14 @@
"X-XSS-Protection" "1; mode=block"}})
(defn- mock-api-fn [response-fn]
((-> response-fn
mb-middleware/catch-api-exceptions
mb-middleware/add-content-type)
{:uri "/api/my_fake_api_call"}))
((-> (fn [request respond _]
(respond (response-fn request)))
mw.exceptions/catch-uncaught-exceptions
mw.exceptions/catch-api-exceptions
mw.misc/add-content-type)
{:uri "/api/my_fake_api_call"}
identity
(fn [e] (throw e))))
(defn- my-mock-api-fn []
(mock-api-fn
......@@ -63,11 +70,14 @@
;; otherwise let-404 should bind as expected
(expect
{:user {:name "Cam"}}
((mb-middleware/catch-api-exceptions
(fn [_]
(let-404 [user {:name "Cam"}]
{:user user})))
nil))
((mw.exceptions/catch-api-exceptions
(fn [_ respond _]
(respond
(let-404 [user {:name "Cam"}]
{:user user}))))
nil
identity
(fn [e] (throw e))))
(defmacro ^:private expect-expansion
......
......@@ -4,11 +4,11 @@
[medley.core :as m]
[metabase
[http-client :as http]
[middleware :as middleware]
[util :as u]]
[metabase.api
[card-test :as card-api-test]
[dashboard :as dashboard-api]]
[metabase.middleware.util :as middleware.u]
[metabase.models
[card :refer [Card]]
[collection :refer [Collection]]
......@@ -97,8 +97,8 @@
;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same
;; authentication test on every single individual endpoint
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "dashboard"))
(expect (get middleware/response-unauthentic :body) (http/client :put 401 "dashboard/13"))
(expect (get middleware.u/response-unauthentic :body) (http/client :get 401 "dashboard"))
(expect (get middleware.u/response-unauthentic :body) (http/client :put 401 "dashboard/13"))
;;; +----------------------------------------------------------------------------------------------------------------+
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment