mirror of
https://github.com/penpot/penpot.git
synced 2026-05-20 15:33:43 +00:00
this allows almost all api operations to success usin application/json encoding with the exception of the update-file, which we need to approach a bit differently; the reason update-file is different, is because the operations vector is right now defined without the context of shape type, so we are just unable to properly parse the value to correct type using the schema decoding mechanism
363 lines
13 KiB
Clojure
363 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
|
|
(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"))
|