0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-25 06:01:46 -05:00

🎉 Add usage quotes

This commit is contained in:
Andrey Antukh 2022-12-27 20:04:41 +01:00
parent 941aa6ad5d
commit 73a3e0c0ae
20 changed files with 914 additions and 68 deletions

View file

@ -102,7 +102,7 @@
(s/def ::audit-log-archive-uri ::us/string)
(s/def ::audit-log-http-handler-concurrency ::us/integer)
(s/def ::admins ::us/set-of-strings)
(s/def ::admins ::us/set-of-valid-emails)
(s/def ::file-change-snapshot-every ::us/integer)
(s/def ::file-change-snapshot-timeout ::dt/duration)
@ -130,6 +130,14 @@
(s/def ::database-min-pool-size ::us/integer)
(s/def ::database-max-pool-size ::us/integer)
(s/def ::quotes-teams-per-profile ::us/integer)
(s/def ::quotes-projects-per-team ::us/integer)
(s/def ::quotes-invitations-per-team ::us/integer)
(s/def ::quotes-profiles-per-team ::us/integer)
(s/def ::quotes-files-per-project ::us/integer)
(s/def ::quotes-files-per-team ::us/integer)
(s/def ::quotes-font-variants-per-team ::us/integer)
(s/def ::default-blob-version ::us/integer)
(s/def ::error-report-webhook ::us/string)
(s/def ::user-feedback-destination ::us/string)
@ -272,6 +280,15 @@
::profile-complaint-max-age
::profile-complaint-threshold
::public-uri
::quotes-teams-per-profile
::quotes-projects-per-team
::quotes-invitations-per-team
::quotes-profiles-per-team
::quotes-files-per-project
::quotes-files-per-team
::quotes-font-variants-per-team
::redis-uri
::registration-domain-whitelist
::rpc-rlimit-config
@ -311,7 +328,8 @@
[:enable-backend-api-doc
:enable-backend-worker
:enable-secure-session-cookies
:enable-email-verification])
:enable-email-verification
:enable-quotes])
(defn- parse-flags
[config]

View file

@ -167,6 +167,7 @@
(instance? javax.sql.DataSource v))
(s/def ::pool pool?)
(s/def ::conn-or-pool some?)
(defn closed?
[pool]

View file

@ -257,15 +257,17 @@
"Schedule an already defined email to be sent using asynchronously
using worker task."
[{:keys [::conn ::factory] :as context}]
(us/verify fn? factory)
(us/verify some? conn)
(let [email (factory context)]
(wrk/submit! (assoc email
::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn))))
(let [email (if factory
(factory context)
(dissoc context ::conn))]
(wrk/submit! (merge
{::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn}
email))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SENDMAIL FN / TASK HANDLER

View file

@ -302,7 +302,10 @@
{:name "0097-mod-profile-table"
:fn (mg/resource "app/migrations/sql/0097-mod-profile-table.sql")}
])
{:name "0098-add-quotes-table"
:fn (mg/resource "app/migrations/sql/0098-add-quotes-table.sql")}
])
(defmethod ig/init-key ::migrations [_ _] migrations)

View file

