diff --git a/backend/deps.edn b/backend/deps.edn
index b57ee55ec..b75718a73 100644
--- a/backend/deps.edn
+++ b/backend/deps.edn
@@ -29,8 +29,6 @@
org.postgresql/postgresql {:mvn/version "42.4.0"}
com.zaxxer/HikariCP {:mvn/version "5.0.1"}
- funcool/datoteka {:mvn/version "3.0.64"}
-
buddy/buddy-hashers {:mvn/version "1.8.158"}
buddy/buddy-sign {:mvn/version "3.4.333"}
diff --git a/backend/resources/log4j2-devenv.xml b/backend/resources/log4j2-devenv.xml
index 7cc9abe3b..fe69b8197 100644
--- a/backend/resources/log4j2-devenv.xml
+++ b/backend/resources/log4j2-devenv.xml
@@ -30,6 +30,8 @@
+
+
diff --git a/backend/scripts/repl b/backend/scripts/repl
index d200ae3f3..984c87bdd 100755
--- a/backend/scripts/repl
+++ b/backend/scripts/repl
@@ -2,7 +2,7 @@
export PENPOT_HOST=devenv
export PENPOT_TENANT=dev
-export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies"
+export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-rpc-rate-limit enable-warn-rpc-rate-limits"
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot"
# export PENPOT_DATABASE_USERNAME="penpot"
@@ -16,6 +16,8 @@ export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enabl
# export PENPOT_LOGGERS_LOKI_URI="http://172.17.0.1:3100/loki/api/v1/push"
# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"
+export PENPOT_DEFAULT_RATE_LIMIT="default,window,10000/h"
+
# Initialize MINIO config
mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin
mc admin user add penpot-s3 penpot-devenv penpot-devenv
diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 5bb0119b2..0d6fd2e2a 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -20,6 +20,7 @@
[clojure.pprint :as pprint]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
+ [datoteka.fs :as fs]
[environ.core :refer [env]]
[integrant.core :as ig]))
@@ -83,16 +84,18 @@
;; a server prop key where initial project is stored.
:initial-project-skey "initial-project"})
+(s/def ::default-rpc-rlimit ::us/vector-of-strings)
+(s/def ::rpc-rlimit-config ::fs/path)
(s/def ::media-max-file-size ::us/integer)
-(s/def ::flags ::us/vec-of-valid-keywords)
+(s/def ::flags ::us/vector-of-keywords)
(s/def ::telemetry-enabled ::us/boolean)
(s/def ::audit-log-archive-uri ::us/string)
(s/def ::audit-log-gc-max-age ::dt/duration)
-(s/def ::admins ::us/set-of-non-empty-strings)
+(s/def ::admins ::us/set-of-strings)
(s/def ::file-change-snapshot-every ::us/integer)
(s/def ::file-change-snapshot-timeout ::dt/duration)
@@ -131,8 +134,8 @@
(s/def ::oidc-token-uri ::us/string)
(s/def ::oidc-auth-uri ::us/string)
(s/def ::oidc-user-uri ::us/string)
-(s/def ::oidc-scopes ::us/set-of-non-empty-strings)
-(s/def ::oidc-roles ::us/set-of-non-empty-strings)
+(s/def ::oidc-scopes ::us/set-of-strings)
+(s/def ::oidc-roles ::us/set-of-strings)
(s/def ::oidc-roles-attr ::us/keyword)
(s/def ::oidc-email-attr ::us/keyword)
(s/def ::oidc-name-attr ::us/keyword)
@@ -165,11 +168,14 @@
(s/def ::profile-complaint-threshold ::us/integer)
(s/def ::public-uri ::us/string)
(s/def ::redis-uri ::us/string)
-(s/def ::registration-domain-whitelist ::us/set-of-non-empty-strings)
-(s/def ::rlimit-font ::us/integer)
-(s/def ::rlimit-file-update ::us/integer)
-(s/def ::rlimit-image ::us/integer)
-(s/def ::rlimit-password ::us/integer)
+(s/def ::registration-domain-whitelist ::us/set-of-strings)
+
+
+
+(s/def ::rpc-semaphore-permits-font ::us/integer)
+(s/def ::rpc-semaphore-permits-file-update ::us/integer)
+(s/def ::rpc-semaphore-permits-image ::us/integer)
+(s/def ::rpc-semaphore-permits-password ::us/integer)
(s/def ::smtp-default-from ::us/string)
(s/def ::smtp-default-reply-to ::us/string)
(s/def ::smtp-host ::us/string)
@@ -217,6 +223,7 @@
::database-min-pool-size
::database-max-pool-size
::default-blob-version
+ ::default-rpc-rlimit
::error-report-webhook
::default-executor-parallelism
::blocking-executor-parallelism
@@ -272,10 +279,11 @@
::public-uri
::redis-uri
::registration-domain-whitelist
- ::rlimit-font
- ::rlimit-file-update
- ::rlimit-image
- ::rlimit-password
+ ::rpc-semaphore-permits-font
+ ::rpc-semaphore-permits-file-update
+ ::rpc-semaphore-permits-image
+ ::rpc-semaphore-permits-password
+ ::rpc-rlimit-config
::sentry-dsn
::sentry-debug
::sentry-attach-stack-trace
diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj
index 852cd1caf..7939b309b 100644
--- a/backend/src/app/http/errors.clj
+++ b/backend/src/app/http/errors.clj
@@ -10,6 +10,7 @@
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
+ [app.http :as-alias http]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[yetti.request :as yrq]
@@ -50,6 +51,11 @@
[err _]
(yrs/response 400 (ex-data err)))
+(defmethod handle-exception :rate-limit
+ [err _]
+ (let [headers (-> err ex-data ::http/headers)]
+ (yrs/response :status 429 :body "" :headers headers)))
+
(defmethod handle-exception :validation
[err _]
(let [{:keys [code] :as data} (ex-data err)]
diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj
index 6241b680e..20ec1b94b 100644
--- a/backend/src/app/http/session.clj
+++ b/backend/src/app/http/session.clj
@@ -69,7 +69,6 @@
{:id (:id data)})
(assoc data :updated-at updated-at))))
-
(delete-session [_ token]
(px/with-dispatch executor
(db/delete! pool :http-session {:id token})
diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj
index a29e5acd8..45636727a 100644
--- a/backend/src/app/main.clj
+++ b/backend/src/app/main.clj
@@ -64,10 +64,14 @@
:app.migrations/all
{:main (ig/ref :app.migrations/migrations)}
+ :app.redis/redis
+ {:uri (cf/get :redis-uri)
+ :metrics (ig/ref :app.metrics/metrics)}
+
:app.msgbus/msgbus
{:backend (cf/get :msgbus-backend :redis)
:executor (ig/ref [::default :app.worker/executor])
- :redis-uri (cf/get :redis-uri)}
+ :redis (ig/ref :app.redis/redis)}
:app.storage.tmp/cleaner
{:executor (ig/ref [::worker :app.worker/executor])
@@ -220,6 +224,7 @@
:storage (ig/ref :app.storage/storage)
:msgbus (ig/ref :app.msgbus/msgbus)
:public-uri (cf/get :public-uri)
+ :redis (ig/ref :app.redis/redis)
:audit (ig/ref :app.loggers.audit/collector)
:ldap (ig/ref :app.auth.ldap/provider)
:http-client (ig/ref :app.http/client)
@@ -290,9 +295,6 @@
{:pool (ig/ref :app.db/pool)
:key (cf/get :secret-key)}
- ;; :app.setup/keys
- ;; {:props (ig/ref :app.setup/props)}
-
:app.loggers.zmq/receiver
{:endpoint (cf/get :loggers-zmq-uri)}
diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj
index 99cbe15cd..796047f10 100644
--- a/backend/src/app/media.clj
+++ b/backend/src/app/media.clj
@@ -20,7 +20,7 @@
[clojure.java.shell :as sh]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
- [datoteka.core :as fs])
+ [datoteka.fs :as fs])
(:import
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation
diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj
index 4c5e10e19..8641e9f07 100644
--- a/backend/src/app/metrics.clj
+++ b/backend/src/app/metrics.clj
@@ -37,51 +37,51 @@
(def default-metrics
{:update-file-changes
- {:name "rpc_update_file_changes_total"
+ {:name "penpot_rpc_update_file_changes_total"
:help "A total number of changes submitted to update-file."
:type :counter}
:update-file-bytes-processed
- {:name "rpc_update_file_bytes_processed_total"
+ {:name "penpot_rpc_update_file_bytes_processed_total"
:help "A total number of bytes processed by update-file."
:type :counter}
:rpc-mutation-timing
- {:name "rpc_mutation_timing"
+ {:name "penpot_rpc_mutation_timing"
:help "RPC mutation method call timming."
:labels ["name"]
:type :histogram}
:rpc-command-timing
- {:name "rpc_command_timing"
+ {:name "penpot_rpc_command_timing"
:help "RPC command method call timming."
:labels ["name"]
:type :histogram}
:rpc-query-timing
- {:name "rpc_query_timing"
+ {:name "penpot_rpc_query_timing"
:help "RPC query method call timing."
:labels ["name"]
:type :histogram}
:websocket-active-connections
- {:name "websocket_active_connections"
+ {:name "penpot_websocket_active_connections"
:help "Active websocket connections gauge"
:type :gauge}
:websocket-messages-total
- {:name "websocket_message_total"
+ {:name "penpot_websocket_message_total"
:help "Counter of processed messages."
:labels ["op"]
:type :counter}
:websocket-session-timing
- {:name "websocket_session_timing"
+ {:name "penpot_websocket_session_timing"
:help "Websocket session timing (seconds)."
:type :summary}
:session-update-total
- {:name "http_session_update_total"
+ {:name "penpot_http_session_update_total"
:help "A counter of session update batch events."
:type :counter}
@@ -91,21 +91,27 @@
:labels ["name"]
:type :summary}
- :rlimit-queued-submissions
- {:name "penpot_rlimit_queued_submissions"
- :help "Current number of queued submissions on RLIMIT."
+ :redis-eval-timing
+ {:name "penpot_redis_eval_timing"
+ :help "Redis EVAL commands execution timings (ms)"
+ :labels ["name"]
+ :type :summary}
+
+ :rpc-semaphore-queued-submissions
+ {:name "penpot_rpc_semaphore_queued_submissions"
+ :help "Current number of queued submissions on RPC-SEMAPHORE."
:labels ["name"]
:type :gauge}
- :rlimit-used-permits
- {:name "penpot_rlimit_used_permits"
- :help "Current number of used permits on RLIMIT."
+ :rpc-semaphore-used-permits
+ {:name "penpot_rpc_semaphore_used_permits"
+ :help "Current number of used permits on RPC-SEMAPHORE."
:labels ["name"]
:type :gauge}
- :rlimit-acquires-total
- {:name "penpot_rlimit_acquires_total"
- :help "Total number of acquire operations on RLIMIT."
+ :rpc-semaphore-acquires-total
+ {:name "penpot_rpc_semaphore_acquires_total"
+ :help "Total number of acquire operations on RPC-SEMAPHORE."
:labels ["name"]
:type :counter}
@@ -147,6 +153,8 @@
:definitions definitions
:registry registry}))
+
+;; TODO: revisit
(s/def ::handler fn?)
(s/def ::registry #(instance? CollectorRegistry %))
(s/def ::metrics
diff --git a/backend/src/app/msgbus.clj b/backend/src/app/msgbus.clj
index e14bf9e12..b4c1a6a77 100644
--- a/backend/src/app/msgbus.clj
+++ b/backend/src/app/msgbus.clj
@@ -13,28 +13,14 @@
[app.common.spec :as us]
[app.common.transit :as t]
[app.config :as cfg]
+ [app.redis :as redis]
[app.util.async :as aa]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
- [promesa.core :as p])
- (:import
- io.lettuce.core.RedisClient
- io.lettuce.core.RedisURI
- io.lettuce.core.api.StatefulConnection
- io.lettuce.core.api.StatefulRedisConnection
- io.lettuce.core.api.async.RedisAsyncCommands
- io.lettuce.core.codec.ByteArrayCodec
- io.lettuce.core.codec.RedisCodec
- io.lettuce.core.codec.StringCodec
- io.lettuce.core.pubsub.RedisPubSubListener
- io.lettuce.core.pubsub.StatefulRedisPubSubConnection
- io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
- io.lettuce.core.resource.ClientResources
- io.lettuce.core.resource.DefaultClientResources
- java.time.Duration))
+ [promesa.core :as p]))
(set! *warn-on-reflection* true)
@@ -62,18 +48,14 @@
:timeout (dt/duration {:seconds 30})}
(d/without-nils cfg)))
-(s/def ::timeout ::dt/duration)
-(s/def ::redis-uri ::us/string)
(s/def ::buffer-size ::us/integer)
(defmethod ig/pre-init-spec ::msgbus [_]
- (s/keys :req-un [::buffer-size ::redis-uri ::timeout ::wrk/executor]))
+ (s/keys :req-un [::buffer-size ::redis/timeout ::redis/redis ::wrk/executor]))
(defmethod ig/init-key ::msgbus
- [_ {:keys [buffer-size redis-uri] :as cfg}]
- (l/info :hint "initialize msgbus"
- :buffer-size buffer-size
- :redis-uri redis-uri)
+ [_ {:keys [buffer-size] :as cfg}]
+ (l/info :hint "initialize msgbus" :buffer-size buffer-size)
(let [cmd-ch (a/chan buffer-size)
rcv-ch (a/chan (a/dropping-buffer buffer-size))
pub-ch (a/chan (a/dropping-buffer buffer-size) xform-prefix-topic)
@@ -106,33 +88,17 @@
;; --- IMPL
(defn- redis-connect
- [{:keys [redis-uri timeout] :as cfg}]
- (let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE)
-
- resources (.. (DefaultClientResources/builder)
- (ioThreadPoolSize 4)
- (computationThreadPoolSize 4)
- (build))
-
- uri (RedisURI/create redis-uri)
- rclient (RedisClient/create ^ClientResources resources ^RedisURI uri)
-
- pconn (.connect ^RedisClient rclient ^RedisCodec codec)
- sconn (.connectPubSub ^RedisClient rclient ^RedisCodec codec)]
-
- (.setTimeout ^StatefulRedisConnection pconn ^Duration timeout)
- (.setTimeout ^StatefulRedisPubSubConnection sconn ^Duration timeout)
-
+ [{:keys [timeout redis] :as cfg}]
+ (let [pconn (redis/connect redis :timeout timeout)
+ sconn (redis/connect redis :type :pubsub :timeout timeout)]
(-> cfg
- (assoc ::resources resources)
(assoc ::pconn pconn)
(assoc ::sconn sconn))))
(defn- redis-disconnect
- [{:keys [::pconn ::sconn ::resources] :as cfg}]
- (.. ^StatefulConnection pconn close)
- (.. ^StatefulConnection sconn close)
- (.shutdown ^ClientResources resources))
+ [{:keys [::pconn ::sconn] :as cfg}]
+ (redis/close! pconn)
+ (redis/close! sconn))
(defn- conj-subscription
"A low level function that is responsible to create on-demand
@@ -204,27 +170,18 @@
(defn- create-listener
[rcv-ch]
- (reify RedisPubSubListener
- (message [_ _pattern _topic _message])
- (message [_ topic message]
- ;; There are no back pressure, so we use a slidding
- ;; buffer for cases when the pubsub broker sends
- ;; more messages that we can process.
- (let [val {:topic topic :message (t/decode message)}]
- (when-not (a/offer! rcv-ch val)
- (l/warn :msg "dropping message on subscription loop"))))
- (psubscribed [_ _pattern _count])
- (punsubscribed [_ _pattern _count])
- (subscribed [_ _topic _count])
- (unsubscribed [_ _topic _count])))
+ (redis/pubsub-listener
+ :on-message (fn [_ topic message]
+ ;; There are no back pressure, so we use a slidding
+ ;; buffer for cases when the pubsub broker sends
+ ;; more messages that we can process.
+ (let [val {:topic topic :message (t/decode message)}]
+ (when-not (a/offer! rcv-ch val)
+ (l/warn :msg "dropping message on subscription loop"))))))
(defn start-io-loop
[{:keys [::sconn ::rcv-ch ::pub-ch ::state executor] :as cfg}]
-
- ;; Add a single listener to the pubsub connection
- (.addListener ^StatefulRedisPubSubConnection sconn
- ^RedisPubSubListener (create-listener rcv-ch))
-
+ (redis/add-listener! sconn (create-listener rcv-ch))
(letfn [(send-to-topic [topic message]
(a/go-loop [chans (seq (get-in @state [:topics topic]))
closed #{}]
@@ -270,11 +227,10 @@
intended to be used in core.async go blocks."
[{:keys [::pconn] :as cfg} {:keys [topic message]}]
(let [message (t/encode message)
- res (a/chan 1)
- pcomm (.async ^StatefulRedisConnection pconn)]
- (-> (.publish ^RedisAsyncCommands pcomm ^String topic ^bytes message)
+ res (a/chan 1)]
+ (-> (redis/publish! pconn topic message)
(p/finally (fn [_ cause]
- (when (and cause (.isOpen ^StatefulConnection pconn))
+ (when (and cause (redis/open? pconn))
(a/offer! res cause))
(a/close! res))))
res))
@@ -283,14 +239,10 @@
"Create redis subscription. Blocking operation, intended to be used
inside an agent."
[{:keys [::sconn] :as cfg} topic]
- (let [topic (into-array String [topic])
- scomm (.sync ^StatefulRedisPubSubConnection sconn)]
- (.subscribe ^RedisPubSubCommands scomm topic)))
+ (redis/subscribe! sconn topic))
(defn redis-unsub
"Removes redis subscription. Blocking operation, intended to be used
inside an agent."
[{:keys [::sconn] :as cfg} topic]
- (let [topic (into-array String [topic])
- scomm (.sync ^StatefulRedisPubSubConnection sconn)]
- (.unsubscribe ^RedisPubSubCommands scomm topic)))
+ (redis/unsubscribe! sconn topic))
diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj
new file mode 100644
index 000000000..4ec815e24
--- /dev/null
+++ b/backend/src/app/redis.clj
@@ -0,0 +1,319 @@
+;; 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) UXBOX Labs SL
+
+(ns app.redis
+ "The msgbus abstraction implemented using redis as underlying backend."
+ (:require
+ [app.common.data :as d]
+ [app.common.logging :as l]
+ [app.common.spec :as us]
+ [app.metrics :as mtx]
+ [app.redis.script :as-alias rscript]
+ [app.util.time :as dt]
+ [clojure.core :as c]
+ [clojure.java.io :as io]
+ [clojure.spec.alpha :as s]
+ [cuerdas.core :as str]
+ [integrant.core :as ig]
+ [promesa.core :as p])
+ (:import
+ clojure.lang.IDeref
+ io.lettuce.core.RedisClient
+ io.lettuce.core.RedisURI
+ io.lettuce.core.ScriptOutputType
+ io.lettuce.core.api.StatefulConnection
+ io.lettuce.core.api.StatefulRedisConnection
+ io.lettuce.core.api.async.RedisAsyncCommands
+ io.lettuce.core.api.async.RedisScriptingAsyncCommands
+ io.lettuce.core.codec.ByteArrayCodec
+ io.lettuce.core.codec.RedisCodec
+ io.lettuce.core.codec.StringCodec
+ io.lettuce.core.pubsub.RedisPubSubListener
+ io.lettuce.core.pubsub.StatefulRedisPubSubConnection
+ io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
+ io.lettuce.core.resource.ClientResources
+ io.lettuce.core.resource.DefaultClientResources
+ io.netty.util.HashedWheelTimer
+ io.netty.util.Timer
+ java.lang.AutoCloseable
+ java.time.Duration))
+
+(set! *warn-on-reflection* true)
+
+(declare initialize-resources)
+(declare shutdown-resources)
+(declare connect)
+(declare close!)
+
+(s/def ::timer
+ #(instance? Timer %))
+
+(s/def ::connection
+ #(or (instance? StatefulRedisConnection %)
+ (and (instance? IDeref %)
+ (instance? StatefulRedisConnection (deref %)))))
+
+(s/def ::pubsub-connection
+ #(or (instance? StatefulRedisPubSubConnection %)
+ (and (instance? IDeref %)
+ (instance? StatefulRedisPubSubConnection (deref %)))))
+
+(s/def ::redis-uri
+ #(instance? RedisURI %))
+
+(s/def ::resources
+ #(instance? ClientResources %))
+
+(s/def ::pubsub-listener
+ #(instance? RedisPubSubListener %))
+
+(s/def ::uri ::us/not-empty-string)
+(s/def ::timeout ::dt/duration)
+(s/def ::connect? ::us/boolean)
+(s/def ::io-threads ::us/integer)
+(s/def ::worker-threads ::us/integer)
+
+(s/def ::redis
+ (s/keys :req [::resources ::redis-uri ::timer ::mtx/metrics]
+ :opt [::connection]))
+
+(defmethod ig/pre-init-spec ::redis [_]
+ (s/keys :req-un [::uri ::mtx/metrics]
+ :opt-un [::timeout
+ ::connect?
+ ::io-threads
+ ::worker-threads]))
+
+(defmethod ig/prep-key ::redis
+ [_ cfg]
+ (let [runtime (Runtime/getRuntime)
+ cpus (.availableProcessors ^Runtime runtime)]
+ (merge {:timeout (dt/duration 5000)
+ :io-threads (max 3 cpus)
+ :worker-threads (max 3 cpus)}
+ (d/without-nils cfg))))
+
+(defmethod ig/init-key ::redis
+ [_ {:keys [connect?] :as cfg}]
+ (let [cfg (initialize-resources cfg)]
+ (cond-> cfg
+ connect? (assoc ::connection (connect cfg)))))
+
+(defmethod ig/halt-key! ::redis
+ [_ state]
+ (shutdown-resources state))
+
+(def default-codec
+ (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE))
+
+(def string-codec
+ (RedisCodec/of StringCodec/UTF8 StringCodec/UTF8))
+
+(defn- initialize-resources
+ "Initialize redis connection resources"
+ [{:keys [uri io-threads worker-threads connect? metrics] :as cfg}]
+ (l/info :hint "initialize redis resources"
+ :uri uri
+ :io-threads io-threads
+ :worker-threads worker-threads
+ :connect? connect?)
+
+ (let [timer (HashedWheelTimer.)
+ resources (.. (DefaultClientResources/builder)
+ (ioThreadPoolSize ^long io-threads)
+ (computationThreadPoolSize ^long worker-threads)
+ (timer ^Timer timer)
+ (build))
+
+ redis-uri (RedisURI/create ^String uri)]
+
+ (-> cfg
+ (assoc ::mtx/metrics metrics)
+ (assoc ::cache (atom {}))
+ (assoc ::timer timer)
+ (assoc ::redis-uri redis-uri)
+ (assoc ::resources resources))))
+
+(defn- shutdown-resources
+ [{:keys [::resources ::cache ::timer]}]
+ (run! close! (vals @cache))
+ (when resources
+ (.shutdown ^ClientResources resources))
+ (when timer
+ (.stop ^Timer timer)))
+
+(defn connect
+ [{:keys [::resources ::redis-uri] :as cfg}
+ & {:keys [timeout codec type] :or {codec default-codec type :default}}]
+
+ (us/assert! ::resources resources)
+
+ (let [client (RedisClient/create ^ClientResources resources ^RedisURI redis-uri)
+ timeout (or timeout (:timeout cfg))
+ conn (case type
+ :default (.connect ^RedisClient client ^RedisCodec codec)
+ :pubsub (.connectPubSub ^RedisClient client ^RedisCodec codec))]
+
+ (.setTimeout ^StatefulConnection conn ^Duration timeout)
+
+ (reify
+ IDeref
+ (deref [_] conn)
+
+ AutoCloseable
+ (close [_]
+ (.close ^StatefulConnection conn)
+ (.shutdown ^RedisClient client)))))
+
+(defn get-or-connect
+ [{:keys [::cache] :as state} key options]
+ (assoc state ::connection
+ (or (get @cache key)
+ (-> (swap! cache (fn [cache]
+ (when-let [prev (get cache key)]
+ (close! prev))
+ (assoc cache key (connect state options))))
+ (get key)))))
+
+(defn add-listener!
+ [conn listener]
+ (us/assert! ::pubsub-connection @conn)
+ (us/assert! ::pubsub-listener listener)
+
+ (.addListener ^StatefulRedisPubSubConnection @conn
+ ^RedisPubSubListener listener)
+ conn)
+
+(defn publish!
+ [conn topic message]
+ (us/assert! ::us/string topic)
+ (us/assert! ::us/bytes message)
+ (us/assert! ::connection @conn)
+
+ (let [pcomm (.async ^StatefulRedisConnection @conn)]
+ (.publish ^RedisAsyncCommands pcomm ^String topic ^bytes message)))
+
+(defn subscribe!
+ "Blocking operation, intended to be used on a worker/agent thread."
+ [conn & topics]
+ (us/assert! ::pubsub-connection @conn)
+ (let [topics (into-array String (map str topics))
+ cmd (.sync ^StatefulRedisPubSubConnection @conn)]
+ (.subscribe ^RedisPubSubCommands cmd topics)))
+
+(defn unsubscribe!
+ "Blocking operation, intended to be used on a worker/agent thread."
+ [conn & topics]
+ (us/assert! ::pubsub-connection @conn)
+ (let [topics (into-array String (map str topics))
+ cmd (.sync ^StatefulRedisPubSubConnection @conn)]
+ (.unsubscribe ^RedisPubSubCommands cmd topics)))
+
+(defn open?
+ [conn]
+ (.isOpen ^StatefulConnection @conn))
+
+(defn pubsub-listener
+ [& {:keys [on-message on-subscribe on-unsubscribe]}]
+ (reify RedisPubSubListener
+ (message [_ pattern topic message]
+ (when on-message
+ (on-message pattern topic message)))
+
+ (message [_ topic message]
+ (when on-message
+ (on-message nil topic message)))
+
+ (psubscribed [_ pattern count]
+ (when on-subscribe
+ (on-subscribe pattern nil count)))
+
+ (punsubscribed [_ pattern count]
+ (when on-unsubscribe
+ (on-unsubscribe pattern nil count)))
+
+ (subscribed [_ topic count]
+ (when on-subscribe
+ (on-subscribe nil topic count)))
+
+ (unsubscribed [_ topic count]
+ (when on-unsubscribe
+ (on-unsubscribe nil topic count)))))
+
+(defn close!
+ [o]
+ (.close ^AutoCloseable o))
+
+(def ^:private scripts-cache (atom {}))
+(def noop-fn (constantly nil))
+
+(s/def ::rscript/name qualified-keyword?)
+(s/def ::rscript/path ::us/not-empty-string)
+(s/def ::rscript/keys (s/every any? :kind vector?))
+(s/def ::rscript/vals (s/every any? :kind vector?))
+
+(s/def ::rscript/script
+ (s/keys :req [::rscript/name
+ ::rscript/path]
+ :opt [::rscript/keys
+ ::rscript/vals]))
+
+(defn eval!
+ [{:keys [::mtx/metrics] :as state} script]
+ (us/assert! ::rscript/script script)
+ (us/assert! ::redis state)
+
+ (let [rconn (-> state ::connection deref)
+ cmd (.async ^StatefulRedisConnection rconn)
+ keys (into-array String (map str (::rscript/keys script)))
+ vals (into-array String (map str (::rscript/vals script)))
+ sname (::rscript/name script)]
+
+ (letfn [(on-error [cause]
+ (if (instance? io.lettuce.core.RedisNoScriptException cause)
+ (do
+ (l/error :hint "no script found" :name sname :cause cause)
+ (-> (load-script)
+ (p/then eval-script)))
+ (if-let [on-error (::rscript/on-error script)]
+ (on-error cause)
+ (p/rejected cause))))
+
+ (eval-script [sha]
+ (let [start-ts (System/nanoTime)]
+ (-> (.evalsha ^RedisScriptingAsyncCommands cmd
+ ^String sha
+ ^ScriptOutputType ScriptOutputType/MULTI
+ ^"[Ljava.lang.String;" keys
+ ^"[Ljava.lang.String;" vals)
+ (p/then (fn [result]
+ (let [elapsed (dt/duration {:nanos (- (System/nanoTime) start-ts)})]
+ (mtx/run! metrics {:id :redis-eval-timing
+ :labels [(name sname)]
+ :val (inst-ms elapsed)})
+ (l/trace :hint "eval script"
+ :name (name sname)
+ :sha sha
+ :params (str/join "," (::rscript/vals script))
+ :elapsed (dt/format-duration elapsed))
+ result)))
+ (p/catch on-error))))
+
+ (read-script []
+ (-> script ::rscript/path io/resource slurp))
+
+ (load-script []
+ (l/trace :hint "load script" :name sname)
+ (-> (.scriptLoad ^RedisScriptingAsyncCommands cmd
+ ^String (read-script))
+ (p/then (fn [sha]
+ (swap! scripts-cache assoc sname sha)
+ sha))))]
+
+ (if-let [sha (get @scripts-cache sname)]
+ (eval-script sha)
+ (-> (load-script)
+ (p/then eval-script))))))
diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj
index 548dda581..162d48444 100644
--- a/backend/src/app/rpc.clj
+++ b/backend/src/app/rpc.clj
@@ -10,10 +10,12 @@
[app.common.logging :as l]
[app.common.spec :as us]
[app.db :as db]
+ [app.http :as-alias http]
[app.loggers.audit :as audit]
[app.metrics :as mtx]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.util.async :as async]
[app.util.services :as sv]
[app.worker :as wrk]
@@ -39,81 +41,72 @@
(ex/ignoring (hook-fn)))
response)
+(defn- handle-response
+ [request result]
+ (let [mdata (meta result)]
+ (p/-> (yrs/response 200 result (::http/headers mdata {}))
+ (handle-response-transformation request mdata)
+ (handle-before-comple-hook mdata))))
+
(defn- rpc-query-handler
"Ring handler that dispatches query requests and convert between
internal async flow into ring async flow."
[methods {:keys [profile-id session-id params] :as request} respond raise]
- (letfn [(handle-response [result]
- (let [mdata (meta result)]
- (-> (yrs/response 200 result)
- (handle-response-transformation request mdata))))]
+ (let [type (keyword (:type params))
+ data (into {::http/request request} params)
+ data (if profile-id
+ (assoc data :profile-id profile-id ::session-id session-id)
+ (dissoc data :profile-id))
+ method (get methods type default-handler)]
- (let [type (keyword (:type params))
- data (into {::request request} params)
- data (if profile-id
- (assoc data :profile-id profile-id ::session-id session-id)
- (dissoc data :profile-id))
- method (get methods type default-handler)]
-
- (-> (method data)
- (p/then handle-response)
- (p/then respond)
- (p/catch (fn [cause]
- (let [context {:profile-id profile-id}]
- (raise (ex/wrap-with-context cause context)))))))))
+ (-> (method data)
+ (p/then (partial handle-response request))
+ (p/then respond)
+ (p/catch (fn [cause]
+ (let [context {:profile-id profile-id}]
+ (raise (ex/wrap-with-context cause context))))))))
(defn- rpc-mutation-handler
"Ring handler that dispatches mutation requests and convert between
internal async flow into ring async flow."
[methods {:keys [profile-id session-id params] :as request} respond raise]
- (letfn [(handle-response [result]
- (let [mdata (meta result)]
- (p/-> (yrs/response 200 result)
- (handle-response-transformation request mdata)
- (handle-before-comple-hook mdata))))]
+ (let [type (keyword (:type params))
+ data (into {::request request} params)
+ data (if profile-id
+ (assoc data :profile-id profile-id ::session-id session-id)
+ (dissoc data :profile-id))
- (let [type (keyword (:type params))
- data (into {::request request} params)
- data (if profile-id
- (assoc data :profile-id profile-id ::session-id session-id)
- (dissoc data :profile-id))
-
- method (get methods type default-handler)]
- (-> (method data)
- (p/then handle-response)
- (p/then respond)
- (p/catch (fn [cause]
- (let [context {:profile-id profile-id}]
- (raise (ex/wrap-with-context cause context)))))))))
+ method (get methods type default-handler)]
+ (-> (method data)
+ (p/then (partial handle-response request))
+ (p/then respond)
+ (p/catch (fn [cause]
+ (let [context {:profile-id profile-id}]
+ (raise (ex/wrap-with-context cause context))))))))
(defn- rpc-command-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods {:keys [profile-id session-id params] :as request} respond raise]
- (letfn [(handle-response [result]
- (let [mdata (meta result)]
- (p/-> (yrs/response 200 result)
- (handle-response-transformation request mdata)
- (handle-before-comple-hook mdata))))]
+ (let [cmd (keyword (:command params))
+ data (into {::request request} params)
+ data (if profile-id
+ (assoc data :profile-id profile-id ::session-id session-id)
+ (dissoc data :profile-id))
- (let [cmd (keyword (:command params))
- data (into {::request request} params)
- data (if profile-id
- (assoc data :profile-id profile-id ::session-id session-id)
- (dissoc data :profile-id))
-
- method (get methods cmd default-handler)]
- (-> (method data)
- (p/then handle-response)
- (p/then respond)
- (p/catch (fn [cause]
- (let [context {:profile-id profile-id}]
- (raise (ex/wrap-with-context cause context)))))))))
+ method (get methods cmd default-handler)]
+ (-> (method data)
+ (p/then (partial handle-response request))
+ (p/then respond)
+ (p/catch (fn [cause]
+ (let [context {:profile-id profile-id}]
+ (raise (ex/wrap-with-context cause context))))))))
(defn- wrap-metrics
"Wrap service method with metrics measurement."
[{:keys [metrics ::metrics-id]} f mdata]
(let [labels (into-array String [(::sv/name mdata)])]
+
(fn [cfg params]
(let [start (System/nanoTime)]
(p/finally
@@ -177,7 +170,8 @@
[cfg f mdata]
(let [f (as-> f $
(wrap-dispatch cfg $ mdata)
- (rlimit/wrap-rlimit cfg $ mdata)
+ (rsem/wrap cfg $ mdata)
+ (rlimit/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-metrics cfg $ mdata)
diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj
index 625bf6cf5..d052c6b20 100644
--- a/backend/src/app/rpc/commands/auth.clj
+++ b/backend/src/app/rpc/commands/auth.clj
@@ -16,7 +16,7 @@
[app.rpc.doc :as-alias doc]
[app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
@@ -136,7 +136,7 @@
(sv/defmethod ::login-with-password
"Performs authentication using penpot password."
{:auth false
- ::rlimit/permits (cf/get :rlimit-password)
+ ::rsem/permits (cf/get :rpc-semaphore-permits-password)
::doc/added "1.15"}
[cfg params]
(login-with-password cfg params))
@@ -177,7 +177,7 @@
(sv/defmethod ::recover-profile
{:auth false
- ::rlimit/permits (cf/get :rlimit-password)
+ ::rsem/permits (cf/get :rpc-semaphore-permits-password)
::doc/added "1.15"}
[cfg params]
(recover-profile cfg params))
@@ -368,7 +368,7 @@
(sv/defmethod ::register-profile
{:auth false
- ::rlimit/permits (cf/get :rlimit-password)
+ ::rsem/permits (cf/get :rpc-semaphore-permits-password)
::doc/added "1.15"}
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
diff --git a/backend/src/app/rpc/mutations/files.clj b/backend/src/app/rpc/mutations/files.clj
index ff45fb6c6..305ac8e2c 100644
--- a/backend/src/app/rpc/mutations/files.clj
+++ b/backend/src/app/rpc/mutations/files.clj
@@ -20,7 +20,7 @@
[app.rpc.permissions :as perms]
[app.rpc.queries.files :as files]
[app.rpc.queries.projects :as proj]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.storage.impl :as simpl]
[app.util.blob :as blob]
[app.util.services :as sv]
@@ -318,7 +318,7 @@
(contains? o :changes-with-metadata)))))
(sv/defmethod ::update-file
- {::rlimit/permits (cf/get :rlimit-file-update)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-file-update)}
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
(db/with-atomic [conn pool]
(db/xact-lock! conn id)
diff --git a/backend/src/app/rpc/mutations/fonts.clj b/backend/src/app/rpc/mutations/fonts.clj
index b24544a88..2fa930b6e 100644
--- a/backend/src/app/rpc/mutations/fonts.clj
+++ b/backend/src/app/rpc/mutations/fonts.clj
@@ -15,7 +15,7 @@
[app.media :as media]
[app.rpc.doc :as-alias doc]
[app.rpc.queries.teams :as teams]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
@@ -42,7 +42,7 @@
::font-id ::font-family ::font-weight ::font-style]))
(sv/defmethod ::create-font-variant
- {::rlimit/permits (cf/get :rlimit-font)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-font)}
[{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}]
(let [cfg (update cfg :storage media/configure-assets-storage)]
(teams/check-edition-permissions! pool profile-id team-id)
diff --git a/backend/src/app/rpc/mutations/media.clj b/backend/src/app/rpc/mutations/media.clj
index 25d894d77..50ab06e0f 100644
--- a/backend/src/app/rpc/mutations/media.clj
+++ b/backend/src/app/rpc/mutations/media.clj
@@ -15,7 +15,7 @@
[app.db :as db]
[app.media :as media]
[app.rpc.queries.teams :as teams]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.bytes :as bs]
@@ -53,7 +53,7 @@
:opt-un [::id]))
(sv/defmethod ::upload-file-media-object
- {::rlimit/permits (cf/get :rlimit-image)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-image)}
[{:keys [pool] :as cfg} {:keys [profile-id file-id content] :as params}]
(let [file (select-file pool file-id)
cfg (update cfg :storage media/configure-assets-storage)]
@@ -181,7 +181,7 @@
:opt-un [::id ::name]))
(sv/defmethod ::create-file-media-object-from-url
- {::rlimit/permits (cf/get :rlimit-image)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-image)}
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
(let [file (select-file pool file-id)
cfg (update cfg :storage media/configure-assets-storage)]
diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj
index f8c0582c9..a72952be9 100644
--- a/backend/src/app/rpc/mutations/profile.clj
+++ b/backend/src/app/rpc/mutations/profile.clj
@@ -17,7 +17,7 @@
[app.rpc.commands.auth :as cmd.auth]
[app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.storage :as sto]
[app.tokens :as tokens]
[app.util.services :as sv]
@@ -87,7 +87,7 @@
(s/keys :req-un [::profile-id ::password ::old-password]))
(sv/defmethod ::update-profile-password
- {::rlimit/permits (cf/get :rlimit-password)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-password)}
[{:keys [pool] :as cfg} {:keys [password] :as params}]
(db/with-atomic [conn pool]
(let [profile (validate-password! conn params)
@@ -130,7 +130,7 @@
(s/keys :req-un [::profile-id ::file]))
(sv/defmethod ::update-profile-photo
- {::rlimit/permits (cf/get :rlimit-image)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-image)}
[cfg {:keys [file] :as params}]
;; Validate incoming mime type
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
@@ -305,7 +305,7 @@
(s/def ::login ::cmd.auth/login-with-password)
(sv/defmethod ::login
- {:auth false ::rlimit/permits (cf/get :rlimit-password)}
+ {:auth false ::rsem/permits (cf/get :rpc-semaphore-permits-password)}
[cfg params]
(cmd.auth/login-with-password cfg params))
@@ -323,7 +323,7 @@
(s/def ::recover-profile ::cmd.auth/recover-profile)
(sv/defmethod ::recover-profile
- {:auth false ::rlimit/permits (cf/get :rlimit-password)}
+ {:auth false ::rsem/permits (cf/get :rpc-semaphore-permits-password)}
[cfg params]
(cmd.auth/recover-profile cfg params))
@@ -340,7 +340,7 @@
(s/def ::register-profile ::cmd.auth/register-profile)
(sv/defmethod ::register-profile
- {:auth false ::rlimit/permits (cf/get :rlimit-password)}
+ {:auth false ::rsem/permits (cf/get :rpc-semaphore-permits-password)}
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
(-> (assoc cfg :conn conn)
diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj
index c9ceae6ff..8e9c1d2c5 100644
--- a/backend/src/app/rpc/mutations/teams.clj
+++ b/backend/src/app/rpc/mutations/teams.clj
@@ -20,7 +20,7 @@
[app.rpc.permissions :as perms]
[app.rpc.queries.profile :as profile]
[app.rpc.queries.teams :as teams]
- [app.rpc.rlimit :as rlimit]
+ [app.rpc.semaphore :as rsem]
[app.storage :as sto]
[app.tokens :as tokens]
[app.util.services :as sv]
@@ -290,7 +290,7 @@
(s/keys :req-un [::profile-id ::team-id ::file]))
(sv/defmethod ::update-team-photo
- {::rlimit/permits (cf/get :rlimit-image)}
+ {::rsem/permits (cf/get :rpc-semaphore-permits-image)}
[cfg {:keys [file] :as params}]
;; Validate incoming mime type
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj
index af04af269..2a90a8aa1 100644
--- a/backend/src/app/rpc/rlimit.clj
+++ b/backend/src/app/rpc/rlimit.clj
@@ -5,63 +5,266 @@
;; Copyright (c) UXBOX Labs SL
(ns app.rpc.rlimit
- "Resource usage limits (in other words: semaphores)."
+ "Rate limit strategies implementation for RPC services.
+
+ It mainly implements two strategies: fixed window and bucket. You
+ can use one of them or both to create a combination of limits. All
+ limits are updated in each request and the most restrictive one
+ blocks the user activity.
+
+ On the HTTP layer it translates to the 429 http response.
+
+ The limits are defined as vector of 3 elements:
+ [ ]
+
+ The opts format is strategy dependent. With fixed `:window` strategy
+ you have the following format:
+ [:somename :window \"1000/m\"]
+
+ Where the first number means the quantity of allowed request and the
+ letter indicates the window unit, that can be `w` for weeks, `h` for
+ hours, `m` for minutes and `s` for seconds.
+
+ The the `:bucket` strategy you will have something like this:
+ [:somename :bucket \"100/10/15s]
+
+ Where the first number indicates the total tokens capacity (or
+ available burst), the second number indicates the refill rate and
+ the last number suffixed with the unit indicates the time window (or
+ interval) of the refill. This means that this limit configurations
+ allow burst of 100 elements and will refill 10 tokens each 15s (1
+ token each 1.5segons).
+
+ The bucket strategy works well for small intervals and window
+ strategy works better for large intervals.
+
+ All limits uses the profile-id as user identifier. In case of the
+ profile-id is not available, the IP address is used as fallback
+ value.
+ "
(:require
[app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.exceptions :as ex]
[app.common.logging :as l]
- [app.metrics :as mtx]
- [app.util.services :as sv]
+ [app.common.spec :as us]
+ [app.common.uri :as uri]
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.http :as-alias http]
+ [app.loggers.audit :refer [parse-client-ip]]
+ [app.redis :as redis]
+ [app.redis.script :as-alias rscript]
+ [app.rpc.rlimit.result :as-alias lresult]
+ [app.util.services :as-alias sv]
+ [app.util.time :as dt]
+ [clojure.spec.alpha :as s]
+ [cuerdas.core :as str]
[promesa.core :as p]))
-(defprotocol IAsyncSemaphore
- (acquire! [_])
- (release! [_]))
+(def ^:private default-timeout
+ (dt/duration 400))
-(defn semaphore
- [{:keys [permits metrics name]}]
- (let [name (d/name name)
- used (volatile! 0)
- queue (volatile! (d/queue))
- labels (into-array String [name])]
- (reify IAsyncSemaphore
- (acquire! [this]
- (let [d (p/deferred)]
- (locking this
- (if (< @used permits)
- (do
- (vswap! used inc)
- (p/resolve! d))
- (vswap! queue conj d)))
+(def ^:private default-options
+ {:codec redis/string-codec
+ :timeout default-timeout})
- (mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels })
- (mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
- (mtx/run! metrics {:id :rlimit-acquires-total :inc 1 :labels labels})
- d))
+(def ^:private bucket-rate-limit-script
+ {::rscript/name ::bucket-rate-limit
+ ::rscript/path "app/rpc/rlimit/bucket.lua"})
- (release! [this]
- (locking this
- (if-let [item (peek @queue)]
- (do
- (vswap! queue pop)
- (p/resolve! item))
- (when (pos? @used)
- (vswap! used dec))))
+(def ^:private window-rate-limit-script
+ {::rscript/name ::window-rate-limit
+ ::rscript/path "app/rpc/rlimit/window.lua"})
- (mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels})
- (mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
- ))))
+(def enabled?
+ "Allows on runtime completly disable rate limiting."
+ (atom true))
-(defn wrap-rlimit
- [{:keys [metrics executors] :as cfg} f mdata]
- (if-let [permits (::permits mdata)]
- (let [sem (semaphore {:permits permits
- :metrics metrics
- :name (::sv/name mdata)})]
- (l/debug :hint "wrapping rlimit" :handler (::sv/name mdata) :permits permits)
- (fn [cfg params]
- (-> (acquire! sem)
- (p/then (fn [_] (f cfg params)) (:default executors))
- (p/finally (fn [_ _] (release! sem))))))
- f))
+(def ^:private window-opts-re
+ #"^(\d+)/([wdhms])$")
+(def ^:private bucket-opts-re
+ #"^(\d+)/(\d+)/(\d+[hms])$")
+(s/def ::strategy (s/and ::us/keyword #{:window :bucket}))
+
+(s/def ::limit-definition
+ (s/tuple ::us/keyword ::strategy string?))
+
+(defmulti parse-limit (fn [[_ strategy _]] strategy))
+(defmulti process-limit (fn [_ _ _ o] (::strategy o)))
+
+(defmethod parse-limit :window
+ [[name strategy opts :as vlimit]]
+ (us/assert! ::limit-definition vlimit)
+ (merge
+ {::name name
+ ::strategy strategy}
+ (if-let [[_ nreq unit] (re-find window-opts-re opts)]
+ (let [nreq (parse-long nreq)]
+ {::nreq nreq
+ ::unit (case unit
+ "d" :days
+ "h" :hours
+ "m" :minutes
+ "s" :seconds
+ "w" :weeks)
+ ::key (dm/str "ratelimit.window." (d/name name))
+ ::opts opts})
+ (ex/raise :type :validation
+ :code :invalid-window-limit-opts
+ :hint (str/ffmt "looks like '%' does not have a valid format" opts)))))
+
+(defmethod parse-limit :bucket
+ [[name strategy opts :as vlimit]]
+ (us/assert! ::limit-definition vlimit)
+ (merge
+ {::name name
+ ::strategy strategy}
+ (if-let [[_ capacity rate interval] (re-find bucket-opts-re opts)]
+ (let [interval (dt/duration interval)
+ rate (parse-long rate)
+ capacity (parse-long capacity)]
+ {::capacity capacity
+ ::rate rate
+ ::interval interval
+ ::opts opts
+ ::params [(dt/->seconds interval) rate capacity]
+ ::key (dm/str "ratelimit.bucket." (d/name name))})
+ (ex/raise :type :validation
+ :code :invalid-bucket-limit-opts
+ :hint (str/ffmt "looks like '%' does not have a valid format" opts)))))
+
+(defmethod process-limit :bucket
+ [redis user-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
+ (let [script (-> bucket-rate-limit-script
+ (assoc ::rscript/keys [(dm/str key "." service "." user-id)])
+ (assoc ::rscript/vals (conj params (dt/->seconds now))))]
+ (-> (redis/eval! redis script)
+ (p/then (fn [result]
+ (let [allowed? (boolean (nth result 0))
+ remaining (nth result 1)
+ reset (* (/ (inst-ms interval) rate)
+ (- capacity remaining))]
+ (l/trace :hint "limit processed"
+ :service service
+ :limit (name (::name limit))
+ :strategy (name (::strategy limit))
+ :opts (::opts limit)
+ :allowed? allowed?
+ :remaining remaining)
+ (-> limit
+ (assoc ::lresult/allowed? allowed?)
+ (assoc ::lresult/reset (dt/plus now reset))
+ (assoc ::lresult/remaining remaining))))))))
+
+(defmethod process-limit :window
+ [redis user-id now {:keys [::nreq ::unit ::key ::service] :as limit}]
+ (let [ts (dt/truncate now unit)
+ ttl (dt/diff now (dt/plus ts {unit 1}))
+ script (-> window-rate-limit-script
+ (assoc ::rscript/keys [(dm/str key "." service "." user-id "." (dt/format-instant ts))])
+ (assoc ::rscript/vals [nreq (dt/->seconds ttl)]))]
+ (-> (redis/eval! redis script)
+ (p/then (fn [result]
+ (let [allowed? (boolean (nth result 0))
+ remaining (nth result 1)]
+ (l/trace :hint "limit processed"
+ :service service
+ :limit (name (::name limit))
+ :strategy (name (::strategy limit))
+ :opts (::opts limit)
+ :allowed? allowed?
+ :remaining remaining)
+ (-> limit
+ (assoc ::lresult/allowed? allowed?)
+ (assoc ::lresult/remaining remaining)
+ (assoc ::lresult/reset (dt/plus ts {unit 1})))))))))
+
+(defn- process-limits
+ [redis user-id limits now]
+ (-> (p/all (map (partial process-limit redis user-id now) (reverse limits)))
+ (p/then (fn [results]
+ (let [remaining (->> results
+ (d/index-by ::name ::lresult/remaining)
+ (uri/map->query-string))
+ reset (->> results
+ (d/index-by ::name (comp dt/->seconds ::lresult/reset))
+ (uri/map->query-string))
+ rejected (->> results
+ (filter (complement ::lresult/allowed?))
+ (first))]
+ (when (and rejected (contains? cf/flags :warn-rpc-rate-limits))
+ (l/warn :hint "rejected rate limit"
+ :user-id (dm/str user-id)
+ :limit-service (-> rejected ::service name)
+ :limit-name (-> rejected ::name name)
+ :limit-strategy (-> rejected ::strategy name)))
+
+ {:enabled? true
+ :allowed? (some? rejected)
+ :headers {"x-rate-limit-remaining" remaining
+ "x-rate-limit-reset" reset}})))))
+
+(defn- parse-limits
+ [service limits]
+ (let [default (some->> (cf/get :default-rpc-rlimit)
+ (us/conform ::limit-definition))
+
+ limits (cond->> limits
+ (some? default) (cons default))]
+
+ (->> (reverse limits)
+ (sequence (comp (map parse-limit)
+ (map #(assoc % ::service service))
+ (d/distinct-xf ::name))))))
+
+(defn- handle-response
+ [f cfg params rres]
+ (if (:enabled? rres)
+ (let [headers {"x-rate-limit-remaining" (:remaining rres)
+ "x-rate-limit-reset" (:reset rres)}]
+ (when-not (:allowed? rres)
+ (ex/raise :type :rate-limit
+ :code :request-blocked
+ :hint "rate limit reached"
+ ::http/headers headers))
+ (-> (f cfg params)
+ (p/then (fn [response]
+ (with-meta response
+ {::http/headers headers})))))
+
+ (f cfg params)))
+
+(defn wrap
+ [{:keys [redis] :as cfg} f {service ::sv/name :as mdata}]
+ (let [limits (parse-limits service (::limits mdata))
+ default-rresp (p/resolved {:enabled? false})]
+
+ (if (and (seq limits)
+ (or (contains? cf/flags :rpc-rate-limit)
+ (contains? cf/flags :soft-rpc-rate-limit)))
+ (fn [cfg {:keys [::http/request] :as params}]
+ (let [user-id (or (:profile-id params)
+ (some-> request parse-client-ip)
+ uuid/zero)
+
+ rresp (when (and user-id @enabled?)
+ (let [redis (redis/get-or-connect redis ::rlimit default-options)
+ rresp (-> (process-limits redis user-id limits (dt/now))
+ (p/catch (fn [cause]
+ ;; If we have an error on processing the
+ ;; rate-limit we just skip it for do not cause
+ ;; service interruption because of redis downtime
+ ;; or similar situation.
+ (l/error :hint "error on processing rate-limit" :cause cause)
+ {:enabled? false})))]
+
+ ;; If soft rate are enabled, we process the rate-limit but return
+ ;; unprotected response.
+ (and (contains? cf/flags :soft-rpc-rate-limit) rresp)))]
+
+ (p/then (or rresp default-rresp)
+ (partial handle-response f cfg params))))
+ f)))
diff --git a/backend/src/app/rpc/rlimit/bucket.lua b/backend/src/app/rpc/rlimit/bucket.lua
new file mode 100644
index 000000000..4200dec4d
--- /dev/null
+++ b/backend/src/app/rpc/rlimit/bucket.lua
@@ -0,0 +1,33 @@
+local tokensKey = KEYS[1]
+
+local interval = tonumber(ARGV[1])
+local rate = tonumber(ARGV[2])
+local capacity = tonumber(ARGV[3])
+local timestamp = tonumber(ARGV[4])
+local requested = tonumber(ARGV[5] or 1)
+
+local fillTime = capacity / (rate / interval);
+local ttl = math.floor(fillTime * 2)
+
+local lastTokens = tonumber(redis.call("hget", tokensKey, "tokens"))
+if lastTokens == nil then
+ lastTokens = capacity
+end
+
+local lastRefreshed = tonumber(redis.call("hget", tokensKey, "timestamp"))
+if lastRefreshed == nil then
+ lastRefreshed = 0
+end
+
+local delta = math.max(0, (timestamp - lastRefreshed) / interval)
+local filled = math.min(capacity, lastTokens + math.floor(delta * rate));
+local allowed = filled >= requested
+local newTokens = filled
+if allowed then
+ newTokens = filled - requested
+end
+
+redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
+redis.call("expire", tokensKey, ttl)
+
+return { allowed, newTokens }
diff --git a/backend/src/app/rpc/rlimit/window.lua b/backend/src/app/rpc/rlimit/window.lua
new file mode 100644
index 000000000..d5e8e8af6
--- /dev/null
+++ b/backend/src/app/rpc/rlimit/window.lua
@@ -0,0 +1,18 @@
+local windowKey = KEYS[1]
+
+local nreq = tonumber(ARGV[1])
+local ttl = tonumber(ARGV[2])
+
+local total = tonumber(redis.call("incr", windowKey))
+redis.call("expire", windowKey, ttl)
+
+local allowed = total <= nreq
+local remaining = nreq - total
+
+if remaining < 0 then
+ remaining = 0
+end
+
+return {allowed, remaining}
+
+
diff --git a/backend/src/app/rpc/semaphore.clj b/backend/src/app/rpc/semaphore.clj
new file mode 100644
index 000000000..45f90839d
--- /dev/null
+++ b/backend/src/app/rpc/semaphore.clj
@@ -0,0 +1,68 @@
+;; 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) UXBOX Labs SL
+
+(ns app.rpc.semaphore
+ "Resource usage limits (in other words: semaphores)."
+ (:require
+ [app.common.data :as d]
+ [app.common.logging :as l]
+ [app.metrics :as mtx]
+ [app.util.locks :as locks]
+ [app.util.services :as-alias sv]
+ [promesa.core :as p]))
+
+(defprotocol IAsyncSemaphore
+ (acquire! [_])
+ (release! [_]))
+
+(defn create
+ [& {:keys [permits metrics name]}]
+ (let [name (d/name name)
+ used (volatile! 0)
+ queue (volatile! (d/queue))
+ labels (into-array String [name])
+ lock (locks/create)]
+
+ (reify IAsyncSemaphore
+ (acquire! [_]
+ (let [d (p/deferred)]
+ (locks/locking lock
+ (if (< @used permits)
+ (do
+ (vswap! used inc)
+ (p/resolve! d))
+ (vswap! queue conj d)))
+
+ (mtx/run! metrics {:id :rpc-semaphore-used-permits :val @used :labels labels })
+ (mtx/run! metrics {:id :rpc-semaphore-queued-submissions :val (count @queue) :labels labels})
+ (mtx/run! metrics {:id :rpc-semaphore-acquires-total :inc 1 :labels labels})
+ d))
+
+ (release! [_]
+ (locks/locking lock
+ (if-let [item (peek @queue)]
+ (do
+ (vswap! queue pop)
+ (p/resolve! item))
+ (when (pos? @used)
+ (vswap! used dec))))
+
+ (mtx/run! metrics {:id :rpc-semaphore-used-permits :val @used :labels labels})
+ (mtx/run! metrics {:id :rpc-semaphore-queued-submissions :val (count @queue) :labels labels})))))
+
+(defn wrap
+ [{:keys [metrics executors] :as cfg} f mdata]
+ (if-let [permits (::permits mdata)]
+ (let [sem (create {:permits permits
+ :metrics metrics
+ :name (::sv/name mdata)})]
+ (l/debug :hint "wrapping semaphore" :handler (::sv/name mdata) :permits permits)
+ (fn [cfg params]
+ (-> (acquire! sem)
+ (p/then (fn [_] (f cfg params)) (:default executors))
+ (p/finally (fn [_ _] (release! sem))))))
+ f))
+
diff --git a/backend/src/app/setup/builtin_templates.clj b/backend/src/app/setup/builtin_templates.clj
index d05255058..11cfe0fa9 100644
--- a/backend/src/app/setup/builtin_templates.clj
+++ b/backend/src/app/setup/builtin_templates.clj
@@ -14,7 +14,7 @@
[clojure.edn :as edn]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[integrant.core :as ig]))
(declare download-all!)
diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj
index 4fbf05a5a..a4e7209ea 100644
--- a/backend/src/app/storage.clj
+++ b/backend/src/app/storage.clj
@@ -20,7 +20,7 @@
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]))
diff --git a/backend/src/app/storage/fs.clj b/backend/src/app/storage/fs.clj
index 4feaaf624..fbfbc8369 100644
--- a/backend/src/app/storage/fs.clj
+++ b/backend/src/app/storage/fs.clj
@@ -14,7 +14,7 @@
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px])
diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj
index 72480dd53..99113f833 100644
--- a/backend/src/app/storage/s3.clj
+++ b/backend/src/app/storage/s3.clj
@@ -17,7 +17,7 @@
[app.worker :as wrk]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px])
diff --git a/backend/src/app/storage/tmp.clj b/backend/src/app/storage/tmp.clj
index cdb1b0cc7..69503a455 100644
--- a/backend/src/app/storage/tmp.clj
+++ b/backend/src/app/storage/tmp.clj
@@ -16,7 +16,7 @@
[app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.exec :as px]))
diff --git a/backend/src/app/tokens.clj b/backend/src/app/tokens.clj
index ff4fd998a..3a991609e 100644
--- a/backend/src/app/tokens.clj
+++ b/backend/src/app/tokens.clj
@@ -12,11 +12,14 @@
[app.common.spec :as us]
[app.common.transit :as t]
[app.util.time :as dt]
- [buddy.sign.jwe :as jwe]))
+ [buddy.sign.jwe :as jwe]
+ [clojure.spec.alpha :as s]))
+
+(s/def ::tokens-key bytes?)
(defn generate
[{:keys [tokens-key]} claims]
- (us/assert! ::us/not-empty-string tokens-key)
+ (us/assert! ::tokens-key tokens-key)
(let [payload (-> claims
(assoc :iat (dt/now))
(d/without-nils)
diff --git a/backend/src/app/util/bytes.clj b/backend/src/app/util/bytes.clj
index 50a73d335..5e5c2dca4 100644
--- a/backend/src/app/util/bytes.clj
+++ b/backend/src/app/util/bytes.clj
@@ -8,7 +8,7 @@
"Bytes & Byte Streams helpers"
(:require
[clojure.java.io :as io]
- [datoteka.core :as fs]
+ [datoteka.fs :as fs]
[yetti.adapter :as yt])
(:import
com.github.luben.zstd.ZstdInputStream
@@ -23,6 +23,8 @@
org.apache.commons.io.IOUtils
org.apache.commons.io.input.BoundedInputStream))
+;; TODO: migrate to datoteka.io
+
(set! *warn-on-reflection* true)
(def ^:const default-buffer-size
diff --git a/backend/src/app/util/locks.clj b/backend/src/app/util/locks.clj
new file mode 100644
index 000000000..05a69166d
--- /dev/null
+++ b/backend/src/app/util/locks.clj
@@ -0,0 +1,26 @@
+;; 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) UXBOX Labs SL
+
+(ns app.util.locks
+ "A syntactic helpers for using locks."
+ (:refer-clojure :exclude [locking])
+ (:import
+ java.util.concurrent.locks.ReentrantLock
+ java.util.concurrent.locks.Lock))
+
+(defn create
+ []
+ (ReentrantLock.))
+
+(defmacro locking
+ [lsym & body]
+ (let [lsym (vary-meta lsym assoc :tag `Lock)]
+ `(do
+ (.lock ~lsym)
+ (try
+ ~@body
+ (finally
+ (.unlock ~lsym))))))
diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj
index 422c92fb3..f51706cce 100644
--- a/backend/src/app/util/time.clj
+++ b/backend/src/app/util/time.clj
@@ -27,16 +27,29 @@
;; Instant & Duration
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+(defn temporal-unit
+ [o]
+ (if (instance? TemporalUnit o)
+ o
+ (case o
+ :nanos ChronoUnit/NANOS
+ :millis ChronoUnit/MILLIS
+ :micros ChronoUnit/MICROS
+ :seconds ChronoUnit/SECONDS
+ :minutes ChronoUnit/MINUTES
+ :hours ChronoUnit/HOURS
+ :days ChronoUnit/DAYS
+ :weeks ChronoUnit/WEEKS
+ :monts ChronoUnit/MONTHS)))
+
;; --- DURATION
(defn- obj->duration
- [{:keys [days minutes seconds hours nanos millis]}]
- (cond-> (Duration/ofMillis (if (int? millis) ^long millis 0))
- (int? days) (.plusDays ^long days)
- (int? hours) (.plusHours ^long hours)
- (int? minutes) (.plusMinutes ^long minutes)
- (int? seconds) (.plusSeconds ^long seconds)
- (int? nanos) (.plusNanos ^long nanos)))
+ [params]
+ (reduce-kv (fn [o k v]
+ (.plus ^Duration o ^long v ^TemporalUnit (temporal-unit k)))
+ (Duration/ofMillis 0)
+ params))
(defn duration?
[v]
@@ -57,20 +70,17 @@
:else
(obj->duration ms-or-obj)))
+(defn ->seconds
+ [d]
+ (-> d inst-ms (/ 1000) int))
+
(defn diff
[t1 t2]
(Duration/between t1 t2))
(defn truncate
[o unit]
- (let [unit (if (instance? TemporalUnit unit)
- unit
- (case unit
- :nanos ChronoUnit/NANOS
- :millis ChronoUnit/MILLIS
- :micros ChronoUnit/MICROS
- :seconds ChronoUnit/SECONDS
- :minutes ChronoUnit/MINUTES))]
+ (let [unit (temporal-unit unit)]
(cond
(instance? Instant o)
(.truncatedTo ^Instant o ^TemporalUnit unit)
@@ -159,11 +169,11 @@
(defn in-future
[v]
- (plus (now) (duration v)))
+ (plus (now) v))
(defn in-past
[v]
- (minus (now) (duration v)))
+ (minus (now) v))
(defn instant->zoned-date-time
[v]
diff --git a/backend/test/app/bounce_handling_test.clj b/backend/test/app/bounce_handling_test.clj
index 3d423f73f..490a8c1b9 100644
--- a/backend/test/app/bounce_handling_test.clj
+++ b/backend/test/app/bounce_handling_test.clj
@@ -10,6 +10,7 @@
[app.emails :as emails]
[app.http.awsns :as awsns]
[app.test-helpers :as th]
+ [app.tokens :as tokens]
[app.util.time :as dt]
[clojure.pprint :refer [pprint]]
[clojure.test :as t]
@@ -100,11 +101,11 @@
(t/deftest test-parse-bounce-report
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
- cfg {:tokens tokens}
- report (bounce-report {:token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ sprops (:app.setup/props th/*system*)
+ cfg {:sprops sprops}
+ report (bounce-report {:token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
result (#'awsns/parse-notification cfg report)]
;; (pprint result)
@@ -117,11 +118,11 @@
(t/deftest test-parse-complaint-report
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
- cfg {:tokens tokens}
- report (complaint-report {:token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ sprops (:app.setup/props th/*system*)
+ cfg {:sprops sprops}
+ report (complaint-report {:token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
result (#'awsns/parse-notification cfg report)]
;; (pprint result)
(t/is (= "complaint" (:type result)))
@@ -132,8 +133,8 @@
))
(t/deftest test-parse-complaint-report-without-token
- (let [tokens (:app.tokens/tokens th/*system*)
- cfg {:tokens tokens}
+ (let [sprops (:app.setup/props th/*system*)
+ cfg {:sprops sprops}
report (complaint-report {:token ""})
result (#'awsns/parse-notification cfg report)]
(t/is (= "complaint" (:type result)))
@@ -145,12 +146,12 @@
(t/deftest test-process-bounce-report
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
+ sprops (:app.setup/props th/*system*)
pool (:app.db/pool th/*system*)
- cfg {:tokens tokens :pool pool}
- report (bounce-report {:token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ cfg {:sprops sprops :pool pool}
+ report (bounce-report {:token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
@@ -174,12 +175,12 @@
(t/deftest test-process-complaint-report
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
+ sprops (:app.setup/props th/*system*)
pool (:app.db/pool th/*system*)
- cfg {:tokens tokens :pool pool}
- report (complaint-report {:token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ cfg {:sprops sprops :pool pool}
+ report (complaint-report {:token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
@@ -205,13 +206,13 @@
(t/deftest test-process-bounce-report-to-self
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
+ sprops (:app.setup/props th/*system*)
pool (:app.db/pool th/*system*)
- cfg {:tokens tokens :pool pool}
+ cfg {:sprops sprops :pool pool}
report (bounce-report {:email (:email profile)
- :token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ :token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
@@ -227,13 +228,13 @@
(t/deftest test-process-complaint-report-to-self
(let [profile (th/create-profile* 1)
- tokens (:app.tokens/tokens th/*system*)
+ sprops (:app.setup/props th/*system*)
pool (:app.db/pool th/*system*)
- cfg {:tokens tokens :pool pool}
+ cfg {:sprops sprops :pool pool}
report (complaint-report {:email (:email profile)
- :token (tokens :generate-predefined
- {:iss :profile-identity
- :profile-id (:id profile)})})
+ :token (tokens/generate sprops
+ {:iss :profile-identity
+ :profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
diff --git a/backend/test/app/services_profile_test.clj b/backend/test/app/services_profile_test.clj
index 68f14c3b4..e750ea6d2 100644
--- a/backend/test/app/services_profile_test.clj
+++ b/backend/test/app/services_profile_test.clj
@@ -9,9 +9,10 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
- [app.rpc.mutations.profile :as profile]
[app.rpc.commands.auth :as cauth]
+ [app.rpc.mutations.profile :as profile]
[app.test-helpers :as th]
+ [app.tokens :as tokens]
[app.util.time :as dt]
[clojure.java.io :as io]
[clojure.test :as t]
@@ -196,13 +197,13 @@
(t/deftest prepare-and-register-with-invitation-and-disabled-registration-1
(with-redefs [app.config/flags [:disable-registration]]
- (let [tokens-fn (:app.tokens/tokens th/*system*)
- itoken (tokens-fn :generate
- {:iss :team-invitation
- :exp (dt/in-future "48h")
- :role :editor
- :team-id uuid/zero
- :member-email "user@example.com"})
+ (let [sprops (:app.setup/props th/*system*)
+ itoken (tokens/generate sprops
+ {:iss :team-invitation
+ :exp (dt/in-future "48h")
+ :role :editor
+ :team-id uuid/zero
+ :member-email "user@example.com"})
data {::th/type :prepare-register-profile
:invitation-token itoken
:email "user@example.com"
@@ -226,13 +227,13 @@
(t/deftest prepare-and-register-with-invitation-and-disabled-registration-2
(with-redefs [app.config/flags [:disable-registration]]
- (let [tokens-fn (:app.tokens/tokens th/*system*)
- itoken (tokens-fn :generate
- {:iss :team-invitation
- :exp (dt/in-future "48h")
- :role :editor
- :team-id uuid/zero
- :member-email "user2@example.com"})
+ (let [sprops (:app.setup/props th/*system*)
+ itoken (tokens/generate sprops
+ {:iss :team-invitation
+ :exp (dt/in-future "48h")
+ :role :editor
+ :team-id uuid/zero
+ :member-email "user2@example.com"})
data {::th/type :prepare-register-profile
:invitation-token itoken
diff --git a/backend/test/app/test_helpers.clj b/backend/test/app/test_helpers.clj
index 6b4e0ec47..a3dec3285 100644
--- a/backend/test/app/test_helpers.clj
+++ b/backend/test/app/test_helpers.clj
@@ -59,7 +59,7 @@
:path (-> "app/test_files/template.penpot" io/resource fs/path)}]
config (-> main/system-config
(merge main/worker-config)
- (assoc-in [:app.msgbus/msgbus :redis-uri] (:redis-uri config))
+ (assoc-in [:app.redis/redis :uri] (:redis-uri config))
(assoc-in [:app.db/pool :uri] (:database-uri config))
(assoc-in [:app.db/pool :username] (:database-username config))
(assoc-in [:app.db/pool :password] (:database-password config))
diff --git a/common/deps.edn b/common/deps.edn
index 202f57b3e..64ad27bf0 100644
--- a/common/deps.edn
+++ b/common/deps.edn
@@ -28,7 +28,8 @@
:exclusions [org.clojure/data.json]}
frankiesardo/linked {:mvn/version "1.3.0"}
- commons-io/commons-io {:mvn/version "2.11.0"}
+
+ funcool/datoteka {:mvn/version "3.0.65"}
com.sun.mail/jakarta.mail {:mvn/version "2.0.1"}
;; exception printing
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index d03a935a2..6c0a195d2 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -10,6 +10,7 @@
parse-double group-by iteration])
#?(:cljs
(:require-macros [app.common.data]))
+
(:require
[app.common.math :as mth]
[clojure.set :as set]
diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc
index 654270206..29d747aa0 100644
--- a/common/src/app/common/spec.cljc
+++ b/common/src/app/common/spec.cljc
@@ -133,9 +133,9 @@
(dm/str v))]
(s/def ::rgb-color-str (s/conformer conformer unformer)))
-;; --- SPEC: set/vec of valid Keywords
+;; --- SPEC: set/vector of Keywords
-(letfn [(conform-fn [dest s]
+(letfn [(conformer-fn [dest s]
(let [xform (keep (fn [s]
(cond
(string? s) (keyword s)
@@ -144,17 +144,38 @@
(cond
(set? s) (into dest xform s)
(string? s) (into dest xform (str/words s))
- :else ::s/invalid)))]
+ :else ::s/invalid)))
+ (unformer-fn [v]
+ (str/join " " (map name v)))]
- (s/def ::set-of-valid-keywords
- (s/conformer
- (fn [s] (conform-fn #{} s))
- (fn [s] (str/join " " (map name s)))))
+ (s/def ::set-of-keywords
+ (s/conformer (partial conformer-fn #{}) unformer-fn))
- (s/def ::vec-of-valid-keywords
- (s/conformer
- (fn [s] (conform-fn [] s))
- (fn [s] (str/join " " (map name s))))))
+ (s/def ::vector-of-keywords
+ (s/conformer (partial conformer-fn []) unformer-fn)))
+
+;; --- SPEC: set/vector of strings
+
+(def non-empty-strings-xf
+ (comp
+ (filter string?)
+ (remove str/empty?)
+ (remove str/blank?)))
+
+(letfn [(conformer-fn [dest v]
+ (cond
+ (string? v) (into dest non-empty-strings-xf (str/split v #"[\s,]+"))
+ (vector? v) (into dest non-empty-strings-xf v)
+ (set? v) (into dest non-empty-strings-xf v)
+ :else ::s/invalid))
+ (unformer-fn [v]
+ (str/join "," v))]
+
+ (s/def ::set-of-strings
+ (s/conformer (partial conformer-fn #{}) unformer-fn))
+
+ (s/def ::vector-of-strings
+ (s/conformer (partial conformer-fn []) unformer-fn)))
;; --- SPEC: set-of-valid-emails
@@ -173,23 +194,15 @@
(str/join " " v))]
(s/def ::set-of-valid-emails (s/conformer conformer unformer)))
-;; --- SPEC: set-of-non-empty-strings
-
-(def non-empty-strings-xf
- (comp
- (filter string?)
- (remove str/empty?)
- (remove str/blank?)))
+;; --- SPEC: query-string
(letfn [(conformer [s]
- (cond
- (string? s) (->> (str/split s #"\s*,\s*")
- (into #{} non-empty-strings-xf))
- (set? s) (into #{} non-empty-strings-xf s)
- :else ::s/invalid))
+ (if (string? s)
+ (ex/try* #(u/query-string->map s) (constantly ::s/invalid))
+ s))
(unformer [s]
- (str/join "," s))]
- (s/def ::set-of-non-empty-strings (s/conformer conformer unformer)))
+ (u/map->query-string s))]
+ (s/def ::query-string (s/conformer conformer unformer)))
;; --- SPECS WITHOUT CONFORMER