penpot/backend/src/app/http/errors.clj
María Valderrama 66876f16b2
Error page for instances without admin-console configuration (#11649)
*  Error page for instances without admin-console configuration

* 📎 Code review
2026-09-15 07:24:55 +02:00

294 lines
10 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns app.http.errors
"A errors handling for the http server."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as-alias sm]
[app.config :as cf]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.auth :as-alias auth]
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
[yetti.request :as yreq]
[yetti.response :as yres]))
(defn request->context
"Extracts error report relevant context data from request."
[request]
(let [{:keys [claims] :as auth} (get request ::http/auth-data)]
(-> (cf/logging-context)
(assoc :request/path (:path request))
(assoc :request/method (:method request))
(assoc :request/params (:params request))
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data (dissoc auth :token))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defn- strip-internal-fields
"Remove fields that leak internal implementation details from error
response data. Full context is preserved in server-side logs."
[data]
(dissoc data :state :path :context))
(defmulti handle-error
(fn [cause _ _]
(-> cause ex-data :type)))
(defmulti handle-exception
(fn [cause _ _]
(class cause)))
(defmethod handle-error :authentication
[err _ _]
{::yres/status 401
::yres/body (ex-data err)})
(defmethod handle-error :authorization
[err _ _]
{::yres/status 403
::yres/body (ex-data err)})
(defmethod handle-error :restriction
[err request _]
(let [data (ex-data err)
code (get data :code)
explain (ex/explain data)
data (-> data
(dissoc ::sm/explain)
(cond-> explain (assoc :explain explain)))]
(if (= code :method-not-allowed)
{::yres/status 405
::yres/body data}
(binding [l/*context* (request->context request)]
{::yres/status 400
::yres/body data}))))
(defmethod handle-error :rate-limit
[err _ _]
(let [headers (-> err ex-data ::http/headers)]
{::yres/status 429
::yres/headers headers}))
(defmethod handle-error :concurrency-limit
[err _ _]
(let [headers (-> err ex-data ::http/headers)]
{::yres/status 429
::yres/headers headers}))
(defmethod handle-error :validation
[err request parent-cause]
(let [{:keys [code] :as data} (ex-data err)]
(cond
(or (= code :spec-validation)
(= code :params-validation)
(= code :schema-validation)
(= code :data-validation))
(let [explain (ex/explain data)]
{::yres/status 400
::yres/body (-> data
(dissoc ::s/problems ::s/value ::s/spec ::sm/explain)
(cond-> explain (assoc :explain explain)))})
(= code :vern-conflict)
{::yres/status 409 ;; 409 - Conflict
::yres/body data}
(= code :request-body-too-large)
{::yres/status 413 ::yres/body data}
(= code :invalid-image)
(binding [l/*context* (request->context request)]
(let [cause (or parent-cause err)]
(l/warn :hint "image process error" :cause cause)
{::yres/status 400 ::yres/body data}))
:else
{::yres/status 400 ::yres/body data})))
(defmethod handle-error :assertion
[error request parent-cause]
(binding [l/*context* (request->context request)]
(let [{:keys [code] :as data} (ex-data error)
cause (or parent-cause error)]
(cond
(= code :data-validation)
(let [explain (ex/explain data)]
(l/error :hint "data assertion error" :cause cause)
{::yres/status 500
::yres/body (-> data
(dissoc ::sm/explain)
(cond-> explain (assoc :explain explain))
(assoc :type :server-error)
(assoc :code :assertion))})
(= code :spec-validation)
(let [explain (ex/explain data)]
(l/error :hint "spec assertion error" :cause cause)
{::yres/status 500
::yres/body (-> data
(dissoc ::s/problems ::s/value ::s/spec)
(cond-> explain (assoc :explain explain))
(assoc :type :server-error)
(assoc :code :assertion))})
:else
(do
(l/error :hint "assertion error" :cause cause)
{::yres/status 500
::yres/body (-> data
(strip-internal-fields)
(assoc :type :server-error)
(assoc :code :assertion))})))))
(defmethod handle-error :not-found
[err _ _]
{::yres/status 404
::yres/body (ex-data err)})
(defmethod handle-error :nitrate-unavailable
[err request _]
(binding [l/*context* (request->context request)]
(l/warn :hint "nitrate is unreachable; blocking request" :cause err)
;; Do not leak Nitrate's internal URL/status to the client; the
;; full context is already logged above for operators.
{::yres/status 503
::yres/body {:type :nitrate-unavailable}}))
(defmethod handle-error :nitrate-not-configured
[err request _]
(binding [l/*context* (request->context request)]
(l/warn :hint "nitrate is not configured; blocking request" :cause err)
{::yres/status 503
::yres/body {:type :nitrate-not-configured}}))
(defmethod handle-error :internal
[error request parent-cause]
(binding [l/*context* (request->context request)]
(let [cause (or parent-cause error)
data (ex-data error)]
(l/error :hint "internal error" :cause cause)
{::yres/status 500
::yres/body (-> data
(strip-internal-fields)
(assoc :type :server-error)
(update :code #(or % :unhandled)))})))
(defmethod handle-error :default
[error request parent-cause]
(let [edata (ex-data error)]
;; This is a special case for the idle-in-transaction error;
;; when it happens, the connection is automatically closed and
;; next-jdbc combines the two errors in a single ex-info. We
;; only need the :handling error, because the :rollback error
;; will be always "connection closed".
(if (and (ex/exception? (:rollback edata))
(ex/exception? (:handling edata)))
(handle-exception (:handling edata) request error)
(handle-exception error request parent-cause))))
(defn- pgsql-state->message
"Map PostgreSQL SQLSTATE codes to safe, client-facing messages.
Returns a user-friendly string that conveys the nature of the error
without exposing table names, constraint names, or other internals."
[state]
(case state
"23505" "A conflicting entry already exists"
"23503" "The referenced item does not exist"
"23502" "A required field is missing"
"23514" "The value violates a data integrity constraint"
"57014" "The operation took too long and was cancelled"
"25P03" "The transaction was idle too long and was cancelled"
"A database error occurred"))
(defmethod handle-exception org.postgresql.util.PSQLException
[error request parent-cause]
(let [state (.getSQLState ^java.sql.SQLException error)
cause (or parent-cause error)]
(binding [l/*context* (request->context request)]
(l/error :hint "postgresql error"
:cause cause)
(cond
(= state "57014")
{::yres/status 504
::yres/body {:type :server-error
:code :statement-timeout
:hint (pgsql-state->message state)}}
(= state "25P03")
{::yres/status 504
::yres/body {:type :server-error
:code :idle-in-transaction-timeout
:hint (pgsql-state->message state)}}
:else
{::yres/status 500
::yres/body {:type :server-error
:code :database-error
:hint (pgsql-state->message state)}}))))
(defmethod handle-exception :default
[error request parent-cause]
(let [edata (ex-data error)
cause (or parent-cause error)]
(cond
;; This means that exception is not a controlled exception.
(nil? edata)
(binding [l/*context* (request->context request)]
(l/error :hint "unexpected error" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :unexpected}})
:else
(binding [l/*context* (request->context request)]
(l/error :hint "unhandled error" :cause cause)
{::yres/status 500
::yres/body (-> edata
(strip-internal-fields)
(assoc :type :server-error)
(update :code #(or % :unhandled)))}))))
(defmethod handle-exception java.io.IOException
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception}}))
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]
(let [cause' (ex-cause cause)]
(if (ex/error? cause')
(handle-error cause' request cause)
(handle-exception cause' request cause))))
(defmethod handle-exception java.util.concurrent.ExecutionException
[cause request _]
(let [cause' (ex-cause cause)]
(if (ex/error? cause')
(handle-error cause' request cause)
(handle-exception cause' request cause))))
(defn handle
[cause request]
(if (ex/error? cause)
(handle-error cause request nil)
(handle-exception cause request nil)))
(defn handle'
[cause request]
(::yres/body (handle cause request)))