@ -0,0 +1,82 @@
CREATE TABLE usage_quote (
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
target text NOT NULL,
quote bigint NOT NULL,
profile_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
project_id uuid NULL REFERENCES project(id) ON DELETE CASCADE DEFERRABLE,
team_id uuid NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
file_id uuid NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE
);
ALTER TABLE usage_quote
ALTER COLUMN target SET STORAGE external;
CREATE INDEX usage_quote__profile_id__idx ON usage_quote(profile_id, target);
CREATE INDEX usage_quote__project_id__idx ON usage_quote(project_id, target);
CREATE INDEX usage_quote__team_id__idx ON usage_quote(team_id, target);
-- DROP TABLE IF EXISTS usage_quote_test;
-- CREATE TABLE usage_quote_test (
-- id bigserial NOT NULL PRIMARY KEY,
-- target text NOT NULL,
-- quote bigint NOT NULL,
-- profile_id bigint NULL,
-- team_id bigint NULL,
-- project_id bigint NULL,
-- file_id bigint NULL
-- );
-- ALTER TABLE usage_quote_test
-- ALTER COLUMN target SET STORAGE external;
-- CREATE INDEX usage_quote_test__profile_id__idx ON usage_quote_test(profile_id, target);
-- CREATE INDEX usage_quote_test__project_id__idx ON usage_quote_test(project_id, target);
-- CREATE INDEX usage_quote_test__team_id__idx ON usage_quote_test(team_id, target);
-- -- CREATE INDEX usage_quote_test__target__idx ON usage_quote_test(target);
-- DELETE FROM usage_quote_test;
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 50*RANDOM(), 2000*RANDOM(), null, null
-- FROM generate_series(1, 5000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 200*RANDOM(), 300*RANDOM(), 300*RANDOM(), null
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), null, 300*RANDOM()
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), 300*RANDOM(), 300*RANDOM()
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 30*RANDOM(), null, 2000*RANDOM(), null
-- FROM generate_series(1, 5000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 10*RANDOM(), null, null, 2000*RANDOM()
-- FROM generate_series(1, 5000);
-- VACUUM ANALYZE usage_quote_test;
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and profile_id = 1
-- and team_id is null
-- and project_id is null;
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and ((team_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (profile_id = 1 and team_id is null and project_id is null));
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and ((project_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (team_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (profile_id = 1 and team_id is null and project_id is null));

View file

@ -18,6 +18,7 @@
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.queries.projects :as proj]
[app.rpc.quotes :as quotes]
[app.util.blob :as blob]
[app.util.objects-map :as omap]
[app.util.pointer-map :as pmap]
@ -84,6 +85,12 @@
(proj/check-edition-permissions! conn profile-id project-id)
(let [team-id (files/get-team-id conn project-id)
params (assoc params :profile-id profile-id)]
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/files-per-project
::quotes/team-id team-id
::quotes/profile-id profile-id
::quotes/project-id project-id}))
(-> (create-file conn params)
(vary-meta assoc ::audit/props {:team-id team-id})))))

View file

@ -23,6 +23,7 @@
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.queries.profile :as profile]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.tokens :as tokens]
[app.util.services :as sv]
@ -297,6 +298,9 @@
{::doc/added "1.17"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(db/with-atomic [conn pool]
(quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(create-team conn (assoc params :profile-id profile-id))))
(defn create-team
@ -739,6 +743,17 @@
team (db/get-by-id conn :team team-id)
emails (cond-> (or emails #{}) (string? email) (conj email))]
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/invitations-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
(when-not (:is-admin perms)
(ex/raise :type :validation
:code :insufficient-permissions))
@ -785,6 +800,18 @@
:role role}))
(run! (partial create-invitation cfg)))
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id}
{::quotes/id ::quotes/invitations-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
(-> team
(vary-meta assoc ::audit/props {:invitations (count emails)})
(rph/with-defer

View file

@ -16,6 +16,7 @@
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.queries.profile :as profile]
[app.rpc.quotes :as quotes]
[app.tokens :as tokens]
[app.tokens.spec.team-invitation :as-alias spec.team-invitation]
[app.util.services :as sv]
@ -96,6 +97,11 @@
(ex/raise :type :restriction
:code :profile-blocked))
(quotes/check-quote! conn
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true})

View file

@ -18,6 +18,7 @@
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
@ -49,6 +50,9 @@
[{: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)
(quotes/check-quote! pool {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(create-font-variant cfg params)))
(defn create-font-variant

View file

@ -14,6 +14,7 @@
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.queries.projects :as proj]
[app.rpc.quotes :as quotes]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s]))
@ -37,6 +38,10 @@
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [project (teams/create-project conn params)]
(teams/create-project-role conn profile-id (:id project) :owner)

View file

