;; 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 INC (ns app.rpc.quotes "Penpot resource usage quotes." (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] [app.common.spec :as us] [app.config :as cf] [app.db :as db] [app.util.time :as dt] [app.worker :as wrk] [clojure.spec.alpha :as s] [cuerdas.core :as str])) (defmulti check-quote ::id) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PUBLIC API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private schema:quote (sm/define [:map {:title "Quote"} [::team-id {:optional true} ::sm/uuid] [::project-id {:optional true} ::sm/uuid] [::file-id {:optional true} ::sm/uuid] [::incr {:optional true} [::sm/int {:min 0}]] [::id :keyword] [::profile-id ::sm/uuid]])) (def ^:private enabled (volatile! true)) (defn enable! "Enable quotes checking at runtime (from server REPL)." [] (vswap! enabled (constantly true))) (defn disable! "Disable quotes checking at runtime (from server REPL)." [] (vswap! enabled (constantly false))) (defn check-quote! [ds quote] (dm/assert! "expected valid quote map" (sm/validate schema:quote quote)) (when (contains? cf/flags :quotes) (when @enabled ;; This approach add flexibility on how and where the ;; check-quote! can be called (in or out of transaction) (db/run! ds (fn [cfg] (-> (merge cfg quote) (assoc ::target (name (::id quote))) (check-quote))))))) (defn- send-notification! [{:keys [::db/conn] :as params}] (l/warn :hint "max quote reached" :target (::target params) :profile-id (some-> params ::profile-id str) :team-id (some-> params ::team-id str) :project-id (some-> params ::project-id str) :file-id (some-> params ::file-id str) :quote (::quote params) :total (::total params) :incr (::inc params 1)) (when-let [admins (seq (cf/get :admins))] (let [subject (str/istr "[quotes:notification]: max quote reached ~(::target params)") content (str/istr "- Param: profile-id '~(::profile-id params)}'\n" "- Param: team-id '~(::team-id params)'\n" "- Param: project-id '~(::project-id params)'\n" "- Param: file-id '~(::file-id params)'\n" "- Quote ID: '~(::target params)'\n" "- Max: ~(::quote params)\n" "- Total: ~(::total params) (INCR ~(::incr params 1))\n")] (wrk/submit! {::db/conn conn ::wrk/task :sendmail ::wrk/delay (dt/duration "30s") ::wrk/max-retries 4 ::wrk/priority 200 ::wrk/dedupe true ::wrk/label "quotes-notification" ::wrk/params {:to (vec admins) :subject subject :body [{:type "text/plain" :content content}]}})))) (defn- generic-check! [{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}] (let [quote (->> (db/exec! conn quote-sql) (map :quote) (reduce max (- Integer/MAX_VALUE))) quote (if (pos? quote) quote default) total (->> (db/exec! conn count-sql) first :total)] (when (> (+ total incr) quote) (if (contains? cf/flags :soft-quotes) (send-notification! (assoc params ::quote quote ::total total)) (ex/raise :type :restriction :code :max-quote-reached :target target :quote quote :count total))))) (def ^:private sql:get-quotes-1 "select id, quote from usage_quote where target = ? and profile_id = ? and team_id is null and project_id is null and file_id is null;") (def ^:private sql:get-quotes-2 "select id, quote from usage_quote where target = ? and ((team_id = ? and (profile_id = ? or profile_id is null)) or (profile_id = ? and team_id is null and project_id is null and file_id is null));") (def ^:private sql:get-quotes-3 "select id, quote from usage_quote where target = ? and ((project_id = ? and (profile_id = ? or profile_id is null)) or (team_id = ? and (profile_id = ? or profile_id is null)) or (profile_id = ? and team_id is null and project_id is null and file_id is null));") (def ^:private sql:get-quotes-4 "select id, quote from usage_quote where target = ? and ((file_id = ? and (profile_id = ? or profile_id is null)) or (project_id = ? and (profile_id = ? or profile_id is null)) or (team_id = ? and (profile_id = ? or profile_id is null)) or (profile_id = ? and team_id is null and project_id is null and file_id is null));") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: TEAMS-PER-PROFILE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-teams-per-profile "select count(*) as total from team_profile_rel where profile_id = ?") (s/def ::profile-id ::us/uuid) (s/def ::teams-per-profile (s/keys :req [::profile-id ::target])) (defmethod check-quote ::teams-per-profile [{:keys [::profile-id ::target] :as quote}] (us/assert! ::teams-per-profile quote) (-> quote (assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-teams-per-profile profile-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: ACCESS-TOKENS-PER-PROFILE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-access-tokens-per-profile "select count(*) as total from access_token where profile_id = ?") (s/def ::access-tokens-per-profile (s/keys :req [::profile-id ::target])) (defmethod check-quote ::access-tokens-per-profile [{:keys [::profile-id ::target] :as quote}] (us/assert! ::access-tokens-per-profile quote) (-> quote (assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-access-tokens-per-profile profile-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: PROJECTS-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-projects-per-team "select count(*) as total from project as p where p.team_id = ? and p.deleted_at is null") (s/def ::team-id ::us/uuid) (s/def ::projects-per-team (s/keys :req [::profile-id ::team-id ::target])) (defmethod check-quote ::projects-per-team [{:keys [::profile-id ::team-id ::target] :as quote}] (-> quote (assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-projects-per-team team-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: FONT-VARIANTS-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-font-variants-per-team "select count(*) as total from team_font_variant as v where v.team_id = ?") (s/def ::font-variants-per-team (s/keys :req [::profile-id ::team-id ::target])) (defmethod check-quote ::font-variants-per-team [{:keys [::profile-id ::team-id ::target] :as quote}] (us/assert! ::font-variants-per-team quote) (-> quote (assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-font-variants-per-team team-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: INVITATIONS-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-invitations-per-team "select count(*) as total from team_invitation where team_id = ?") (s/def ::invitations-per-team (s/keys :req [::profile-id ::team-id ::target])) (defmethod check-quote ::invitations-per-team [{:keys [::profile-id ::team-id ::target] :as quote}] (us/assert! ::invitations-per-team quote) (-> quote (assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-invitations-per-team team-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: PROFILES-PER-TEAM ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-profiles-per-team "select (select count(*) from team_profile_rel where team_id = ?) + (select count(*) from team_invitation where team_id = ? and valid_until > now()) as total;") ;; NOTE: the total number of profiles is determined by the number of ;; effective members plus ongoing valid invitations. (s/def ::profiles-per-team (s/keys :req [::profile-id ::team-id ::target])) (defmethod check-quote ::profiles-per-team [{:keys [::profile-id ::team-id ::target] :as quote}] (us/assert! ::profiles-per-team quote) (-> quote (assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-profiles-per-team team-id team-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: FILES-PER-PROJECT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-files-per-project "select count(*) as total from file as f where f.project_id = ? and f.deleted_at is null") (s/def ::project-id ::us/uuid) (s/def ::files-per-project (s/keys :req [::profile-id ::project-id ::team-id ::target])) (defmethod check-quote ::files-per-project [{:keys [::profile-id ::project-id ::team-id ::target] :as quote}] (us/assert! ::files-per-project quote) (-> quote (assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id]) (assoc ::count-sql [sql:get-files-per-project project-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: COMMENT-THREADS-PER-FILE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-comment-threads-per-file "select count(*) as total from comment_thread as ct where ct.file_id = ?") (s/def ::comment-threads-per-file (s/keys :req [::profile-id ::project-id ::team-id ::target])) (defmethod check-quote ::comment-threads-per-file [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] (us/assert! ::files-per-project quote) (-> quote (assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id profile-id team-id profile-id profile-id]) (assoc ::count-sql [sql:get-comment-threads-per-file file-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: COMMENTS-PER-FILE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private sql:get-comments-per-file "select count(*) as total from comment as c join comment_thread as ct on (ct.id = c.thread_id) where ct.file_id = ?") (s/def ::comments-per-file (s/keys :req [::profile-id ::project-id ::team-id ::target])) (defmethod check-quote ::comments-per-file [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] (us/assert! ::files-per-project quote) (-> quote (assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id profile-id team-id profile-id profile-id]) (assoc ::count-sql [sql:get-comments-per-file file-id]) (generic-check!))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defmethod check-quote :default [{:keys [::id]}] (ex/raise :type :internal :code :quote-not-defined :quote id :hint "backend using a quote identifier not defined"))