mirror of
https://github.com/penpot/penpot.git
synced 2026-11-12 04:50:42 +00:00
Bring iop-vc-preview up to develop b438676d6c. Conflicts: - migrations.clj: develop's 0157-migrate-legacy-file-data and 0158-del-forbidden-file-library-rels keep develop's order, and the branch's 0800-0808 follow them. No file names collide, so nothing is renumbered. - files_update.clj: develop's save-path rewrite wins (commit-by-commit rejection, page-scoped validation, the file carrying ::changes and ::rejected). The branch's op-log save is ported onto it instead of the earlier [file op-changes media-index] triple: process-changes-and-validate attaches the media fix-up remap as ::media-index, and update-file* builds the op-log vector from the accepted changes (remapped) behind the repair changes. A branch save persists only when develop's saved? holds, and its audit and log outcome is "rejected" when the first commit is refused. The tpoint threading, persist-branch-file!, the public invalidate-caches!, and the branch audit props are kept. - Repair and validation timing tests: take develop's calls, since process-changes-and-validate returns a file again. - binfile_test.clj: keep the branch import-overwrite test and develop's library relation tests. - dashboard.cljs, dashboard_test.cljs: union of requires and tests. - viewer.cljs: develop's optional page-id plus the branch's pr-id. - left_header.cljs: develop's raw-svg logo, recent-files href and project link, inside the branch's wrapper with the review badge, branch title and branch bar. The unused deprecated-icon require and nav-to-project go. - en.po, es.po: three-way merge by msgid. Every entry of both sides is kept, develop's removals of a duplicate errors.save-retrying and of integrations.mcp-server.title.beta stand, and labels.retry carries develop's references plus the branch's. Adaptations to develop: - email.clj, files_pull_request.clj: the review request email joins develop's template registry and goes through send-template, as the comment emails it mirrors now do. The registry coverage test lists it, and the rollback test of the pull request commands injects its failure into send-template, the function the create path now calls. - rpc_file_pull_request_test.clj: pr-flags is read at test time instead of at namespace load. The loaded value held the production defaults, which now enable the admin console (#12475), and it replaced th/state-init's test flags, so creating a profile called nitrate and failed with :nitrate-not-configured. AI-assisted-by: claude-opus-5-5 Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
357 lines
12 KiB
Clojure
357 lines
12 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]
|
|
[cuerdas.core :as str]
|
|
[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 [data (ex-data err)
|
|
headers (cond-> (::http/headers data)
|
|
(some? (:ttl data))
|
|
(assoc "retry-after" (str (:ttl data))))]
|
|
{::yres/status 429
|
|
::yres/headers headers
|
|
::yres/body {:type :rate-limit
|
|
:code (:code data)
|
|
:hint (:hint data)
|
|
:ttl (:ttl data)}}))
|
|
|
|
(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
|
|
(let [explain (ex/explain data)]
|
|
{::yres/status 400
|
|
::yres/body (-> data
|
|
(dissoc ::sm/explain)
|
|
(cond-> explain (assoc :explain explain)))}))))
|
|
|
|
(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 :conflict
|
|
[err _ _]
|
|
{::yres/status 409
|
|
::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"))
|
|
|
|
(defn- transient-sql-state?
|
|
"True for SQLSTATE codes of failures that pass on their own: lost or
|
|
refused connections, lack of resources, a server shutting down, and
|
|
serialization failures or deadlocks of concurrent transactions."
|
|
[state]
|
|
(and (string? state)
|
|
(or (str/starts-with? state "08")
|
|
(str/starts-with? state "53")
|
|
(contains? #{"57P01" "57P02" "57P03" "40001" "40P01"} state))))
|
|
|
|
(defn- unavailable
|
|
"Response for a failure that passes on its own: the request can be sent
|
|
again."
|
|
[code hint]
|
|
{::yres/status 503
|
|
::yres/body {:type :server-error
|
|
:code code
|
|
:hint hint}})
|
|
|
|
(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
|
|
(transient-sql-state? state)
|
|
(unavailable :database-unavailable "The database is not available right now")
|
|
|
|
(= 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.sql.SQLTransientException
|
|
[cause request _]
|
|
(binding [l/*context* (request->context request)]
|
|
(l/wrn :hint "transient database error" :cause cause)
|
|
(unavailable :database-unavailable "The database is not available right now")))
|
|
|
|
(defn- transient-io-exception?
|
|
"True for I/O failures that pass on their own: a lost, refused or timed
|
|
out connection, or a host name that did not resolve. A parse error or
|
|
data that cannot be read fails again on every attempt."
|
|
[cause]
|
|
(or (instance? java.net.SocketException cause)
|
|
(instance? java.io.InterruptedIOException cause)
|
|
(instance? java.net.UnknownHostException cause)
|
|
(instance? java.net.http.HttpTimeoutException cause)))
|
|
|
|
(defmethod handle-exception java.io.IOException
|
|
[cause request _]
|
|
(binding [l/*context* (request->context request)]
|
|
(l/wrn :hint "io exception" :cause cause)
|
|
(if (transient-io-exception? cause)
|
|
(unavailable :io-exception "An input/output operation failed")
|
|
{::yres/status 500
|
|
::yres/body {:type :server-error
|
|
:code :io-exception}})))
|
|
|
|
(defmethod handle-exception InterruptedException
|
|
[cause request _]
|
|
(binding [l/*context* (request->context request)]
|
|
(l/wrn :hint "interrupted request" :cause cause)
|
|
(unavailable :interrupted "The request was interrupted")))
|
|
|
|
(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)))
|