@ -0,0 +1,327 @@
;; 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.exceptions :as ex]
[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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::conn ::db/conn-or-pool)
(s/def ::file-id ::us/uuid)
(s/def ::team-id ::us/uuid)
(s/def ::project-id ::us/uuid)
(s/def ::profile-id ::us/uuid)
(s/def ::incr (s/and int? pos?))
(s/def ::target ::us/string)
(s/def ::quote
(s/keys :req [::id ::profile-id]
:opt [::conn
::team-id
::project-id
::file-id
::incr]))
(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!
[conn quote]
(us/assert! ::db/conn-or-pool conn)
(us/assert! ::quote quote)
(when (contains? cf/flags :quotes)
(when @enabled
(check-quote (assoc quote ::conn conn ::target (name (::id quote)))))))
(defn- send-notification!
[{:keys [::conn] :as params}]
(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! {::wrk/task :sendmail
::wrk/delay (dt/duration "30s")
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn
::wrk/dedupe true
::wrk/label "quotes-notification"
:to (vec admins)
:subject subject
:body [{:type "text/plain"
:content content}]}))))
(defn- generic-check!
[{:keys [::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: 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 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"))

View file

@ -59,7 +59,8 @@
(def default-flags
[:enable-secure-session-cookies
:enable-email-verification
:enable-smtp])
:enable-smtp
:enable-quotes])
(defn state-init
[next]

View file

@ -6,52 +6,56 @@
(ns backend-tests.rpc-font-test
(:require
[backend-tests.helpers :as th]
[app.common.uuid :as uuid]
[app.db :as db]
[app.http :as http]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]))
[datoteka.io :as io]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest ttf-font-upload-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
(with-mocks [mock {:target 'app.rpc.quotes/check-quote! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
io/input-stream
io/read-as-bytes)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
io/input-stream
io/read-as-bytes)
params {::th/type :create-font-variant
:profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" ttfdata}}
out (th/mutation! params)]
params {::th/type :create-font-variant
:profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" ttfdata}}
out (th/mutation! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))))
(t/is (= 1 (:call-count @mock)))
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k)
(get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style)))))
(t/deftest ttf-font-upload-2
(let [prof (th/create-profile* 1 {:is-active true})

View file

@ -0,0 +1,344 @@
;; 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 backend-tests.rpc-quotes-test
(:require
[app.common.uuid :as uuid]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.rpc.cond :as cond]
[app.rpc.quotes :as-alias quotes]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.core :as fs]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest teams-per-profile-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-teams-per-profile 2})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
data {::th/type :create-team
::rpc/profile-id (:id profile-1)}
check-ok! (fn [n]
(let [data (assoc data :name (str "team" n))
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
check-ko! (fn [n]
(let [data (assoc data :name (str "team" n))
out (th/command! data)]
;; (th/print-result! out)
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "teams-per-profile" (:target (ex-data error)))))))]
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "teams-per-profile"
:quote 100})
(check-ok! 1)
(check-ko! 2)
(th/db-insert! :usage-quote
{:profile-id (:id profile-1)
:target "teams-per-profile"
:quote 3})
(check-ok! 2)
(check-ko! 3))))
(t/deftest projects-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-projects-per-team 2})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
team-id (:default-team-id profile-1)
data {::th/type :create-project
:profile-id (:id profile-1)
:team-id team-id}
check-ok! (fn [name]
(let [data (assoc data :name (str "project" name))
out (th/mutation! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
check-ko! (fn [name]
;; create second project
(let [data (assoc data :name (str "project" name))
out (th/mutation! data)]
;; (th/print-result! out)
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "projects-per-team" (:target (ex-data error)))))))]
(check-ok! 1)
(check-ko! 2)
(th/db-insert! :usage-quote
{:team-id team-id
:target "projects-per-team"
:quote 3})
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-2)
:target "projects-per-team"
:quote 10})
(check-ok! 2)
(check-ko! 3)
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-1)
:target "projects-per-team"
:quote 4})
(check-ok! 3)
(check-ko! 4)
(th/db-insert! :usage-quote
{:profile-id (:id profile-1)
:target "projects-per-team"
:quote 5})
(check-ok! 4)
(check-ko! 5)
)))
(t/deftest invitations-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-invitations-per-team 2})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
data {::th/type :create-team-invitations
::rpc/profile-id (:id profile-1)
:team-id (:default-team-id profile-1)
:role :editor}
check-ok! (fn [n]
(let [data (assoc data :emails [(str "foo" n "@example.net")])
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
check-ko! (fn [n]
(let [data (assoc data :emails [(str "foo" n "@example.net")])
out (th/command! data)]
;; (th/print-result! out)
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "invitations-per-team" (:target (ex-data error)))))))]
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "invitations-per-team"
:quote 100})
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-2)
:target "invitations-per-team"
:quote 100})
(check-ok! 1)
(check-ok! 2)
(check-ko! 3)
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-1)
:target "invitations-per-team"
:quote 3})
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-1)
:profile-id (:id profile-2)
:target "invitations-per-team"
:quote 100})
(check-ok! 3)
(check-ko! 4)
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-1)
:profile-id (:id profile-1)
:target "invitations-per-team"
:quote 4})
(check-ok! 4)
(check-ko! 5)
(th/db-insert! :usage-quote
{:profile-id (:id profile-1)
:target "invitations-per-team"
:quote 5})
(check-ok! 5)
(check-ko! 6))))
(t/deftest profiles-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-profiles-per-team 3})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
data {::th/type :create-team-invitations
::rpc/profile-id (:id profile-1)
:team-id (:default-team-id profile-1)
:role :editor}
check-ok! (fn [n]
(let [data (assoc data :emails [(str "foo" n "@example.net")])
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
check-ko! (fn [n]
(let [data (assoc data :emails [(str "foo" n "@example.net")])
out (th/command! data)]
;; (th/print-result! out)
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "profiles-per-team" (:target (ex-data error)))))))]
(th/create-team-role* {:team-id (:default-team-id profile-1)
:profile-id (:id profile-2)
:role :admin})
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "profiles-per-team"
:quote 100})
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-2)
:target "profiles-per-team"
:quote 100})
(check-ok! 1)
(check-ko! 2)
(th/db-insert! :usage-quote
{:team-id (:default-team-id profile-1)
:target "profiles-per-team"
:quote 4})
(check-ok! 2)
(check-ko! 3))))
(t/deftest files-per-project-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-files-per-project 1})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
project-1 (th/create-project* 1 {:profile-id (:id profile-1)
:team-id (:default-team-id profile-1)})
project-2 (th/create-project* 2 {:profile-id (:id profile-2)
:team-id (:default-team-id profile-2)})
data {::th/type :create-file
::rpc/profile-id (:id profile-1)
:project-id (:id project-1)}
check-ok! (fn [n]
(let [data (assoc data :name (str "file" n))
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (some? (:result out)))))
check-ko! (fn [n]
(let [data (assoc data :name (str "file" n))
out (th/command! data)]
;; (th/print-result! out)
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "files-per-project" (:target (ex-data error)))))))]
(th/db-insert! :usage-quote
{:project-id (:id project-2)
:target "files-per-project"
:quote 100})
(th/db-insert! :usage-quote
{:team-id (:team-id project-2)
:target "files-per-project"
:quote 100})
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "files-per-project"
:quote 100})
(check-ok! 1)
(check-ko! 2)
(th/db-insert! :usage-quote
{:project-id (:id project-1)
:target "files-per-project"
:quote 2})
(th/db-insert! :usage-quote
{:project-id (:id project-1)
:profile-id (:id profile-2)
:target "files-per-project"
:quote 100})
(check-ok! 2)
(check-ko! 3)
(th/db-insert! :usage-quote
{:team-id (:team-id project-1)
:target "files-per-project"
:quote 3})
(th/db-insert! :usage-quote
{:team-id (:team-id project-1)
:profile-id (:id profile-2)
:target "files-per-project"
:quote 100})
(check-ok! 3)
(check-ko! 4)
(th/db-insert! :usage-quote
{:profile-id (:id profile-1)
:target "files-per-project"
:quote 4})
(check-ok! 4)
(check-ko! 5)
)))

