diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index c50af92813..bc077f92c4 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -22,6 +22,7 @@ [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.changes :as dch] @@ -1617,9 +1618,6 @@ (rx/catch on-clipboard-error)) (rx/empty))))) -;; Bigger HTML is pasted as plain text rather than walked. -(def ^:private max-paste-html-length 1000000) - (defn payload-content "The content of a Penpot text `payload` without what the current file cannot reach. A payload without team counts as coming from the current team." @@ -1636,7 +1634,10 @@ [text] (when (str/starts-with? (str/trim (or text "")) "{") (let [data (ex/ignoring (t/decode-str text))] - (when (and (map? data) (= :copied-shapes (:type data))) + (when (and (map? data) + (= :copied-shapes (:type data)) + (map? (:objects data)) + (set? (:selected data))) data)))) (defn copied-text-geometry @@ -1660,22 +1661,30 @@ (mapv #(mapv (constantly nil) (:children %)) (-> content :children first :children)))) +(defn- pasteable-text? + "True when a copied text shape, which other apps or plugins may have written, can + be pasted into the text editor." + [{:keys [x y content]}] + (and (number? x) (number? y) (cts/valid-content? content))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and - left to right; nil when none of the copied shapes is a text." + left to right; nil when none of the copied shapes is a text or it is not valid." [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) - (filter cfh/text-shape?) - (sort-by (juxt :y :x)))] - (when (seq texts) - (cond-> {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))} - (some? text-geometry) - (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) + (filter cfh/text-shape?))] + (when (and (seq texts) (every? pasteable-text? texts)) + (let [texts (sort-by (juxt :y :x) texts) + payload (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))] + (when (text-clipboard/valid-payload? payload) + payload))))) (defn- penpot-paste [state payload] @@ -1700,7 +1709,7 @@ (some->> (copied-shapes->payload shapes) (penpot-paste state)) :else - (when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length)) + (when-let [fragment (or (when (and html-paste? (< 0 (count html) text-clipboard/max-paste-html-length)) (some-> (text-clipboard/html->fragment html) (text-clipboard/without-overrides))) (text-clipboard/text->fragment text))] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 35df340afe..f8368aa34a 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -11,7 +11,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.fonts :as cfnt] + [app.common.schema :as sm] [app.common.transit :as t] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.types.typography :as ctt] [cuerdas.core :as str])) @@ -277,6 +279,29 @@ (def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"") +;; Bigger HTML is pasted as plain text rather than read. +(def max-paste-html-length 1000000) + +(def ^:private schema:origin + [:map {:closed true} + [:x ::sm/safe-number] + [:y ::sm/safe-number] + [:box [:map {:closed true} + [:width ::sm/safe-number] + [:height ::sm/safe-number]]]]) + +(def ^:private valid-geometry? + (sm/lazy-validator + [:map {:closed true} + [:origins [:vector [:vector [:maybe schema:origin]]]]])) + +(defn valid-payload? + "True when the `content` and `geometry` of a payload, which other apps or plugins + may have written, are safe to paste into a text shape." + [{:keys [content geometry]}] + (and (cts/valid-content? content) + (or (nil? geometry) (valid-geometry? geometry)))) + (defn- encode-base64 [s] (->> (.encode (js/TextEncoder.) s) @@ -307,11 +332,15 @@ (defn html->payload "The Penpot payload in `html`, or nil when it has none." [html] - (when-let [[_ encoded] (some->> html (re-find payload-re))] - (let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))] + (when-let [[_ encoded] (when (< (count html) max-paste-html-length) + (some->> html (re-find payload-re)))] + (let [{:keys [type file-id team-id] :as payload} + (ex/ignoring (t/decode-str (decode-base64 encoded)))] (when (and (map? payload) - (= :copied-text (:type payload)) - (map? (:content payload))) + (= :copied-text type) + (uuid? file-id) + (or (nil? team-id) (uuid? team-id)) + (valid-payload? payload)) payload)))) ;; --- Pasting into another file or team diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index e5534a1517..88b1cbdcfe 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -228,6 +228,50 @@ (t/is (nil? (clipboard/html->payload ""))) (t/is (nil? (clipboard/html->payload nil))))) +(defn- tampered-html + "Payload markup carrying `payload` as is, as a plugin could write it." + [{:keys [content file-id team-id geometry]}] + (clipboard/payload->html content file-id team-id geometry)) + +(def ^:private valid-payload + {:content (text-content gradient-span) :file-id file-id :team-id team-id}) + +(t/deftest invalid-payload + (t/testing "a valid payload is read" + (t/is (some? (clipboard/html->payload (tampered-html valid-payload))))) + + (t/testing "a payload with a broken envelope is dropped" + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :file-id "file"))))) + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :team-id 42)))))) + + (t/testing "a payload whose content a text shape cannot hold is dropped" + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc-in valid-payload [:content :children 0 :children] []))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content (text-content (assoc gradient-span :text 42))))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content + (text-content (assoc gradient-span :fills [{:fill-color "red"}])))))))) + + (t/testing "a payload with geometry that is not plain numbers is dropped" + (let [origin {:x 12.5 :y 0.2 :box {:width 200 :height 40}}] + (t/is (some? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :x "12")]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :y ##NaN)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc-in origin [:box :width] nil)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]] :extra 1})))))))) + +(t/deftest oversized-payload + (t/testing "HTML over the paste size limit is not decoded" + (let [hidden (clipboard/payload->html (text-content gradient-span) file-id team-id)] + (t/is (nil? (clipboard/html->payload + (str hidden (apply str (repeat clipboard/max-paste-html-length "x"))))))))) + ;; --- Clean-up (def ^:private image-fill