mirror of
https://github.com/penpot/penpot.git
synced 2025-08-03 19:18:31 +02:00
361 lines
13 KiB
Clojure
361 lines
13 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 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
|
|
[: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"))
|