View file

@ -249,14 +249,6 @@
(s/with-gen (s/and string? #(not (str/empty? %)))
#(tgen/such-that (complement str/empty?) (s/gen ::string))))
(s/def ::url ::string)
(s/def ::fn fn?)
(s/def ::id ::uuid)
(s/def ::set-of-string (s/every ::string :kind set?))
(s/def ::coll-of-uuid (s/every ::uuid))
(s/def ::set-of-uuid (s/every ::uuid :kind set?))
#?(:clj
(s/def ::agent #(instance? clojure.lang.Agent %)))
@ -300,6 +292,13 @@
(s/with-gen safe-number? #(tgen/one-of [(s/gen ::safe-integer)
(s/gen ::safe-float)])))
(s/def ::url ::string)
(s/def ::fn fn?)
(s/def ::id ::uuid)
(s/def ::some some?)
(s/def ::coll-of-uuid (s/every ::uuid))
(s/def ::set-of-uuid (s/every ::uuid :kind set?))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MACROS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -247,7 +247,7 @@
(defn top-nested-frame-ids
"Search the top nested frame in a list of ids"
[objects ids]
(let [frame-ids (->> ids (filter #(cph/frame-shape? objects %)))
frame-set (set frame-ids)]
(loop [current-id (first frame-ids)]
@ -296,11 +296,14 @@
[p1 (+ 1 (d/parse-integer p2))]
[basename 1]))
(s/def ::set-of-strings
(s/every ::us/string :kind set?))
(defn generate-unique-name
"A unique name generator"
[used basename]
(s/assert ::us/set-of-string used)
(s/assert ::us/string basename)
(us/assert! ::set-of-strings used)
(us/assert! ::us/string basename)
(if-not (contains? used basename)
basename
(let [[prefix initial] (extract-numeric-suffix basename)]
@ -355,8 +358,8 @@
[new-object new-objects updated-objects])
(let [child-id (first child-ids)
child (get objects child-id)
_ (us/assert some? child)
child (get objects child-id)
_ (us/assert! ::us/some child)
[new-child new-child-objects updated-child-objects]
(clone-object child new-id objects update-new-object update-original-object)]

View file

@ -189,7 +189,6 @@
(s/def ::file-change-event
(s/keys :req-un [::type ::profile-id ::file-id ::session-id ::revn ::changes]))
(defn handle-file-change
[{:keys [file-id changes] :as msg}]
(us/assert ::file-change-event msg)

View file

@ -173,20 +173,18 @@
(cond
(= :feature-mismatch code)
(let [message (tr "errors.feature-mismatch" (:feature error))]
(st/emit! (modal/show
{:type :alert
:message message
:on-accept #(prn "kaka")})))
(st/emit! (modal/show {:type :alert :message message})))
(= :features-not-supported code)
(let [message (tr "errors.feature-not-supported" (:feature error))]
(st/emit! (modal/show
{:type :alert
:message message
:on-accept #(prn "kaka")})))
(st/emit! (modal/show {:type :alert :message message})))
(= :max-quote-reached code)
(let [message (tr "errors.max-quote-reached" (:target error))]
(st/emit! (modal/show {:type :alert :message message})))
:else
(ptk/handle-error (assoc error :type :server-error))))
(ptk/handle-error {:type :server-error :data error})))
;; This happens when the backed server fails to process the
;; request. This can be caused by an internal assertion or any other

View file

@ -765,6 +765,10 @@ msgstr "Your browser cannot do this operation"
msgid "errors.feature-not-supported"
msgstr "Feature '%s' is not supported."
#: src/app/main/errors.cljs
msgid "errors.max-quote-reached"
msgstr "You have reached the '%s' quote. Contact with support."
#: src/app/main/errors.cljs
msgid "errors.feature-mismatch"
msgstr "Looks like you are opening a file that has the feature '%s' enabled bug your penpot frontend does not supports it or has it disabled."

View file

@ -743,6 +743,18 @@ msgstr "Webhook modificado con éxito"
msgid "dashboard.webhooks.create.success"
msgstr "Webhook creado con éxito"
#: src/app/main/errors.cljs
msgid "errors.feature-not-supported"
msgstr "Caracteristica no soportada: '%s'."
#: src/app/main/errors.cljs
msgid "errors.max-quote-reached"
msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico."
#: src/app/main/errors.cljs
msgid "errors.feature-mismatch"
msgstr "Parece que esta abriendo un fichero con la caracteristica '%s' habilitada pero la aplicacion web de penpot que esta usando no tiene soporte para ella o esta deshabilitada."
msgid "errors.webhooks.timeout"
msgstr "Timeout"