diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index ded9b5c9b8..c109b2afdb 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -51,4 +51,10 @@ {:permits 4} :import-binfile/by-profile + {:permits 1 :queue 2} + + :profile-plugin-ops/global + {:permits 4} + + :profile-plugin-ops/by-profile {:permits 1 :queue 2}} diff --git a/backend/resources/rlimit.edn b/backend/resources/rlimit.edn index 86247690f1..246992fa8e 100644 --- a/backend/resources/rlimit.edn +++ b/backend/resources/rlimit.edn @@ -221,6 +221,8 @@ ;; ═══════════════════════════════════════════════ #{:main/update-profile :main/update-profile-props + :main/add-profile-plugin + :main/remove-profile-plugin :main/update-profile-photo :main/update-profile-password :main/update-profile-notifications diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index a9bc88e7eb..c4f097c373 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -165,6 +165,9 @@ [:binfile-import-max-text-total-size {:optional true} ::sm/int] [:binfile-import-max-zip-entries {:optional true} ::sm/int] + ;; Max serialized size of profile props in bytes (default 2 MiB) + [:profile-props-max-size {:optional true} ::sm/int] + [:login-lockout-max-attempts {:optional true} ::sm/int] [:login-lockout-window {:optional true} ::ct/duration] diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj index 4d6b8eb8c6..8f01b3b521 100644 --- a/backend/src/app/rpc/commands/plugins.clj +++ b/backend/src/app/rpc/commands/plugins.clj @@ -11,6 +11,7 @@ [app.common.types.plugins :as ctp] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) @@ -33,6 +34,8 @@ (sv/defmethod ::add-profile-plugin {::doc/added "2.18" + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] + [:profile-plugin-ops/global]] ::sm/params schema:add-profile-plugin ::sm/result ctp/schema:registry-entry ::db/transaction true} @@ -42,14 +45,22 @@ (let [profile (profile/get-profile conn profile-id ::db/for-update true) plugins (get-in profile [:props :plugins] {:ids [] :data {}}) plugin-id (:plugin-id plugin) - plugins (-> plugins - (update :ids #(vec (distinct (conj % plugin-id)))) - (assoc-in [:data plugin-id] plugin))] - (db/update! conn :profile - {:props (db/tjson (assoc (:props profile) :plugins plugins))} - {:id profile-id} - {::db/return-keys false}) - plugin)) + exists? (contains? (set (:ids plugins)) plugin-id)] + (when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins)) + (ex/raise :type :validation + :code :too-many-plugins + :hint "plugin registry exceeds maximum size")) + (let [plugins (-> plugins + (update :ids #(vec (distinct (conj % plugin-id)))) + (assoc-in [:data plugin-id] plugin)) + props (-> (:props profile) + (assoc :plugins plugins) + (profile/check-props-size))] + (db/update! conn :profile + {:props (db/tjson props)} + {:id profile-id} + {::db/return-keys false}) + plugin))) (def ^:private schema:remove-profile-plugin @@ -58,18 +69,28 @@ (sv/defmethod ::remove-profile-plugin {::doc/added "2.18" + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] + [:profile-plugin-ops/global]] ::sm/params schema:remove-profile-plugin ::sm/result :nil ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}] (let [profile (profile/get-profile conn profile-id ::db/for-update true) plugins (get-in profile [:props :plugins] {:ids [] :data {}}) - plugin-id-str (str plugin-id) - plugins (-> plugins - (update :ids #(vec (remove (partial = plugin-id-str) %))) - (update :data dissoc plugin-id-str))] - (db/update! conn :profile - {:props (db/tjson (assoc (:props profile) :plugins plugins))} - {:id profile-id} - {::db/return-keys false}) - nil)) + plugin-id-str (str plugin-id)] + (if-not (or (some #(= % plugin-id-str) (:ids plugins)) + (contains? (:data plugins) plugin-id-str)) + ;; Nothing to remove: no write + nil + (let [plugins (-> plugins + (update :ids #(vec (remove (partial = plugin-id-str) %))) + (update :data dissoc plugin-id-str)) + ;; Raises when the remaining props still exceed the size limit + props (-> (:props profile) + (assoc :plugins plugins) + (profile/check-props-size))] + (db/update! conn :profile + {:props (db/tjson props)} + {:id profile-id} + {::db/return-keys false}) + nil)))) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 68e93231cd..686884aeed 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -12,6 +12,7 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.transit :as t] [app.common.types.plugins :as ctp] [app.common.uuid :as uuid] [app.config :as cf] @@ -36,6 +37,7 @@ [cuerdas.core :as str])) (declare check-profile-existence!) +(declare check-props-size) (declare decode-row) (declare filter-props) (declare get-profile) @@ -283,7 +285,8 @@ props (-> (get profile :props) - (assoc :notifications notifications))] + (assoc :notifications notifications) + (check-props-size))] (db/update! conn :profile {:props (db/tjson props)} @@ -474,18 +477,46 @@ [:map {:title "update-profile-props"} [:props schema:props-writeable]]) +(def default-props-max-size + "Default total serialized size limit (in bytes) for profile props. + Overridable with the :profile-props-max-size config entry." + (* 1024 1024 2)) ;; 2 MiB + +(defn- props-size + "Returns the serialized size in UTF-8 bytes of the props map." + [props] + (if props + (alength ^bytes (t/encode props {:type :json-verbose})) + 0)) + +(defn check-props-size + "Raises :props-too-large when props exceed the total size limit. + Returns props unchanged so it can be threaded into the write. + + Used by the user-facing props writers; system writers (OIDC login, + management subscription) write fixed-key props and skip it." + [props] + (let [limit (cf/get :profile-props-max-size default-props-max-size) + size (props-size props)] + (when (> size limit) + (ex/raise :type :validation + :code :props-too-large + :hint "profile props exceed maximum size")) + props)) + (defn update-profile-props [{:keys [::db/conn] :as cfg} profile-id props] (let [profile (get-profile conn profile-id ::db/for-update true) - props (reduce-kv (fn [props k v] - ;; We don't accept namespaced keys - (if (simple-ident? k) - (if (nil? v) - (dissoc props k) - (assoc props k v)) - props)) - (:props profile) - (apply dissoc props system-managed-props))] + props (->> (apply dissoc props system-managed-props) + (reduce-kv (fn [props k v] + ;; We don't accept namespaced keys + (if (simple-ident? k) + (if (nil? v) + (dissoc props k) + (assoc props k v)) + props)) + (:props profile)) + (check-props-size))] (db/update! conn :profile {:props (db/tjson props)} @@ -658,8 +689,41 @@ [props] (into {} (filter (fn [[k _]] (simple-ident? k))) props)) +(defn- truncate-string + "Cuts s to at most n chars without splitting a surrogate pair." + [^String s n] + (if (> (count s) n) + (let [n (if (Character/isHighSurrogate (.charAt s (dec n))) (dec n) n)] + (subs s 0 n)) + s)) + +(defn- clamp-plugin-entry + [entry] + (reduce-kv (fn [entry k n] + (let [v (get entry k)] + (if (and (string? v) (> (count v) n)) + (assoc entry k (truncate-string v n)) + entry))) + entry + ctp/registry-entry-max-lengths)) + +(defn clamp-plugins-registry + "Fits a plugin registry into the registry schema caps: keeps the + first `ctp/max-plugins` plugins with data and truncates the bounded + strings. Applied on read, so a stored registry over the caps cannot + keep the profile over the props size limit." + [{:keys [ids data] :as plugins}] + (let [ids (->> ids + (filter #(contains? data %)) + (distinct) + (take ctp/max-plugins) + (vec)) + data (update-vals (select-keys data ids) clamp-plugin-entry)] + (assoc plugins :ids ids :data data))) + (defn decode-row [{:keys [props] :as row}] (cond-> row (db/pgobject? props "jsonb") - (assoc :props (db/decode-transit-pgobject props)))) + (assoc :props (-> (db/decode-transit-pgobject props) + (d/update-when :plugins #(cond-> % (map? %) (clamp-plugins-registry))))))) diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj index cb895d4395..8c57931844 100644 --- a/backend/test/backend_tests/rpc_plugins_test.clj +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -6,7 +6,11 @@ (ns backend-tests.rpc-plugins-test (:require + [app.common.schema :as sm] + [app.common.types.plugins :as ctp] [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [backend-tests.helpers :as th] @@ -145,6 +149,140 @@ (t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name]))) (t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name])))))) +(t/deftest add-profile-plugin-rejects-oversized-code + ;; The merged props must not exceed :profile-props-max-size + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin :code (apply str (repeat 200 "x"))) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin}] + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + +(t/deftest add-profile-plugin-rejects-oversized-code-path + ;; :code holds a manifest path, not content: overlong values are + ;; rejected by the entry schema before the props size check runs + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin :code (apply str (repeat 501 "x"))) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest remove-profile-plugin-allowed-on-oversized-profile + ;; Removal shrinks props, so it passes even under a tight limit + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))] + ;; Seed an oversized registry while the limit is high + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})] + (let [out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin})] + (t/is (nil? (:error out))))) + ;; Removal under a tighter limit still passes: the seeded registry + ;; is oversized against it, but the remaining props fit + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 300})] + (let [out (th/command! {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/uuid plugin-id-1)})] + (t/is (nil? (:error out))))))) + +(t/deftest add-profile-plugin-rejects-51st-plugin + ;; The registry holds at most 50 plugins; the 51st (new id) must fail + (let [profile (th/create-profile* 1)] + ;; Seed 50 plugins + (doseq [i (range 50)] + (let [plugin (assoc valid-plugin + :plugin-id (str (uuid/next)) + :name (str "Plugin " i)) + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin})] + (t/is (nil? (:error out)) (str "seed plugin " i " should install")))) + ;; The 51st must fail with a specific error + (let [extra (assoc valid-plugin + :plugin-id (str (uuid/next)) + :name "One Too Many") + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin extra})] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :too-many-plugins))) + ;; And nothing extra was persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (= 50 (count (get-in props [:props :plugins :ids]))))))) + +(t/deftest add-profile-plugin-updates-existing-at-limit + ;; Re-adding an existing id at the limit is an update, not a new entry + (let [profile (th/create-profile* 1) + ids (mapv (fn [_] (str (uuid/next))) (range 50))] + (doseq [[i pid] (map-indexed vector ids)] + (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))})) + (let [out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})] + (t/is (nil? (:error out))) + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (= 50 (count (get-in props [:props :plugins :ids])))) + (t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name]))))))) + +(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size + ;; A full registry plus oversized content reports the count guard, + ;; which runs before the size check + (let [profile (th/create-profile* 1)] + ;; Seed 50 plugins under a generous limit + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})] + (doseq [i (range 50)] + (let [plugin (assoc valid-plugin + :plugin-id (str (uuid/next)) + :name (str "Plugin " i)) + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin})] + (t/is (nil? (:error out)) (str "seed plugin " i " should install"))))) + ;; Tight limit + 51st small plugin: count wins over size + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [extra (assoc valid-plugin + :plugin-id (str (uuid/next)) + :name "One Too Many") + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin extra})] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :too-many-plugins)))) + ;; And nothing extra was persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (= 50 (count (get-in props [:props :plugins :ids]))))))) + +(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins + ;; Removing an absent id changes nothing: no write, no size failure, + ;; and no :plugins key is manufactured + (let [profile (th/create-profile* 1) + big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}] + (th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [out (th/command! {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/next)})] + (t/is (nil? (:error out))))) + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (nil? (get-in props [:props :plugins]))) + (t/is (= big (get props :props)))))) + (t/deftest update-profile-props-rejects-plugins (let [profile (th/create-profile* 1) data {::th/type :update-profile-props @@ -160,3 +298,86 @@ props (profile/decode-row saved)] (t/is (nil? (get-in props [:props :plugins])) ":plugins must not be writable via update-profile-props")))) + +;; --- Stored registries over the caps + +(defn- legacy-entry + [plugin-id & {:as attrs}] + (merge valid-plugin {:plugin-id plugin-id :code "plugin.js"} attrs)) + +(defn- legacy-registry + [entries] + {:ids (mapv :plugin-id entries) + :data (into {} (map (juxt :plugin-id identity)) entries)}) + +(defn- stored-props + "Props as stored in the database, without the read-time clamping." + [profile-id] + (db/decode-transit-pgobject (:props (th/db-get :profile {:id profile-id})))) + +(t/deftest get-profile-clamps-legacy-registry + (let [profile (th/create-profile* 1) + big (apply str (repeat 10000 "x")) + ;; "😀" is two chars, so char 500 is a high surrogate + emoji (str (apply str (repeat 499 "n")) "😀") + entries (into [(legacy-entry plugin-id-1 + :name emoji + :description big + :host big + :code big + :icon big)] + (map #(legacy-entry (str "extra-" %))) + (range 60)) + stored (-> (legacy-registry entries) + (update :ids conj "dangling"))] + (th/db-update! :profile {:props (db/tjson {:plugins stored :renderer :wasm})} + {:id (:id profile)}) + (let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)}) + props (get-in out [:result :props]) + plugins (:plugins props) + entry (get-in plugins [:data plugin-id-1])] + (t/is (nil? (:error out))) + (t/is (= :wasm (:renderer props)) "other props are kept") + (t/is (= (mapv :plugin-id (take ctp/max-plugins entries)) (:ids plugins)) + "keeps the first plugins in registry order, drops ids without data") + (t/is (= (set (:ids plugins)) (set (keys (:data plugins))))) + (t/is (= (apply str (repeat 499 "n")) (:name entry)) "does not split a surrogate pair") + (doseq [k [:description :host :code :icon]] + (t/is (= (get ctp/registry-entry-max-lengths k) (count (get entry k))) + (str k " truncated to its cap"))) + (t/is (sm/validate ctp/schema:plugin-registry plugins))) + (t/is (= stored (:plugins (stored-props (:id profile)))) + "reading does not write"))) + +(t/deftest get-profile-keeps-valid-registry + (let [profile (th/create-profile* 1) + stored (legacy-registry [(legacy-entry plugin-id-1)])] + (th/db-update! :profile {:props (db/tjson {:plugins stored})} {:id (:id profile)}) + (let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})] + (t/is (= stored (get-in out [:result :props :plugins])))))) + +(t/deftest oversized-legacy-profile-recovers-on-write + ;; A stored registry over the caps pushes props past the size limit; + ;; the clamped read lets writes through and saves the clamped registry + (let [profile (th/create-profile* 1) + code (apply str (repeat 3000 "c")) + entries [(legacy-entry plugin-id-1 :code code) + (legacy-entry plugin-id-2 :code code)]] + (th/db-update! :profile {:props (db/tjson {:plugins (legacy-registry entries)})} + {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 5000})] + (t/is (thrown? Exception (profile/check-props-size (stored-props (:id profile)))) + "the stored props exceed the limit") + (let [out (th/command! {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:workspace-visited true}})] + (t/is (nil? (:error out)))) + (let [out (th/command! {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/uuid plugin-id-1)})] + (t/is (nil? (:error out))))) + (let [props (stored-props (:id profile))] + (t/is (true? (:workspace-visited props))) + (t/is (= [plugin-id-2] (get-in props [:plugins :ids]))) + (t/is (= 500 (count (get-in props [:plugins :data plugin-id-2 :code]))) + "the write saved the clamped registry")))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index a2750beb12..1bfb54aeee 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -1549,6 +1549,111 @@ (t/is (th/ex-of-code? (:error out) :params-validation)))) +(t/deftest update-profile-props-rejects-oversized-props + ;; The merged props must not exceed :profile-props-max-size + (let [profile (th/create-profile* 1)] + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + + +(t/deftest update-profile-props-enforces-hard-limit-on-oversized-profile + ;; An already-oversized profile can only write back under the limit: + ;; shrinking below it passes, staying above it fails + (let [profile (th/create-profile* 1) + big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}] + ;; Seed an already-oversized profile directly in DB (bypasses RPC validation) + (th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + ;; Shrinking below the limit passes + (let [data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-questions {:big-blob "small"}}} + out (th/command! data)] + (t/is (nil? (:error out)))) + ;; Staying above the limit fails + (let [data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + + +(t/deftest check-props-size-measures-bytes-not-chars + ;; The limit is in UTF-8 bytes: multibyte content that fits in chars + ;; but exceeds the byte limit must be rejected + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})] + ;; 70 ASCII chars (~87 bytes serialized) passes and returns props unchanged + (let [props {:blob (apply str (repeat 70 "x"))}] + (t/is (= props (profile/check-props-size props)))) + ;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises + (try + (profile/check-props-size {:blob (apply str (repeat 70 "日"))}) + (t/is false "should have thrown") + (catch clojure.lang.ExceptionInfo e + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :props-too-large (:code (ex-data e)))))))) + +(t/deftest update-profile-props-rejects-steady-size-on-oversized-profile + ;; Same size (not smaller) on an oversized profile still exceeds + ;; the limit, so it fails + (let [profile (th/create-profile* 1) + big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}] + (th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + + +(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile + ;; The notifications write path goes through the same size check: + ;; growing an oversized profile fails + (let [profile (th/create-profile* 1) + big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}] + (th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [data {::th/type :update-profile-notifications + ::rpc/profile-id (:id profile) + :dashboard-comments :all + :email-comments :all + :email-invites :all} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + +(t/deftest update-profile-notifications-rejects-steady-on-oversized-profile + ;; Same-size notifications write on an oversized profile still exceeds + ;; the limit, so it fails + (let [profile (th/create-profile* 1) + notifications {:dashboard-comments :all + :email-comments :all + :email-invites :all} + big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))} + :notifications notifications}] + (th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)}) + (with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})] + (let [data (merge {::th/type :update-profile-notifications + ::rpc/profile-id (:id profile)} + notifications) + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :props-too-large)))))) + + (t/deftest prepare-register-profile-password-too-short (let [data {::th/type :prepare-register-profile :email "user@example.com" diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index bb74bfcc09..035be0efb3 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -41,21 +41,34 @@ "Schema for plugin permissions - a set of valid permission strings." [:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) +(def max-plugins + "Maximum number of plugins a profile can hold." + 50) + +(def registry-entry-max-lengths + "Maximum length (in chars) of the bounded registry entry strings. + `:code` and `:icon` hold manifest paths, not content." + {:name 500 + :description 4096 + :host 500 + :code 500 + :icon 500}) + (def schema:registry-entry [:map [:plugin-id :string] [:version {:optional true} :int] - [:name :string] - [:description {:optional true} :string] - [:host :string] - [:code :string] - [:icon {:optional true} :string] + [:name [:string {:max (:name registry-entry-max-lengths)}]] + [:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]] + [:host [:string {:max (:host registry-entry-max-lengths)}]] + [:code [:string {:max (:code registry-entry-max-lengths)}]] + [:icon {:optional true} [:string {:max (:icon registry-entry-max-lengths)}]] [:permissions schema:permissions]]) (def schema:plugin-registry [:map - [:ids [:vector :string]] + [:ids [:vector {:max max-plugins} :string]] [:data - [:map-of {:gen/max 5} + [:map-of {:gen/max 5 :max max-plugins} :string schema:registry-entry]]]) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 8cab260e59..728fafda6d 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -84,6 +84,7 @@ [common-tests.types.objects-map-test] [common-tests.types.organization-test] [common-tests.types.path-data-test] + [common-tests.types.plugins-test] [common-tests.types.shape-decode-encode-test] [common-tests.types.shape-interactions-test] [common-tests.types.shape-layout-test] @@ -167,6 +168,7 @@ 'common-tests.types.objects-map-test 'common-tests.types.organization-test 'common-tests.types.path-data-test + 'common-tests.types.plugins-test 'common-tests.types.shape-decode-encode-test 'common-tests.types.shape-interactions-test 'common-tests.types.shape-layout-test diff --git a/common/test/common_tests/types/plugins_test.cljc b/common/test/common_tests/types/plugins_test.cljc new file mode 100644 index 0000000000..ff4f2b65cf --- /dev/null +++ b/common/test/common_tests/types/plugins_test.cljc @@ -0,0 +1,64 @@ +;; 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 SUBSIDIARY SL + +(ns common-tests.types.plugins-test + (:require + [app.common.schema :as sm] + [app.common.types.plugins :as ctp] + [clojure.test :as t])) + +(def ^:private valid-entry + {:plugin-id "plugin-1" + :name "Test Plugin" + :description "A test plugin" + :host "https://example.com" + :code "(function() {})()" + :permissions #{"content:read"}}) + +(t/deftest registry-entry-accepts-valid-plugin + (t/is (true? (sm/validate ctp/schema:registry-entry valid-entry)))) + +(t/deftest registry-entry-rejects-oversized-code + (t/is (false? (sm/validate ctp/schema:registry-entry + (assoc valid-entry + :code (apply str (repeat 501 "x"))))))) + +(t/deftest registry-entry-rejects-oversized-name + (t/is (false? (sm/validate ctp/schema:registry-entry + (assoc valid-entry :name (apply str (repeat 501 "x"))))))) + +(t/deftest registry-entry-rejects-oversized-host + (t/is (false? (sm/validate ctp/schema:registry-entry + (assoc valid-entry :host (apply str (repeat 501 "x"))))))) + +(t/deftest registry-entry-rejects-oversized-description + (t/is (false? (sm/validate ctp/schema:registry-entry + (assoc valid-entry :description (apply str (repeat 4097 "x"))))))) + +(t/deftest registry-entry-rejects-oversized-icon + (t/is (false? (sm/validate ctp/schema:registry-entry + (assoc valid-entry :icon (apply str (repeat 501 "x"))))))) + +(t/deftest registry-entry-accepts-values-at-max + (t/is (true? (sm/validate ctp/schema:registry-entry + (assoc valid-entry + :name (apply str (repeat 500 "x")) + :host (apply str (repeat 500 "x")) + :description (apply str (repeat 4096 "x")) + :icon (apply str (repeat 500 "x")) + :code (apply str (repeat 500 "x"))))))) + +(defn- make-registry + [n] + (let [ids (mapv #(str "plugin-" %) (range n))] + {:ids ids + :data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))})) + +(t/deftest plugin-registry-accepts-fifty-plugins + (t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50))))) + +(t/deftest plugin-registry-rejects-more-than-fifty-plugins + (t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51))))) diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 0fa7fe5ffa..009f4c1c4e 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -274,6 +274,7 @@ hay hubo importada iniciarse +instalarla invitaciones lectores letras diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 056e10eecf..928722f889 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -719,6 +719,15 @@ :level :error :timeout 3000}))) + ;; Rejected profile write: notify and keep the app running + (= code :props-too-large) + (let [message (tr "errors.profile-props-too-large")] + (st/async-emit! + (ntf/show {:content message + :type :toast + :level :error + :timeout 5000}))) + (= code :snapshot-already-locked) (let [message (tr "errors.version-already-locked")] (st/async-emit! diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index 2f61a5bb16..d3662c3659 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -79,6 +79,7 @@ .modal-msg { @include use-typography("body-large"); + overflow-wrap: anywhere; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index f1340e9ca4..5a64cf76c9 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -806,7 +806,7 @@ :submenu-item true :disabled (not can-open?)) :on-key-down on-key-down} - [:span {:class (stl/css :item-name)} name] + [:span {:class (stl/css :item-name :plugin-name) :title name} name] (when-not can-open? [:span {:title (tr "workspace.plugins.error.need-editor")} [:> icon* {:icon-id i/help diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 1ff9364c6c..591e96b9c7 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -9,6 +9,7 @@ @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; .base-menu { position: absolute; @@ -70,6 +71,7 @@ } &.plugins { + max-width: $sz-364; max-height: calc(100vh - $sz-200); overflow: hidden auto; } @@ -120,6 +122,12 @@ grid-area: name; } +.plugin-name { + @include text-ellipsis; + + min-width: 0; +} + .item-indicator { --menu-indicator-color: var(--color-foreground-secondary); diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 41cf242d8d..a6ff1b34f0 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -56,7 +56,7 @@ icon)) (mf/defc plugin-entry* - [{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}] + [{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}] (let [{:keys [plugin-id host icon name description permissions]} manifest plugins-permissions-peek (deref refs/plugins-permissions-peek) @@ -86,8 +86,8 @@ (icon-url host icon) (avatars/generate {:name name}))}]] [:div {:class (stl/css :plugin-description)} - [:div {:class (stl/css :plugin-title)} name] - [:div {:class (stl/css :plugin-summary)} (d/nilv description "")]] + [:div {:class (stl/css :plugin-title) :title name} name] + [:div {:class (stl/css :plugin-summary) :title description} (d/nilv description "")]] [:> button* {:class (stl/css :open-button) @@ -100,6 +100,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.plugins.remove-plugin") :on-click handle-delete-click + :disabled remove-disabled :icon i/delete}]])) (mf/defc plugin-management-dialog @@ -126,6 +127,10 @@ fetching-manifest? (mf/use-state false) + ;; Ids with a persist in flight; their remove button is disabled + in-flight* + (mf/use-state #{}) + on-url-change (mf/use-fn (fn [value] @@ -174,13 +179,35 @@ (mf/deps plugins-state) (fn [plugin-index] (let [plugins-list (preg/plugins-list) - plugin (nth plugins-list plugin-index)] - (st/emit! (ev/event {::ev/name "remove-plugin" - :name (:name plugin) - :host (:host plugin)})) - (dp/close-plugin! plugin) - (preg/remove-plugin! plugin) - (reset! plugins-state* (preg/plugins-list)))))] + plugin (nth plugins-list plugin-index) + plugin-name (:name plugin) + ;; Truncated so long names fit the confirm dialog + plugin-name (if (> (count plugin-name) 60) + (str (subs plugin-name 0 60) "…") + plugin-name)] + (modal/show! + {:type :confirm + :title (tr "workspace.plugins.remove-confirmation.title") + :message (tr "workspace.plugins.remove-confirmation.message" plugin-name) + :accept-label (tr "workspace.plugins.remove-plugin") + :on-accept (fn [_] + (st/emit! (ev/event {::ev/name "remove-plugin" + :name (:name plugin) + :host (:host plugin)})) + (dp/close-plugin! plugin) + (preg/remove-plugin! plugin) + (modal/show! :plugin-management {})) + :on-cancel (fn [_] + (modal/show! :plugin-management {}))}))))] + + (mf/with-effect [] + (let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))] + (partial preg/unsubscribe-in-flight! listener))) + + ;; Re-reads the list on every registry change, including rollbacks + (mf/with-effect [] + (let [listener (preg/subscribe-registry! #(reset! plugins-state* (preg/plugins-list)))] + (partial preg/unsubscribe-registry! listener))) [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-management)} @@ -236,6 +263,7 @@ :index idx :manifest manifest :user-can-edit user-can-edit? + :remove-disabled (contains? @in-flight* (:plugin-id manifest)) :on-open-plugin on-open-plugin :on-remove-plugin on-remove-plugin}])]])]]])) diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index b1aa38ff8e..c67d5ea498 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/mixins.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -15,6 +16,7 @@ display: grid; grid-template-rows: auto 1fr auto; + grid-template-columns: minmax(0, 1fr); max-height: initial; &.plugin-permissions { @@ -160,17 +162,21 @@ flex-direction: column; gap: deprecated.$s-8; width: 100%; + min-width: 0; } .plugin-title { @include deprecated.body-medium-typography; + @include text-ellipsis; color: var(--color-foreground-primary); } .plugin-summary { @include deprecated.body-small-typography; + @include two-line-text-ellipsis; + overflow-wrap: anywhere; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 2a3720c65e..c70b4f9e7a 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -112,6 +112,20 @@ manifest (.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest)))))) +(defn subscribe-registry! + "Subscribes f, called with no arguments on every registry change. + Returns f." + [f] + (add-watch registry f (fn [_ _ old new] + (when-not (identical? old new) + (f)))) + f) + +(defn unsubscribe-registry! + [f] + (remove-watch registry f) + nil) + (defn load-from-store [] (reset! registry (get-in @st/state [:profile :props :plugins] {}))) @@ -122,36 +136,128 @@ (declare remove-plugin!) +;; Plugin ids with a persist in flight; install/remove calls on them are skipped +(defonce ^:private in-flight (atom #{})) + +(defonce ^:private in-flight-listeners (atom #{})) + +(defn subscribe-in-flight! + "Subscribes f, called with the in-flight id set on every change. + Calls f immediately with the current set. Returns f." + [f] + (swap! in-flight-listeners conj f) + (f @in-flight) + f) + +(defn unsubscribe-in-flight! + [f] + (swap! in-flight-listeners disj f) + nil) + +(defn- notify-in-flight! + [] + (let [ids @in-flight] + (doseq [f @in-flight-listeners] + (f ids)))) + +(defn- track! + [plugin-id] + (swap! in-flight conj plugin-id) + (notify-in-flight!)) + +(defn- release! + [plugin-id] + (swap! in-flight disj plugin-id) + (notify-in-flight!)) + +(defn- validation-error? + [err] + (= :validation (:type (ex-data err)))) + +(defn- drop-local! + [{:keys [plugin-id]}] + (swap! registry #(-> % + (update :ids (fn [ids] (vec (remove (partial = plugin-id) ids)))) + (update :data dissoc plugin-id)))) + +(defn- insert-at + [ids idx id] + (let [v (vec ids) + idx (max 0 (min idx (count v)))] + (vec (concat (subvec v 0 idx) [id] (subvec v idx))))) + +(defn- restore-local! + "Puts the stored plugin back into the registry at position idx." + [{:keys [plugin-id] :as plugin} idx] + (swap! registry #(-> % + (update :ids (fn [ids] + (insert-at (remove (partial = plugin-id) ids) + idx + plugin-id))) + (assoc-in [:data plugin-id] plugin)))) + (defn install-plugin! [plugin] - (letfn [(update-ids [ids] - (conj - (->> ids (remove #(= % (:plugin-id plugin)))) - (:plugin-id plugin)))] - (swap! registry #(-> % - (update :ids update-ids) - (update :data assoc (:plugin-id plugin) plugin))) - (->> (rp/cmd! :add-profile-plugin {:plugin plugin}) - (rx/subs! identity - (fn [err] - (remove-plugin! plugin) - (.error js/console "Failed to install plugin:" err)))))) + (let [plugin-id (:plugin-id plugin) + previous (get-plugin plugin-id) + prev-idx (.indexOf (vec (:ids @registry)) plugin-id)] + (when-not (contains? @in-flight plugin-id) + (track! plugin-id) + (letfn [(update-ids [ids] + (conj + (->> ids (remove #(= % (:plugin-id plugin)))) + (:plugin-id plugin)))] + (swap! registry #(-> % + (update :ids update-ids) + (update :data assoc (:plugin-id plugin) plugin))) + (->> (rp/cmd! :add-profile-plugin {:plugin plugin}) + (rx/subs! (fn [_] + (release! plugin-id)) + (fn [err] + (release! plugin-id) + ;; Restore the previous version in place, else drop it + (if previous + (restore-local! previous prev-idx) + (drop-local! plugin)) + ;; Other failures may have reached the server: undo it + ;; once by re-saving the previous version or removing + ;; the new entry, without further rollback. + (when-not (validation-error? err) + (->> (if previous + (rp/cmd! :add-profile-plugin {:plugin previous}) + (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})) + (rx/subs! (fn [_] nil) + (fn [err2] + (.error js/console "Rollback failed:" err2))))) + (.error js/console "Failed to install plugin:" err)))))))) (defn remove-plugin! [{:keys [plugin-id]}] - (let [plugin (get-plugin plugin-id)] - (letfn [(update-ids [ids] - (->> ids - (remove #(= % plugin-id))))] - (swap! registry #(-> % - (update :ids update-ids) - (update :data dissoc plugin-id))) - (->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}) - (rx/subs! identity - (fn [err] - (when plugin - (install-plugin! plugin)) - (.error js/console "Failed to remove plugin:" err))))))) + (let [stored (get-plugin plugin-id) + prev-idx (.indexOf (vec (:ids @registry)) plugin-id)] + (when-not (contains? @in-flight plugin-id) + (track! plugin-id) + (letfn [(update-ids [ids] + (->> ids + (remove #(= % plugin-id))))] + (swap! registry #(-> % + (update :ids update-ids) + (update :data dissoc plugin-id))) + (->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}) + (rx/subs! (fn [_] + (release! plugin-id)) + (fn [err] + (release! plugin-id) + (when stored + ;; Restore in place; validation errors keep it server-side + (restore-local! stored prev-idx) + ;; Other failures: re-save it once, without further rollback + (when-not (validation-error? err) + (->> (rp/cmd! :add-profile-plugin {:plugin stored}) + (rx/subs! (fn [_] nil) + (fn [err2] + (.error js/console "Rollback install failed:" err2)))))) + (.error js/console "Failed to remove plugin:" err)))))))) (defn check-permission [plugin-id permission] diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs new file mode 100644 index 0000000000..55ddd1c4a6 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -0,0 +1,322 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.plugins.register-test + (:require + [app.main.repo :as rp] + [app.plugins.register :as preg] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(defn- record-cmd-mock + "Mock rp/cmd! that records calls in mock/rpc-calls and answers + with (response-fn cmd params)." + [response-fn] + (mock/stub + (fn [cmd params] + (swap! mock/rpc-calls conj {:cmd cmd :params params}) + (response-fn cmd params)))) + +(defn- cmds + [] + (mapv :cmd @mock/rpc-calls)) + +;; --- install-plugin! --- + +(t/deftest install-success-releases-id + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))} + (fn [done'] + (let [plugin {:plugin-id "reg-install-ok"}] + (preg/install-plugin! plugin) + (t/is (= [:add-profile-plugin] (cmds))) + (t/is (= plugin (preg/get-plugin "reg-install-ok"))) + ;; id released: a second install issues a second RPC + (preg/install-plugin! plugin) + (t/is (= 2 (count @mock/rpc-calls))) + (done'))) + done))) + +(t/deftest install-while-in-flight-issues-no-second-rpc + (t/async done + (let [subjects (atom [])] + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ _] + (let [sb (rx/subject)] + (swap! subjects conj sb) + sb)))} + (fn [done'] + (let [plugin {:plugin-id "reg-install-dedupe"}] + (preg/install-plugin! plugin) + (preg/install-plugin! plugin) + (t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped") + ;; complete the pending call; the id is released afterwards + (rx/push! (first @subjects) {:ok true}) + (preg/install-plugin! plugin) + (t/is (= 2 (count @mock/rpc-calls))) + (done'))) + done)))) + +(t/deftest install-validation-error-cleans-local-only + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))} + (fn [done'] + (let [plugin {:plugin-id "reg-install-validation"}] + (preg/install-plugin! plugin) + (t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection") + (t/is (nil? (preg/get-plugin "reg-install-validation"))) + ;; id released: the next install retries the RPC + (preg/install-plugin! plugin) + (t/is (= 2 (count @mock/rpc-calls))) + (done'))) + done))) + +(t/deftest install-validation-error-restores-previous-version + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ params] + (if (= "v2" (get-in params [:plugin :name])) + (rx/throw (ex-info "rejected" {:type :validation})) + (rx/of {:ok true}))))} + (fn [done'] + (let [v1 {:plugin-id "reg-install-prev" :name "v1"} + v2 {:plugin-id "reg-install-prev" :name "v2"}] + (preg/install-plugin! v1) + (preg/install-plugin! v2) + (t/is (= 2 (count @mock/rpc-calls))) + (t/is (= v1 (preg/get-plugin "reg-install-prev")) + "the server-kept version is restored, not dropped") + (done'))) + done))) + +(t/deftest install-validation-error-keeps-original-position + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ params] + (if (= "v2" (get-in params [:plugin :name])) + (rx/throw (ex-info "rejected" {:type :validation})) + (rx/of {:ok true}))))} + (fn [done'] + (let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"} + v1 {:plugin-id "reg-pos-b" :name "v1"} + v2 {:plugin-id "reg-pos-b" :name "v2"}] + (preg/install-plugin! {:plugin-id "reg-pos-a"}) + (preg/install-plugin! v1) + (preg/install-plugin! {:plugin-id "reg-pos-c"}) + (preg/install-plugin! v2) + ;; installs prepend, so newest-first; the rejected update + ;; must preserve order and restore v1 + (t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"] + (filterv own (mapv :plugin-id (preg/plugins-list))))) + (t/is (= v1 (preg/get-plugin "reg-pos-b"))) + (done'))) + done))) + +(t/deftest install-persistent-failure-terminates + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))} + (fn [done'] + (let [plugin {:plugin-id "reg-install-hang"}] + (preg/install-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)) + "one-shot rollback: no further calls") + (t/is (nil? (preg/get-plugin "reg-install-hang"))) + (done'))) + done))) + +(t/deftest install-non-validation-error-rolls-back-via-rpc + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :add-profile-plugin) + (rx/throw (ex-info "boom" {:type :other})) + (rx/of nil))))} + (fn [done'] + (let [plugin {:plugin-id "reg-install-rollback"}] + (preg/install-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))) + (t/is (nil? (preg/get-plugin "reg-install-rollback"))) + (done'))) + done))) + +(t/deftest install-non-validation-error-on-update-resaves-previous + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ params] + (if (= "v2" (get-in params [:plugin :name])) + (rx/throw (ex-info "busy" {:type :concurrency-limit})) + (rx/of {:ok true}))))} + (fn [done'] + (let [own #{"reg-upd-a" "reg-upd-b" "reg-upd-c"} + v1 {:plugin-id "reg-upd-b" :name "v1"} + v2 {:plugin-id "reg-upd-b" :name "v2"}] + (preg/install-plugin! {:plugin-id "reg-upd-a"}) + (preg/install-plugin! v1) + (preg/install-plugin! {:plugin-id "reg-upd-c"}) + (reset! mock/rpc-calls []) + (preg/install-plugin! v2) + (t/is (= [:add-profile-plugin :add-profile-plugin] (cmds)) + "the compensating write re-saves v1, never removes it") + (t/is (= v1 (get-in (second @mock/rpc-calls) [:params :plugin]))) + (t/is (= v1 (preg/get-plugin "reg-upd-b"))) + (t/is (= ["reg-upd-c" "reg-upd-b" "reg-upd-a"] + (filterv own (mapv :plugin-id (preg/plugins-list))))) + (done'))) + done))) + +;; --- remove-plugin! --- + +(t/deftest remove-success-releases-id + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))} + (fn [done'] + (let [plugin {:plugin-id "reg-remove-ok"}] + (preg/install-plugin! plugin) + (preg/remove-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))) + (t/is (nil? (preg/get-plugin "reg-remove-ok"))) + ;; id released: a second remove issues another RPC + (preg/remove-plugin! plugin) + (t/is (= 3 (count @mock/rpc-calls))) + (done'))) + done))) + +(t/deftest remove-while-in-flight-issues-no-second-rpc + (t/async done + (let [subjects (atom [])] + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :add-profile-plugin) + (rx/of {:ok true}) + (let [sb (rx/subject)] + (swap! subjects conj sb) + sb))))} + (fn [done'] + (let [plugin {:plugin-id "reg-remove-dedupe"}] + (preg/install-plugin! plugin) + (preg/remove-plugin! plugin) + (preg/remove-plugin! plugin) + (t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped") + ;; complete the pending call; the id is released afterwards + (rx/push! (first @subjects) nil) + (preg/remove-plugin! plugin) + (t/is (= 3 (count @mock/rpc-calls))) + (done'))) + done)))) + +(t/deftest remove-validation-error-restores-local-only + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :add-profile-plugin) + (rx/of {:ok true}) + (rx/throw (ex-info "rejected" {:type :validation})))))} + (fn [done'] + (let [plugin {:plugin-id "reg-remove-validation"}] + (preg/install-plugin! plugin) + (preg/remove-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)) + "no reinstall write after validation rejection") + (t/is (= plugin (preg/get-plugin "reg-remove-validation")) + "local entry is restored") + (done'))) + done))) + +(t/deftest remove-non-validation-error-reinstalls-via-rpc + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :remove-profile-plugin) + (rx/throw (ex-info "boom" {:type :other})) + (rx/of {:ok true}))))} + (fn [done'] + (let [plugin {:plugin-id "reg-remove-rollback"}] + (preg/install-plugin! plugin) + (preg/remove-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))) + (t/is (= plugin (preg/get-plugin "reg-remove-rollback"))) + (done'))) + done))) + +(t/deftest remove-persistent-failure-terminates + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :add-profile-plugin) + (rx/of {:ok true}) + (rx/throw (ex-info "boom" {:type :other})))))} + (fn [done'] + (let [plugin {:plugin-id "reg-remove-hang"}] + (preg/install-plugin! plugin) + (preg/remove-plugin! plugin) + (t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)) + "one-shot rollback: no further calls") + (t/is (= plugin (preg/get-plugin "reg-remove-hang"))) + (done'))) + done))) + +(t/deftest remove-validation-error-keeps-original-position + (t/async done + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [cmd _] + (if (= cmd :add-profile-plugin) + (rx/of {:ok true}) + (rx/throw (ex-info "rejected" {:type :validation})))))} + (fn [done'] + (let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"} + plugin {:plugin-id "reg-idx-b"}] + (preg/install-plugin! {:plugin-id "reg-idx-a"}) + (preg/install-plugin! plugin) + (preg/install-plugin! {:plugin-id "reg-idx-c"}) + (preg/remove-plugin! plugin) + ;; installs prepend, so the order is newest-first; + ;; the failed removal must preserve it exactly + (t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"] + (filterv own (mapv :plugin-id (preg/plugins-list))))) + (done'))) + done))) + +;; --- subscribe-registry! --- + +(t/deftest registry-subscriber-sees-rollback + (t/async done + (let [subjects (atom []) + seen (atom []) + listener (fn [] (swap! seen conj (some? (preg/get-plugin "reg-watch"))))] + (mock/with-mocks + {rp/cmd! (record-cmd-mock + (fn [_ _] + (let [sb (rx/subject)] + (swap! subjects conj sb) + sb)))} + (fn [done'] + (preg/subscribe-registry! listener) + (preg/install-plugin! {:plugin-id "reg-watch"}) + (rx/error! (first @subjects) (ex-info "rejected" {:type :validation})) + (t/is (= [true false] @seen) + "notified on the optimistic add and on the rollback") + (preg/unsubscribe-registry! listener) + (preg/install-plugin! {:plugin-id "reg-watch"}) + (t/is (= [true false] @seen) "no calls after unsubscribing") + (done')) + done)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b3764d977b..0937f7b732 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -72,6 +72,7 @@ [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] + [frontend-tests.plugins.register-test] [frontend-tests.plugins.shape-bugfixes-test] [frontend-tests.plugins.text-test] [frontend-tests.plugins.tokens-test] @@ -207,6 +208,7 @@ 'frontend-tests.plugins.page-active-validation-test 'frontend-tests.plugins.page-test 'frontend-tests.plugins.parser-test + 'frontend-tests.plugins.register-test 'frontend-tests.plugins.shape-bugfixes-test 'frontend-tests.plugins.text-test 'frontend-tests.plugins.tokens-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 815de4addb..b25cf8ed43 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1438,11 +1438,11 @@ msgstr "Attention" msgid "ds.component-subtitle" msgstr "Components to update:" -#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442 +#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470 msgid "ds.confirm-allow" msgstr "Allow" -#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436 +#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464 msgid "ds.confirm-cancel" msgstr "Cancel" @@ -1524,7 +1524,7 @@ msgstr "" "Clipboard access denied. Please allow clipboard permissions in your browser " "to paste content" -#: src/app/main/errors.cljs:773 +#: src/app/main/errors.cljs:782 msgid "errors.comment-error" msgstr "There was an error with the comment" @@ -1532,21 +1532,21 @@ msgstr "There was an error with the comment" msgid "errors.connection-error" msgstr "Cannot reach the server. Check your connection and try again." -#: src/app/main/errors.cljs:842 +#: src/app/main/errors.cljs:851 msgid "errors.deprecated" msgstr "" "Sorry! This is an old file that uses a deprecated type of Penpot assets and " "you can't open it." -#: src/app/main/errors.cljs:845 +#: src/app/main/errors.cljs:854 msgid "errors.deprecated.contact.after" msgstr "so we can help you." -#: src/app/main/errors.cljs:843 +#: src/app/main/errors.cljs:852 msgid "errors.deprecated.contact.before" msgstr "Although Penpot no longer support this type of Assets, you can" -#: src/app/main/errors.cljs:844 +#: src/app/main/errors.cljs:853 msgid "errors.deprecated.contact.text" msgstr "contact us" @@ -1582,13 +1582,13 @@ msgstr "The email «%s» has many permanent bounce reports." msgid "errors.email-spam-or-permanent-bounces" msgstr "The email «%s» has been reported as spam or permanently bounce." -#: src/app/main/errors.cljs:819 +#: src/app/main/errors.cljs:828 msgid "errors.feature-mismatch" msgstr "" "Looks like you are opening a file that has the feature '%s' enabled but the " "current penpot version does not supports it or has it disabled." -#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837 +#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846 msgid "errors.feature-not-supported" msgstr "Feature '%s' is not supported." @@ -1608,7 +1608,7 @@ msgstr "Empty field" msgid "errors.field-not-all-whitespace" msgstr "The name must contain some character other than space." -#: src/app/main/errors.cljs:815 +#: src/app/main/errors.cljs:824 msgid "errors.file-feature-mismatch" msgstr "" "It seems that there is a mismatch between the enabled features and the " @@ -1621,11 +1621,11 @@ msgstr "" "The font family name can only contain letters, numbers, spaces, hyphens, " "underscores, and dots." -#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 +#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 msgid "errors.generic" msgstr "Something wrong has happened." -#: src/app/main/errors.cljs:752 +#: src/app/main/errors.cljs:761 msgid "errors.internal-worker-error" msgstr "Something wrong has happened with the web worker." @@ -1675,7 +1675,7 @@ msgstr "This invite might be canceled or may be expired." msgid "errors.ldap-disabled" msgstr "LDAP authentication is disabled." -#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 +#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 msgid "errors.max-quota-reached" msgstr "You have reached the '%s' quota. Contact support." @@ -1701,7 +1701,7 @@ msgstr "Seems that this is not a valid image." msgid "errors.member-is-muted" msgstr "The profile you inviting has emails muted (spam reports or high bounces)." -#: src/app/main/errors.cljs:805 +#: src/app/main/errors.cljs:814 msgid "errors.migration-in-progress" msgstr "Migration in progress" @@ -1745,6 +1745,12 @@ msgstr "The profile is blocked" msgid "errors.profile-is-muted" msgstr "Your profile has emails muted (spam reports or high bounces)." +#: src/app/main/errors.cljs:724 +msgid "errors.profile-props-too-large" +msgstr "" +"Your profile settings are too large to save. Remove some plugins and try " +"again." + #: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96 msgid "errors.registration-disabled" msgstr "The registration is currently disabled." @@ -1759,11 +1765,11 @@ msgstr "" msgid "errors.save-retrying" msgstr "Connection lost. Retrying to save your changes." -#: src/app/main/errors.cljs:764 +#: src/app/main/errors.cljs:773 msgid "errors.svg-parser.invalid-svg" msgstr "SVG is invalid or malformed" -#: src/app/main/errors.cljs:810 +#: src/app/main/errors.cljs:819 msgid "errors.team-feature-mismatch" msgstr "Detected incompatible feature '%s'" @@ -1936,7 +1942,7 @@ msgstr "An unexpected error occurred." msgid "errors.unexpected-token" msgstr "Unknown token" -#: src/app/main/errors.cljs:723 +#: src/app/main/errors.cljs:732 msgid "errors.version-already-locked" msgstr "This version is already locked" @@ -1944,7 +1950,7 @@ msgstr "This version is already locked" msgid "errors.version-locked" msgstr "This version is locked and cannot be deleted by others" -#: src/app/main/errors.cljs:827 +#: src/app/main/errors.cljs:836 msgid "errors.version-not-supported" msgstr "File has an incompatible version number" @@ -8099,16 +8105,16 @@ msgstr "Snap nodes (%s)" msgid "workspace.plugins.button-open" msgstr "Open" -#: src/app/main/ui/workspace/plugins.cljs:215 +#: src/app/main/ui/workspace/plugins.cljs:242 #, markdown msgid "workspace.plugins.discover" msgstr "Discover [more plugins](%s)" -#: src/app/main/ui/workspace/plugins.cljs:222 +#: src/app/main/ui/workspace/plugins.cljs:249 msgid "workspace.plugins.empty-plugins" msgstr "No plugins installed yet" -#: src/app/main/ui/workspace/plugins.cljs:209 +#: src/app/main/ui/workspace/plugins.cljs:236 msgid "workspace.plugins.error.manifest" msgstr "The plugin manifest is incorrect." @@ -8120,15 +8126,15 @@ msgstr "You need to be an editor to use this plugin" msgid "workspace.plugins.error.unreachable" msgstr "The plugin URL could not be reached." -#: src/app/main/ui/workspace/plugins.cljs:205 +#: src/app/main/ui/workspace/plugins.cljs:232 msgid "workspace.plugins.error.url" msgstr "The plugin doesn't exist or the URL is not correct." -#: src/app/main/ui/workspace/plugins.cljs:201 +#: src/app/main/ui/workspace/plugins.cljs:228 msgid "workspace.plugins.install" msgstr "Install" -#: src/app/main/ui/workspace/plugins.cljs:231 +#: src/app/main/ui/workspace/plugins.cljs:258 msgid "workspace.plugins.installed-plugins" msgstr "Installed plugins" @@ -8140,102 +8146,112 @@ msgstr "Plugins manager" msgid "workspace.plugins.menu.title" msgstr "Plugins" -#: src/app/main/ui/workspace/plugins.cljs:424 +#: src/app/main/ui/workspace/plugins.cljs:452 msgid "workspace.plugins.permissions-update.title" msgstr "UPDATE THIS PLUGIN" -#: src/app/main/ui/workspace/plugins.cljs:428 +#: src/app/main/ui/workspace/plugins.cljs:456 msgid "workspace.plugins.permissions-update.warning" msgstr "" "The plugin has been modified since you last opened it. It now also wants to " "access:" -#: src/app/main/ui/workspace/plugins.cljs:297 +#: src/app/main/ui/workspace/plugins.cljs:325 msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." -#: src/app/main/ui/workspace/plugins.cljs:304 +#: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." -#: src/app/main/ui/workspace/plugins.cljs:317 +#: src/app/main/ui/workspace/plugins.cljs:345 msgid "workspace.plugins.permissions.clipboard-read" msgstr "Read the contents of your clipboard." -#: src/app/main/ui/workspace/plugins.cljs:311 +#: src/app/main/ui/workspace/plugins.cljs:339 msgid "workspace.plugins.permissions.clipboard-write" msgstr "Read and write to your clipboard." -#: src/app/main/ui/workspace/plugins.cljs:290 +#: src/app/main/ui/workspace/plugins.cljs:318 msgid "workspace.plugins.permissions.comment-read" msgstr "Read your comments and replies." -#: src/app/main/ui/workspace/plugins.cljs:284 +#: src/app/main/ui/workspace/plugins.cljs:312 msgid "workspace.plugins.permissions.comment-write" msgstr "Read and modify your comments and reply in your name." -#: src/app/main/ui/workspace/plugins.cljs:257 +#: src/app/main/ui/workspace/plugins.cljs:285 msgid "workspace.plugins.permissions.content-read" msgstr "Read the content of files that users have access to." -#: src/app/main/ui/workspace/plugins.cljs:251 +#: src/app/main/ui/workspace/plugins.cljs:279 msgid "workspace.plugins.permissions.content-write" msgstr "Read and modify the content of files that users have access to." -#: src/app/main/ui/workspace/plugins.cljs:366 +#: src/app/main/ui/workspace/plugins.cljs:394 msgid "workspace.plugins.permissions.disclaimer" msgstr "" "Please note that this plugin is created by an external party, so ensure you " "trust it before granting access. Your data privacy and security are " "important to us. If you have any concerns, please contact support." -#: src/app/main/ui/workspace/plugins.cljs:277 +#: src/app/main/ui/workspace/plugins.cljs:305 msgid "workspace.plugins.permissions.library-read" msgstr "Read your libraries and assets." -#: src/app/main/ui/workspace/plugins.cljs:271 +#: src/app/main/ui/workspace/plugins.cljs:299 msgid "workspace.plugins.permissions.library-write" msgstr "Read and modify your libraries and assets." -#: src/app/main/ui/workspace/plugins.cljs:359 +#: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "'%s' PLUGIN WANTS ACCESS TO:" -#: src/app/main/ui/workspace/plugins.cljs:264 +#: src/app/main/ui/workspace/plugins.cljs:292 msgid "workspace.plugins.permissions.user-read" msgstr "Read the profile information of the current user." -#: src/app/main/ui/workspace/plugins.cljs:227 +#: src/app/main/ui/workspace/plugins.cljs:254 msgid "workspace.plugins.plugin-list-link" msgstr "Plugins List" -#: src/app/main/ui/workspace/plugins.cljs:101 +#: src/app/main/ui/workspace/plugins.cljs:191 +msgid "workspace.plugins.remove-confirmation.message" +msgstr "" +"Are you sure you want to remove the plugin %s? You can install it again at " +"any time." + +#: src/app/main/ui/workspace/plugins.cljs:190 +msgid "workspace.plugins.remove-confirmation.title" +msgstr "Remove plugin" + +#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192 msgid "workspace.plugins.remove-plugin" msgstr "Remove plugin" -#: src/app/main/ui/workspace/plugins.cljs:195 +#: src/app/main/ui/workspace/plugins.cljs:222 msgid "workspace.plugins.search-placeholder" msgstr "Write a plugin URL" -#: src/app/main/ui/workspace/plugins.cljs:188 +#: src/app/main/ui/workspace/plugins.cljs:215 msgid "workspace.plugins.title" msgstr "Plugins" -#: src/app/main/ui/workspace/plugins.cljs:494 +#: src/app/main/ui/workspace/plugins.cljs:522 msgid "workspace.plugins.try-out.cancel" msgstr "NOT NOW" -#: src/app/main/ui/workspace/plugins.cljs:487 +#: src/app/main/ui/workspace/plugins.cljs:515 msgid "workspace.plugins.try-out.message" msgstr "" "Want to take a look? It will open in a new draft for your current team. (If " "not, you can always find it in the installed plugins of any file.)" -#: src/app/main/ui/workspace/plugins.cljs:483 +#: src/app/main/ui/workspace/plugins.cljs:511 msgid "workspace.plugins.try-out.title" msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!" -#: src/app/main/ui/workspace/plugins.cljs:500 +#: src/app/main/ui/workspace/plugins.cljs:528 msgid "workspace.plugins.try-out.try" msgstr "TRY PLUGIN" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2d22dedfdf..a45e2512dd 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1452,11 +1452,11 @@ msgstr "Atención" msgid "ds.component-subtitle" msgstr "Componentes a actualizar:" -#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442 +#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470 msgid "ds.confirm-allow" msgstr "Permitir" -#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436 +#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464 msgid "ds.confirm-cancel" msgstr "Cancelar" @@ -1538,25 +1538,25 @@ msgstr "" "Acceso al portapapeles denegado. Permite el acceso al portapapeles en tu " "navegador para pegar contenido." -#: src/app/main/errors.cljs:773 +#: src/app/main/errors.cljs:782 msgid "errors.comment-error" msgstr "Ha habido un error con el comentario" -#: src/app/main/errors.cljs:842 +#: src/app/main/errors.cljs:851 msgid "errors.deprecated" msgstr "" "¡Lo sentimos! Este es un fichero antiguo que utiliza un tipo de recurso de " "Penpot obsoleto, y no puedes abrirlo." -#: src/app/main/errors.cljs:845 +#: src/app/main/errors.cljs:854 msgid "errors.deprecated.contact.after" msgstr "para que podamos ayudarte." -#: src/app/main/errors.cljs:843 +#: src/app/main/errors.cljs:852 msgid "errors.deprecated.contact.before" msgstr "Aunque Penpot ya no da soporte a este tipo de Recursos, puedes" -#: src/app/main/errors.cljs:844 +#: src/app/main/errors.cljs:853 msgid "errors.deprecated.contact.text" msgstr "contactar con nosotros" @@ -1592,13 +1592,13 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente msgid "errors.email-spam-or-permanent-bounces" msgstr "El email «%s» tiene reportes de spam o de rebote permanente." -#: src/app/main/errors.cljs:819 +#: src/app/main/errors.cljs:828 msgid "errors.feature-mismatch" msgstr "" "Parece que está abriendo un archivo que tiene la función '%s' habilitada, " "pero la versión actual de penpot no la admite o la tiene deshabilitada." -#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837 +#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846 msgid "errors.feature-not-supported" msgstr "Caracteristica no soportada: '%s'." @@ -1618,7 +1618,7 @@ msgstr "Campo vacio" msgid "errors.field-not-all-whitespace" msgstr "Debe contener algún carácter diferente de espacio." -#: src/app/main/errors.cljs:815 +#: src/app/main/errors.cljs:824 msgid "errors.file-feature-mismatch" msgstr "" "Parece que hay discordancia entre las features habilitadas y las features " @@ -1631,11 +1631,11 @@ msgstr "" "El nombre de la familia tipográfica solo puede contener letras, números, " "espacios, guiones, guiones bajos y puntos." -#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 +#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 msgid "errors.generic" msgstr "Ha ocurrido algún error." -#: src/app/main/errors.cljs:752 +#: src/app/main/errors.cljs:761 msgid "errors.internal-worker-error" msgstr "Ha ocurrido un problema con el web worker." @@ -1687,7 +1687,7 @@ msgstr "Esta invitación puede haber sido cancelada o ha expirado." msgid "errors.ldap-disabled" msgstr "La autheticacion via LDAP esta deshabilitada." -#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 +#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117 msgid "errors.max-quota-reached" msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico." @@ -1717,7 +1717,7 @@ msgstr "" "El perfil que esta invitando tiene los emails silenciados (por reportes de " "spam o alto índice de rebote)." -#: src/app/main/errors.cljs:805 +#: src/app/main/errors.cljs:814 msgid "errors.migration-in-progress" msgstr "Migración en proceso" @@ -1765,6 +1765,12 @@ msgstr "" "Tu perfil tiene los emails silenciados (por reportes de spam o alto índice " "de rebote)." +#: src/app/main/errors.cljs:724 +msgid "errors.profile-props-too-large" +msgstr "" +"La configuración de tu perfil es demasiado grande y no se puede guardar. " +"Elimina algunas extensiones e inténtalo de nuevo." + #: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96 msgid "errors.registration-disabled" msgstr "El registro está actualmente desactivado." @@ -1776,11 +1782,11 @@ msgstr "" "informar del error y recarga para continuar desde los últimos cambios " "guardados." -#: src/app/main/errors.cljs:764 +#: src/app/main/errors.cljs:773 msgid "errors.svg-parser.invalid-svg" msgstr "El SVG no es válido o está mal formado" -#: src/app/main/errors.cljs:810 +#: src/app/main/errors.cljs:819 msgid "errors.team-feature-mismatch" msgstr "Detectada funcionalidad incompatible '%s'" @@ -1964,7 +1970,7 @@ msgstr "Ha ocurrido un error inesperado." msgid "errors.unexpected-token" msgstr "Token desconocido" -#: src/app/main/errors.cljs:723 +#: src/app/main/errors.cljs:732 msgid "errors.version-already-locked" msgstr "Esta versión ya está bloqueada" @@ -1972,7 +1978,7 @@ msgstr "Esta versión ya está bloqueada" msgid "errors.version-locked" msgstr "Esta versión está bloqueada y otras personas no pueden eliminarla" -#: src/app/main/errors.cljs:827 +#: src/app/main/errors.cljs:836 msgid "errors.version-not-supported" msgstr "El fichero tiene un número de versión incompatible" @@ -8169,16 +8175,16 @@ msgstr "Alinear nodos (%s)" msgid "workspace.plugins.button-open" msgstr "Abrir" -#: src/app/main/ui/workspace/plugins.cljs:215 +#: src/app/main/ui/workspace/plugins.cljs:242 #, markdown msgid "workspace.plugins.discover" msgstr "Descubre [más extensiones](%s)" -#: src/app/main/ui/workspace/plugins.cljs:222 +#: src/app/main/ui/workspace/plugins.cljs:249 msgid "workspace.plugins.empty-plugins" msgstr "No se encuentran extensiones" -#: src/app/main/ui/workspace/plugins.cljs:209 +#: src/app/main/ui/workspace/plugins.cljs:236 msgid "workspace.plugins.error.manifest" msgstr "El manifiesto de la expansión es incorrecto." @@ -8190,15 +8196,15 @@ msgstr "Debes ser un editor para usar este plugin" msgid "workspace.plugins.error.unreachable" msgstr "No se ha podido acceder a la URL de la extensión." -#: src/app/main/ui/workspace/plugins.cljs:205 +#: src/app/main/ui/workspace/plugins.cljs:232 msgid "workspace.plugins.error.url" msgstr "La extensión no existe o la url no es correcta." -#: src/app/main/ui/workspace/plugins.cljs:201 +#: src/app/main/ui/workspace/plugins.cljs:228 msgid "workspace.plugins.install" msgstr "Instalar" -#: src/app/main/ui/workspace/plugins.cljs:231 +#: src/app/main/ui/workspace/plugins.cljs:258 msgid "workspace.plugins.installed-plugins" msgstr "Extensiones instaladas" @@ -8210,49 +8216,49 @@ msgstr "Gestor de extensiones" msgid "workspace.plugins.menu.title" msgstr "Extensiones" -#: src/app/main/ui/workspace/plugins.cljs:424 +#: src/app/main/ui/workspace/plugins.cljs:452 msgid "workspace.plugins.permissions-update.title" msgstr "EXTENSIÓN ACTUALIZADA" -#: src/app/main/ui/workspace/plugins.cljs:428 +#: src/app/main/ui/workspace/plugins.cljs:456 msgid "workspace.plugins.permissions-update.warning" msgstr "" "La extensión ha cambiado desde la última vez que la abriste. Ahora quiere " "acceder a:" -#: src/app/main/ui/workspace/plugins.cljs:297 +#: src/app/main/ui/workspace/plugins.cljs:325 msgid "workspace.plugins.permissions.allow-download" msgstr "Comenzar descargas de ficheros." -#: src/app/main/ui/workspace/plugins.cljs:304 +#: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Guardar datos en el navegador." -#: src/app/main/ui/workspace/plugins.cljs:317 +#: src/app/main/ui/workspace/plugins.cljs:345 msgid "workspace.plugins.permissions.clipboard-read" msgstr "Leer el contenido de tu portapapeles." -#: src/app/main/ui/workspace/plugins.cljs:311 +#: src/app/main/ui/workspace/plugins.cljs:339 msgid "workspace.plugins.permissions.clipboard-write" msgstr "Leer y escribir en tu portapapeles." -#: src/app/main/ui/workspace/plugins.cljs:290 +#: src/app/main/ui/workspace/plugins.cljs:318 msgid "workspace.plugins.permissions.comment-read" msgstr "Leer tus comentarios y respuestas." -#: src/app/main/ui/workspace/plugins.cljs:284 +#: src/app/main/ui/workspace/plugins.cljs:312 msgid "workspace.plugins.permissions.comment-write" msgstr "Leer y modificar tus comentarios y responder en tu nombre." -#: src/app/main/ui/workspace/plugins.cljs:257 +#: src/app/main/ui/workspace/plugins.cljs:285 msgid "workspace.plugins.permissions.content-read" msgstr "Leer el contenido de sus archivos." -#: src/app/main/ui/workspace/plugins.cljs:251 +#: src/app/main/ui/workspace/plugins.cljs:279 msgid "workspace.plugins.permissions.content-write" msgstr "Leer y modificar el contenido de sus archivos." -#: src/app/main/ui/workspace/plugins.cljs:366 +#: src/app/main/ui/workspace/plugins.cljs:394 msgid "workspace.plugins.permissions.disclaimer" msgstr "" "Ten en cuenta que las extensiones están desarrolladas por terceros, " @@ -8260,54 +8266,64 @@ msgstr "" "seguridad es importante para nosotros. Si tienes cualquier duda, contacta " "con soporte." -#: src/app/main/ui/workspace/plugins.cljs:277 +#: src/app/main/ui/workspace/plugins.cljs:305 msgid "workspace.plugins.permissions.library-read" msgstr "Leer la información de sus bibliotecas y recursos." -#: src/app/main/ui/workspace/plugins.cljs:271 +#: src/app/main/ui/workspace/plugins.cljs:299 msgid "workspace.plugins.permissions.library-write" msgstr "Leer y modificar la información de sus bibliotecas y recursos." -#: src/app/main/ui/workspace/plugins.cljs:359 +#: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:" -#: src/app/main/ui/workspace/plugins.cljs:264 +#: src/app/main/ui/workspace/plugins.cljs:292 msgid "workspace.plugins.permissions.user-read" msgstr "Leer la información del usuario actual." -#: src/app/main/ui/workspace/plugins.cljs:227 +#: src/app/main/ui/workspace/plugins.cljs:254 msgid "workspace.plugins.plugin-list-link" msgstr "Lista de extensiones" -#: src/app/main/ui/workspace/plugins.cljs:101 +#: src/app/main/ui/workspace/plugins.cljs:191 +msgid "workspace.plugins.remove-confirmation.message" +msgstr "" +"¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en " +"cualquier momento." + +#: src/app/main/ui/workspace/plugins.cljs:190 +msgid "workspace.plugins.remove-confirmation.title" +msgstr "Eliminar extensión" + +#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192 msgid "workspace.plugins.remove-plugin" msgstr "Eliminar extensión" -#: src/app/main/ui/workspace/plugins.cljs:195 +#: src/app/main/ui/workspace/plugins.cljs:222 msgid "workspace.plugins.search-placeholder" msgstr "Intruduzca URL de la extensión" -#: src/app/main/ui/workspace/plugins.cljs:188 +#: src/app/main/ui/workspace/plugins.cljs:215 msgid "workspace.plugins.title" msgstr "Extensiones" -#: src/app/main/ui/workspace/plugins.cljs:494 +#: src/app/main/ui/workspace/plugins.cljs:522 msgid "workspace.plugins.try-out.cancel" msgstr "AHORA NO" -#: src/app/main/ui/workspace/plugins.cljs:487 +#: src/app/main/ui/workspace/plugins.cljs:515 msgid "workspace.plugins.try-out.message" msgstr "" "¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo " "actual. (Si no, puedes encontrar los plugins instalados en cualquier " "fichero.)" -#: src/app/main/ui/workspace/plugins.cljs:483 +#: src/app/main/ui/workspace/plugins.cljs:511 msgid "workspace.plugins.try-out.title" msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!" -#: src/app/main/ui/workspace/plugins.cljs:500 +#: src/app/main/ui/workspace/plugins.cljs:528 msgid "workspace.plugins.try-out.try" msgstr "PROBAR PLUGIN"