mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 12:19:58 -04:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc55bc2a48 | ||
|
|
eb34e1c118 | ||
|
|
81e44afbe3 | ||
|
|
495e9f059e | ||
|
|
a60b648c6c | ||
|
|
b6656ee8dd | ||
|
|
86aaf642b6 | ||
|
|
c4dd04353f | ||
|
|
0ac711aa68 | ||
|
|
bf62e59f73 | ||
|
|
5906312dff | ||
|
|
25066c2f46 | ||
|
|
3d176d5390 | ||
|
|
0481408531 | ||
|
|
689d3a1be2 | ||
|
|
fb07273897 | ||
|
|
9242556da6 | ||
|
|
4f7bb94bb1 | ||
|
|
49276886f3 | ||
|
|
a2968defbe | ||
|
|
6628f0a134 |
No files matched your search
@@ -48,6 +48,7 @@
|
||||
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
org.passay/passay {:mvn/version "1.6.6"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
|
||||
@@ -39,4 +39,10 @@
|
||||
{:permits 3}
|
||||
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}}
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:send-user-feedback/global
|
||||
{:permits 4}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3}}
|
||||
@@ -776,7 +776,7 @@
|
||||
|
||||
(defn prepare-organization-sso-provider
|
||||
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
|
||||
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||
[cfg {:keys [client-id client-secret issuer]}]
|
||||
(prepare-oidc-provider cfg
|
||||
{:type "oidc"
|
||||
@@ -785,8 +785,7 @@
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes default-oidc-scopes
|
||||
:skip-ssrf-check? true}))
|
||||
:scopes default-oidc-scopes}))
|
||||
|
||||
(defn build-organization-sso-auth-redirect-uri
|
||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns app.auth.passwords
|
||||
"Password strength validation using Passay library."
|
||||
(:require
|
||||
[app.common.exceptions :as ex])
|
||||
(:import
|
||||
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
|
||||
|
||||
(defonce ^:private passay-code->translation-key
|
||||
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
|
||||
"INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
|
||||
"INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
|
||||
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
|
||||
|
||||
(defonce ^:private character-characteristics-rule
|
||||
(doto (CharacterCharacteristicsRule.)
|
||||
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||
(CharacterRule. EnglishCharacterData/UpperCase 1)
|
||||
(CharacterRule. EnglishCharacterData/Digit 1)
|
||||
(CharacterRule. EnglishCharacterData/Special 1)])
|
||||
(.setNumberOfCharacteristics 4)))
|
||||
|
||||
(defn validate-password
|
||||
"Validates password strength.
|
||||
Returns nil if valid, or raises exception if invalid.
|
||||
Checks:
|
||||
- Minimum length of 8 characters
|
||||
- At least 1 lowercase letter
|
||||
- At least 1 uppercase letter
|
||||
- At least 1 digit
|
||||
- At least 1 special character"
|
||||
[password]
|
||||
(when (< (count password) 8)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password must be at least 8 characters"
|
||||
:details ["errors.weak-password.too-short"]))
|
||||
|
||||
(let [password-data (PasswordData. password)
|
||||
char-result (.validate character-characteristics-rule password-data)]
|
||||
(when-not (.isValid char-result)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
|
||||
:details (->> (.getDetails char-result)
|
||||
(mapv #(.getErrorCode %))
|
||||
(mapv passay-code->translation-key)
|
||||
(filterv some?))))))
|
||||
@@ -748,9 +748,17 @@
|
||||
(fmigr/upsert-migrations! conn file))
|
||||
|
||||
(let [file (encode-file cfg file)]
|
||||
(db/insert! conn :file
|
||||
(file->params file)
|
||||
(assoc opts ::db/return-keys false))
|
||||
(try
|
||||
(db/insert! conn :file
|
||||
(file->params file)
|
||||
(assoc opts ::db/return-keys false))
|
||||
(catch org.postgresql.util.PSQLException cause
|
||||
(if (db/duplicate-key-error? cause)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "file already exists"
|
||||
:cause cause)
|
||||
(throw cause))))
|
||||
|
||||
(->> (file->file-data-params file)
|
||||
(fdata/upsert! cfg))
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
(assert-mark m :obj)
|
||||
(let [size (read-long! input)]
|
||||
(assert (pos? size) "incorrect header size found on reading header")
|
||||
(when (> size bfc/max-object-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (dm/str "unable to import object with size " size " bytes")))
|
||||
(let [buff (byte-array size)]
|
||||
(read-bytes! input buff)
|
||||
(fres/decode buff)))))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.http.assets
|
||||
"Assets related handlers."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
@@ -42,7 +43,7 @@
|
||||
|
||||
(defn- get-file-media-object
|
||||
[pool id]
|
||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
|
||||
(defn- serve-object-from-s3
|
||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||
@@ -109,13 +110,21 @@
|
||||
(defn- generic-handler
|
||||
"A generic handler helper/common code for file-media based handlers."
|
||||
[{:keys [::sto/storage] :as cfg} request kf]
|
||||
(let [pool (::db/pool storage)
|
||||
id (get-id request)
|
||||
mobj (get-file-media-object pool id)
|
||||
sobj (sto/get-object storage (kf mobj))]
|
||||
(if sobj
|
||||
(serve-object cfg sobj)
|
||||
{::yres/status 404})))
|
||||
(let [pool (::db/pool storage)
|
||||
id (get-id request)
|
||||
mobj (get-file-media-object pool id)]
|
||||
(if (nil? mobj)
|
||||
{::yres/status 404}
|
||||
(let [file-id (:file-id mobj)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
perms (bfc/get-file-permissions pool profile-id file-id)]
|
||||
(if-not (:can-read perms)
|
||||
{::yres/status 404}
|
||||
(let [sobj (sto/get-object storage (kf mobj))]
|
||||
(if sobj
|
||||
(serve-object cfg sobj)
|
||||
{::yres/status 404})))))))
|
||||
|
||||
(defn file-objects-handler
|
||||
"Handler that serves storage objects by file media id."
|
||||
|
||||
@@ -7,30 +7,22 @@
|
||||
(ns app.media.local
|
||||
"Local media processing via ImageMagick and FontForge shell commands."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.media.svg :as svg]
|
||||
[app.media.validation :as validation]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.shell :as shell]
|
||||
[buddy.core.bytes :as bb]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.string]
|
||||
[clojure.xml :as xml]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
org.apache.commons.io.IOUtils))
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(defmulti process (fn [_system params] (:cmd params)))
|
||||
|
||||
@@ -40,30 +32,6 @@
|
||||
:code :not-implemented
|
||||
:hint (str/fmt "No impl found for local process cmd: %s" cmd)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG PARSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- secure-parser-factory
|
||||
[^InputStream input ^XMLHandler handler]
|
||||
(.. (doto (SAXParserFactory/newInstance)
|
||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||
(newSAXParser)
|
||||
(parse input handler)))
|
||||
|
||||
(defn- strip-doctype
|
||||
[data]
|
||||
(cond-> data
|
||||
(str/includes? data "<!DOCTYPE")
|
||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||
|
||||
(defn parse-svg
|
||||
[text]
|
||||
(let [text (strip-doctype text)]
|
||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||
(xml/parse istream secure-parser-factory))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE THUMBNAILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -167,34 +135,6 @@
|
||||
"-extent" (str width "x" height)
|
||||
"-quality" (str quality)]))))
|
||||
|
||||
(defn get-basic-info-from-svg
|
||||
[{:keys [tag attrs] :as data}]
|
||||
(when (not= tag :svg)
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-parse-svg
|
||||
:hint "uploaded svg has invalid content"))
|
||||
(reduce (fn [default f]
|
||||
(if-let [res (f attrs)]
|
||||
(reduced res)
|
||||
default))
|
||||
{:width 100 :height 100}
|
||||
[(fn parse-width-and-height
|
||||
[{:keys [width height]}]
|
||||
(when (and (string? width)
|
||||
(string? height))
|
||||
(let [width (d/parse-double width)
|
||||
height (d/parse-double height)]
|
||||
(when (and width height)
|
||||
{:width (int width)
|
||||
:height (int height)}))))
|
||||
(fn parse-viewbox
|
||||
[{:keys [viewBox]}]
|
||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||
(map d/parse-double))]
|
||||
(when (and x y width height)
|
||||
{:width (int width)
|
||||
:height (int height)})))]))
|
||||
|
||||
(defn- get-dimensions-with-orientation [system ^String path]
|
||||
;; Image magick doesn't give info about exif rotation so we use the identify command
|
||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||
@@ -217,7 +157,7 @@
|
||||
[system {:keys [input] :as params}]
|
||||
(let [{:keys [path mtype] :as input} (validation/check-input input)]
|
||||
(if (= mtype "image/svg+xml")
|
||||
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
|
||||
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[app.common.uri :as uri]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.media.local :as local]
|
||||
[app.media.svg :as svg]
|
||||
[app.media.validation :as validation]
|
||||
[app.setup :as-alias setup]
|
||||
[app.storage.tmp :as tmp]
|
||||
@@ -182,7 +182,7 @@
|
||||
(let [{:keys [path mtype]} (validation/check-input input)]
|
||||
(if (= mtype "image/svg+xml")
|
||||
;; SVG: parse locally (Sharp doesn't support SVG)
|
||||
(let [info (some-> path slurp local/parse-svg local/get-basic-info-from-svg)]
|
||||
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns app.media.svg
|
||||
"SVG parsing, sanitization, and info extraction.
|
||||
Centralizes all SVG-related security concerns."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[clojure.xml :as xml]
|
||||
[cuerdas.core :as str])
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
org.apache.commons.io.IOUtils))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG PARSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- secure-parser-factory
|
||||
[^InputStream input ^XMLHandler handler]
|
||||
(.. (doto (SAXParserFactory/newInstance)
|
||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||
(newSAXParser)
|
||||
(parse input handler)))
|
||||
|
||||
(defn- strip-doctype
|
||||
[data]
|
||||
(cond-> data
|
||||
(str/includes? data "<!DOCTYPE")
|
||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||
|
||||
(defn parse-svg
|
||||
[text]
|
||||
(let [text (strip-doctype text)]
|
||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||
(xml/parse istream secure-parser-factory))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG SANITIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
|
||||
(def ^:private javascript-href-pattern #"(?i)^javascript:")
|
||||
|
||||
(defn- sanitize-svg-element
|
||||
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
|
||||
[{:keys [tag attrs content] :as element}]
|
||||
(when (and (map? element) tag)
|
||||
(let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
|
||||
(when-not (contains? dangerous-tags tag)
|
||||
(let [clean-attrs (->> attrs
|
||||
(remove (fn [[k v]]
|
||||
(or (re-matches dangerous-attrs-pattern (name k))
|
||||
(and (#{:href :xlink:href} k)
|
||||
(string? v)
|
||||
(re-find javascript-href-pattern (str/trim v))))))
|
||||
(into {}))
|
||||
clean-content (when content
|
||||
(->> content
|
||||
(filter #(or (string? %) (map? %)))
|
||||
(map (fn [child]
|
||||
(if (map? child)
|
||||
(sanitize-svg-element child)
|
||||
child)))
|
||||
(filter some?)
|
||||
vec))]
|
||||
(cond-> {:tag tag :attrs clean-attrs}
|
||||
(seq clean-content) (assoc :content clean-content)))))))
|
||||
|
||||
(defn sanitize-svg
|
||||
"Sanitize SVG content by removing dangerous elements and attributes.
|
||||
Removes <script> tags, <foreignObject> elements, event handlers (on*),
|
||||
and javascript: URLs from href attributes."
|
||||
[svg-text]
|
||||
(try
|
||||
(let [parsed (parse-svg svg-text)
|
||||
sanitized (sanitize-svg-element parsed)]
|
||||
(if sanitized
|
||||
(with-out-str (xml/emit sanitized))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "SVG sanitization produced no output")))
|
||||
(catch Exception e
|
||||
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "SVG parsing failed during sanitization"
|
||||
:cause e))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG INFO EXTRACTION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn get-basic-info-from-svg
|
||||
[{:keys [tag attrs] :as data}]
|
||||
(when (not= tag :svg)
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-parse-svg
|
||||
:hint "uploaded svg has invalid content"))
|
||||
(reduce (fn [default f]
|
||||
(if-let [res (f attrs)]
|
||||
(reduced res)
|
||||
default))
|
||||
{:width 100 :height 100}
|
||||
[(fn parse-width-and-height
|
||||
[{:keys [width height]}]
|
||||
(when (and (string? width)
|
||||
(string? height))
|
||||
(let [width (d/parse-double width)
|
||||
height (d/parse-double height)]
|
||||
(when (and width height)
|
||||
{:width (int width)
|
||||
:height (int height)}))))
|
||||
(fn parse-viewbox
|
||||
[{:keys [viewBox]}]
|
||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||
(map d/parse-double))]
|
||||
(when (and x y width height)
|
||||
{:width (int width)
|
||||
:height (int height)})))]))
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.auth.passwords :as passwords]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
@@ -182,6 +183,7 @@
|
||||
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
||||
nil))]
|
||||
|
||||
(passwords/validate-password password)
|
||||
(->> (validate-token token)
|
||||
(update-password conn))
|
||||
|
||||
@@ -240,6 +242,9 @@
|
||||
:code :email-as-password
|
||||
:hint "you can't use your email as password"))
|
||||
|
||||
;; Validate password strength against common password dictionary
|
||||
(passwords/validate-password (:password params))
|
||||
|
||||
(when (eml/has-bounce-reports? cfg (:email params))
|
||||
(ex/raise :type :restriction
|
||||
:code :email-has-permanent-bounces
|
||||
@@ -258,7 +263,8 @@
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email pool email)]
|
||||
profile (profile/get-profile-by-email pool email)
|
||||
fullname (d/normalize-string fullname)]
|
||||
|
||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
||||
;; profile already exists for this email.
|
||||
@@ -359,6 +365,9 @@
|
||||
is-active (:is-active params false)
|
||||
theme (:theme params nil)
|
||||
email (str/lower email)
|
||||
fullname (d/normalize-string (:fullname params))
|
||||
locale (d/normalize-string locale)
|
||||
theme (d/normalize-string theme)
|
||||
|
||||
photo-id (some->> (or (:oidc/picture props)
|
||||
(:google/picture props)
|
||||
@@ -367,7 +376,7 @@
|
||||
(import-profile-picture cfg))
|
||||
|
||||
params {:id id
|
||||
:fullname (:fullname params)
|
||||
:fullname fullname
|
||||
:email email
|
||||
:auth-backend backend
|
||||
:lang locale
|
||||
|
||||
@@ -118,11 +118,10 @@
|
||||
|
||||
(def ^:private schema:import-binfile
|
||||
[:and
|
||||
[:map {:title "import-binfile"}
|
||||
[:map {:title "import-binfile" :closed true}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:file-id {:optional true} ::sm/uuid]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:file {:optional true} media.v/schema:upload]
|
||||
[:upload-id {:optional true} ::sm/uuid]]
|
||||
@@ -131,35 +130,26 @@
|
||||
(or (some? file) (some? upload-id)))]])
|
||||
|
||||
(sv/defmethod ::import-binfile
|
||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
||||
an in-place import will be performed instead of creating a new file.
|
||||
|
||||
The in-place imports are only supported for binfile-v3 and when a
|
||||
.penpot file only contains one penpot file.
|
||||
"Import a penpot file in a binary format.
|
||||
|
||||
The file content may be provided either as a multipart `file` upload
|
||||
or as an `upload-id` referencing a completed chunked-upload session,
|
||||
which allows importing files larger than the multipart size limit.
|
||||
"
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
||||
"1.20" "Set default version to 3"
|
||||
"2.15" "Add upload-id param for chunked upload support"]
|
||||
::doc/changes [["1.20" "Set default version to 3"]
|
||||
["2.15" "Add upload-id param for chunked upload support"]]
|
||||
|
||||
::webhooks/event? true
|
||||
::sse/stream? true
|
||||
::sm/params schema:import-binfile}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
|
||||
(projects/check-edition-permissions! pool profile-id project-id)
|
||||
(let [version (or version 3)
|
||||
params (-> params
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :version version))
|
||||
|
||||
cfg (cond-> cfg
|
||||
(uuid? file-id)
|
||||
(assoc ::bfc/file-id file-id))
|
||||
|
||||
params
|
||||
(if (some? upload-id)
|
||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
||||
@@ -174,6 +164,5 @@
|
||||
(with-meta
|
||||
(sse/response (partial import-binfile cfg params))
|
||||
{::audit/props {:file nil
|
||||
:file-id file-id
|
||||
:generated-by (:generated-by manifest)
|
||||
:referer (:referer manifest)}})))
|
||||
@@ -14,22 +14,25 @@
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[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]))
|
||||
|
||||
(declare ^:private send-user-feedback!)
|
||||
|
||||
(def ^:private schema:send-user-feedback
|
||||
(def schema:send-user-feedback
|
||||
[:map {:title "send-user-feedback"}
|
||||
[:subject [:string {:max 500}]]
|
||||
[:content [:string {:max 2500}]]
|
||||
[:type {:optional true} :string]
|
||||
[:error-href {:optional true} [:string {:max 2500}]]
|
||||
[:error-report {:optional true} :string]])
|
||||
[:error-report {:optional true} [:string {:max 1048576}]]])
|
||||
|
||||
(sv/defmethod ::send-user-feedback
|
||||
{::doc/added "1.18"
|
||||
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
|
||||
[:send-user-feedback/global]]
|
||||
::doc/added "1.18"
|
||||
::sm/params schema:send-user-feedback}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
||||
(when-not (contains? cf/flags :user-feedback)
|
||||
|
||||
@@ -1069,6 +1069,25 @@
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
|
||||
|
||||
;; --- Library relation helpers
|
||||
|
||||
(defn- check-library-team-ownership!
|
||||
"Verify that file and library belong to the same team.
|
||||
Prevents cross-team library relation injection."
|
||||
[conn file-id library-id]
|
||||
(let [sql "SELECT EXISTS (
|
||||
SELECT 1 FROM file AS f
|
||||
JOIN project AS fp ON (fp.id = f.project_id)
|
||||
JOIN file AS l ON (l.id = ?)
|
||||
JOIN project AS lp ON (lp.id = l.project_id)
|
||||
WHERE f.id = ? AND fp.team_id = lp.team_id
|
||||
) AS ok"
|
||||
row (db/exec-one! conn [sql library-id file-id])]
|
||||
(when-not (:ok row)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "file and library must belong to the same team"))))
|
||||
|
||||
;; --- MUTATION COMMAND: link-file-to-library
|
||||
|
||||
(def sql:link-file-to-library
|
||||
@@ -1104,6 +1123,7 @@
|
||||
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(check-library-team-ownership! conn file-id library-id)
|
||||
|
||||
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
||||
(when (contains? transitive-deps file-id)
|
||||
@@ -1135,6 +1155,7 @@
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(check-library-team-ownership! conn file-id library-id)
|
||||
(unlink-file-from-library conn params)
|
||||
nil)
|
||||
|
||||
@@ -1159,6 +1180,7 @@
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(check-library-team-ownership! conn file-id library-id)
|
||||
(update-sync conn params))
|
||||
|
||||
;; --- MUTATION COMMAND: ignore-sync
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.media :as media]
|
||||
[app.media.svg :as svg]
|
||||
[app.media.validation :as media.v]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
@@ -114,13 +115,22 @@
|
||||
|
||||
(defn- process-main-image
|
||||
[info]
|
||||
(let [hash (sto/calculate-hash (:path info))
|
||||
data (-> (sto/content (:path info))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(let [path (:path info)
|
||||
mtype (:mtype info)
|
||||
path (if (= mtype "image/svg+xml")
|
||||
(let [content (slurp path)
|
||||
sanitized (svg/sanitize-svg content)
|
||||
temp-path (tmp/tempfile :prefix "penpot-svg-" :suffix ".svg" :min-age "5m")]
|
||||
(spit (str temp-path) sanitized)
|
||||
temp-path)
|
||||
path)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))]
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (:ts info)
|
||||
:content-type (:mtype info)
|
||||
:content-type mtype
|
||||
:bucket "file-media-object"}))
|
||||
|
||||
(defn- process-thumb-image
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.rpc.commands.profile
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.auth.passwords :as passwords]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
@@ -164,6 +165,9 @@
|
||||
;; it or not for explicit locking and avoid concurrent updates of
|
||||
;; the same row/object.
|
||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||
fullname (d/normalize-string fullname)
|
||||
lang (d/normalize-string lang)
|
||||
theme (d/normalize-string theme)
|
||||
;; Update the profile map with direct params
|
||||
profile (-> profile
|
||||
(assoc :fullname fullname)
|
||||
@@ -209,6 +213,9 @@
|
||||
:code :email-as-password
|
||||
:hint "you can't use your email as password"))
|
||||
|
||||
;; Validate password strength against common password dictionary
|
||||
(passwords/validate-password (:password params))
|
||||
|
||||
(update-profile-password! cfg (assoc profile :password password))
|
||||
|
||||
(->> (rph/get-request params)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.rpc.commands.projects
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
@@ -259,7 +260,8 @@
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
|
||||
(let [project (db/get-by-id conn :project id ::sql/for-update true)
|
||||
name (d/normalize-string name)]
|
||||
(db/update! conn :project
|
||||
{:name name}
|
||||
{:id id})
|
||||
|
||||
@@ -652,6 +652,7 @@
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)
|
||||
features (db/create-array conn "text" features)
|
||||
name (d/normalize-string name)
|
||||
team (db/insert! conn :team
|
||||
{:id id
|
||||
:name name
|
||||
@@ -688,6 +689,7 @@
|
||||
[conn {:keys [id team-id name is-default created-at modified-at]}]
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)
|
||||
name (d/normalize-string name)
|
||||
params {:id id
|
||||
:name name
|
||||
:team-id team-id
|
||||
@@ -718,9 +720,10 @@
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id})
|
||||
(let [name (d/normalize-string name)]
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id}))
|
||||
nil)
|
||||
|
||||
|
||||
|
||||
@@ -46,10 +46,29 @@
|
||||
|
||||
(def sql:upsert-organization-invitation
|
||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||
values (?, null, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, email_to) where team_id is null do
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
returning *")
|
||||
values (?, null, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, email_to) where team_id is null do
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
returning *")
|
||||
|
||||
(def ^:private sql:check-recent-invitation
|
||||
"SELECT 1 FROM team_invitation
|
||||
WHERE team_id = ? AND email_to = ?
|
||||
AND updated_at > now() - interval '5 minutes'
|
||||
LIMIT 1")
|
||||
|
||||
(def ^:private sql:check-recent-org-invitation
|
||||
"SELECT 1 FROM team_invitation
|
||||
WHERE org_id = ? AND email_to = ?
|
||||
AND updated_at > now() - interval '5 minutes'
|
||||
LIMIT 1")
|
||||
|
||||
(defn- recently-invited?
|
||||
[{:keys [::db/conn]} team-id org-id email]
|
||||
(let [query (if org-id
|
||||
[sql:check-recent-org-invitation org-id email]
|
||||
[sql:check-recent-invitation team-id email])]
|
||||
(some? (db/exec-one! conn query))))
|
||||
|
||||
(defn- create-invitation-token
|
||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||
@@ -185,35 +204,36 @@
|
||||
(teams/check-email-bounce conn email true)
|
||||
(teams/check-email-spam conn email true)
|
||||
|
||||
(let [id (uuid/next)
|
||||
expire (if organization
|
||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||
(ct/in-future "168h")) ;; 7 days
|
||||
invitation (db/exec-one! conn (if organization
|
||||
[sql:upsert-organization-invitation id
|
||||
(:id organization)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]
|
||||
[sql:upsert-team-invitation id
|
||||
(:id team)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]))
|
||||
updated? (not= id (:id invitation))
|
||||
profile-id (:id profile)
|
||||
(let [id (uuid/next)
|
||||
expire (if organization
|
||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||
(ct/in-future "168h")) ;; 7 days
|
||||
recent? (recently-invited? cfg (:id team) (:id organization) email)
|
||||
invitation (db/exec-one! conn (if organization
|
||||
[sql:upsert-organization-invitation id
|
||||
(:id organization)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]
|
||||
[sql:upsert-team-invitation id
|
||||
(:id team)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]))
|
||||
updated? (not= id (:id invitation))
|
||||
profile-id (:id profile)
|
||||
team-organization-id (get-in team [:organization :id])
|
||||
tprops {:profile-id profile-id
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
:team-id (:id team)
|
||||
:organization-id (:id organization)
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
tprops {:profile-id profile-id
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
:team-id (:id team)
|
||||
:organization-id (:id organization)
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
audit-props
|
||||
(cond-> {:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
@@ -234,8 +254,8 @@
|
||||
(and team-organization-id
|
||||
member
|
||||
(contains? all-organization-member-ids (:id member))))))
|
||||
itoken (create-invitation-token cfg tprops)
|
||||
ptoken (create-profile-identity-token cfg profile-id)]
|
||||
itoken (create-invitation-token cfg tprops)
|
||||
ptoken (create-profile-identity-token cfg profile-id)]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/info :hint "invitation token" :token itoken))
|
||||
@@ -251,7 +271,8 @@
|
||||
(assoc :props props))]
|
||||
(audit/submit cfg event))
|
||||
|
||||
(when (allow-invitation-emails? member)
|
||||
(when (and (allow-invitation-emails? member)
|
||||
(not recent?))
|
||||
(if organization
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(eml/send! {::eml/conn conn
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn get-webhooks-permissions
|
||||
[conn profile-id team-id creator-id]
|
||||
[conn profile-id team-id]
|
||||
(let [permissions (t/get-permissions conn profile-id team-id)
|
||||
|
||||
can-edit (boolean (or (:can-edit permissions)
|
||||
(= profile-id creator-id)))]
|
||||
can-edit (boolean (:can-edit permissions))]
|
||||
(assoc permissions :can-edit can-edit)))
|
||||
|
||||
(def has-webhook-edit-permissions?
|
||||
@@ -120,7 +118,7 @@
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:create-webhook}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
|
||||
(t/check-edition-permissions! pool profile-id team-id)
|
||||
(validate-quotes! cfg params)
|
||||
(validate-webhook! cfg nil params)
|
||||
(insert-webhook! cfg params))
|
||||
@@ -137,7 +135,7 @@
|
||||
::sm/params schema:update-webhook}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
||||
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
|
||||
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
|
||||
(validate-webhook! cfg whook params)
|
||||
(update-webhook! cfg whook params)))
|
||||
|
||||
@@ -151,7 +149,7 @@
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
|
||||
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
||||
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
|
||||
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
|
||||
(db/delete! conn :webhook {:id id})
|
||||
nil))
|
||||
|
||||
|
||||
@@ -518,3 +518,16 @@
|
||||
loc (redirect-location result)]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "error=unable-to-auth")))))))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
|
||||
(t/testing "organization SSO provider must use SSRF protection"
|
||||
(let [captured-params (atom nil)]
|
||||
(with-redefs [oidc/prepare-oidc-provider (fn [_cfg params]
|
||||
(reset! captured-params params)
|
||||
{:type "oidc" :id "test"})]
|
||||
(#'oidc/prepare-organization-sso-provider {}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"})
|
||||
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
|
||||
"SSRF protection must be disabled for organization SSO")))))
|
||||
@@ -8,6 +8,7 @@
|
||||
"Internal binfile test, no RPC involved"
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as v1]
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.validate :as cfv]
|
||||
@@ -25,7 +26,10 @@
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]))
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.ByteArrayInputStream
|
||||
java.io.DataInputStream))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
@@ -202,3 +206,26 @@
|
||||
(v3/import-files!))]
|
||||
(t/is (= (count result) 1))
|
||||
(t/is (every? uuid? result)))))
|
||||
|
||||
(t/deftest read-obj-rejects-oversized-buffer
|
||||
;; N1-07: read-obj! must reject objects exceeding max-object-size
|
||||
;; before attempting to allocate the buffer
|
||||
(let [size (+ bfc/max-object-size 1)
|
||||
baos (java.io.ByteArrayOutputStream. 17)
|
||||
dos (java.io.DataOutputStream. baos)]
|
||||
(.writeByte dos 5)
|
||||
(.writeLong dos (long size))
|
||||
(.flush dos)
|
||||
(let [input (java.io.DataInputStream.
|
||||
(ByteArrayInputStream. (.toByteArray baos)))]
|
||||
(binding [v1/*position* (atom 0)]
|
||||
(let [out (try
|
||||
(v1/read-obj! input)
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(ex-data e)))]
|
||||
;; Without the guard, read-obj! will either OOM or proceed
|
||||
;; to read-bytes! on a truncated stream (no :max-file-size-reached).
|
||||
;; With the guard, it raises :validation :max-file-size-reached.
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :max-file-size-reached (:code out))))))))
|
||||
@@ -189,7 +189,7 @@
|
||||
(let [params (merge {:id (mk-uuid "profile" i)
|
||||
:fullname (str "Profile " i)
|
||||
:email (str "profile" i ".test@nodomain.com")
|
||||
:password "123123"
|
||||
:password "Test123!"
|
||||
:is-demo false}
|
||||
params)]
|
||||
(db/run! system
|
||||
|
||||
@@ -459,6 +459,135 @@
|
||||
;; Tests: objects-handler — expired objects
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Tests: file-objects-handler — authz required (T2-N1-01)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest file-objects-handler-unauthenticated-returns-404
|
||||
;; Unauthenticated requests to file-media assets must return 404
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-no-file-perms-returns-404
|
||||
;; Authenticated user without file read permissions must get 404
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
stranger (th/create-profile* 2)
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
::session/profile-id (:id stranger)}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-with-file-perms-succeeds
|
||||
;; Authenticated user with file read permissions must get the object
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
::session/profile-id (:id owner)}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-thumbnails-handler-unauthenticated-returns-404
|
||||
;; Unauthenticated requests to file-thumbnail assets must return 404
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}}
|
||||
response (assets/file-thumbnails-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-thumbnails-handler-with-file-perms-succeeds
|
||||
;; Authenticated user with file read permissions must get the thumbnail
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id thumb-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
::session/profile-id (:id owner)}
|
||||
response (assets/file-thumbnails-handler cfg request)]
|
||||
;; Falls back to media-id since no thumbnail-id, but still serves
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-non-existent-media-returns-404
|
||||
;; Request for non-existent file-media-object returns 404
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
profile (th/create-profile* 1)
|
||||
request {:path-params {:id (str (uuid/next))}
|
||||
::session/profile-id (:id profile)}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-nil-profile-id-returns-404
|
||||
;; When profile-id is nil (invalid session), must return 404
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
::session/profile-id nil}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-expired-object
|
||||
;; Expired objects should return 404 (get-object filters them out).
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.media :as media]
|
||||
[app.media.svg :as svg]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
@@ -55,6 +56,87 @@
|
||||
(t/is (pos? (:width info)))
|
||||
(t/is (pos? (:height info))))))
|
||||
|
||||
(t/deftest sanitize-svg-script-tag
|
||||
(t/testing "sanitize-svg removes script tags"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><script>alert('xss')</script><rect width=\"50\" height=\"50\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "<script>")))
|
||||
(t/is (not (clojure.string/includes? result "alert")))
|
||||
(t/is (clojure.string/includes? result "<rect")))))
|
||||
|
||||
(t/deftest sanitize-svg-event-handlers
|
||||
(t/testing "sanitize-svg removes event handler attributes"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" onload=\"alert('xss')\"><rect width=\"50\" height=\"50\" onmouseover=\"alert('xss')\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "onload")))
|
||||
(t/is (not (clojure.string/includes? result "onmouseover")))
|
||||
(t/is (not (clojure.string/includes? result "alert")))
|
||||
(t/is (clojure.string/includes? result "<rect")))))
|
||||
|
||||
(t/deftest sanitize-svg-javascript-href
|
||||
(t/testing "sanitize-svg removes javascript: URLs from href attributes"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><a xlink:href=\"javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "javascript:")))
|
||||
(t/is (not (clojure.string/includes? result "alert")))
|
||||
(t/is (clojure.string/includes? result "<a")))))
|
||||
|
||||
(t/deftest sanitize-svg-foreign-object
|
||||
(t/testing "sanitize-svg removes foreignObject elements"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><foreignObject width=\"100\" height=\"100\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert('xss')</script></body></foreignObject><rect width=\"50\" height=\"50\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "foreignObject")))
|
||||
(t/is (not (clojure.string/includes? result "<script>")))
|
||||
(t/is (clojure.string/includes? result "<rect")))))
|
||||
|
||||
(t/deftest sanitize-svg-clean-content
|
||||
(t/testing "sanitize-svg preserves clean SVG content"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"50\" height=\"50\" fill=\"red\"/><circle cx=\"75\" cy=\"75\" r=\"20\" fill=\"blue\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (clojure.string/includes? result "<rect"))
|
||||
(t/is (clojure.string/includes? result "<circle"))
|
||||
(t/is (or (clojure.string/includes? result "fill=\"red\"")
|
||||
(clojure.string/includes? result "fill='red'")))
|
||||
(t/is (or (clojure.string/includes? result "fill=\"blue\"")
|
||||
(clojure.string/includes? result "fill='blue'"))))))
|
||||
|
||||
(t/deftest sanitize-svg-invalid-svg-rejected
|
||||
(t/testing "sanitize-svg rejects malformed SVG input"
|
||||
(let [svg "<svg><not-closed>"]
|
||||
(t/is (thrown-with-msg? Exception #"SVG parsing failed during sanitization"
|
||||
(svg/sanitize-svg svg))))))
|
||||
|
||||
(t/deftest sanitize-svg-preserves-xlink
|
||||
(t/testing "sanitize-svg preserves legitimate xlink:href attributes"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><use xlink:href=\"#icon\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (clojure.string/includes? result "xlink:href"))
|
||||
(t/is (clojure.string/includes? result "#icon")))))
|
||||
|
||||
(t/deftest sanitize-svg-javascript-href-whitespace
|
||||
(t/testing "sanitize-svg catches javascript: URLs with leading whitespace"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><a href=\" javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "javascript:")))
|
||||
(t/is (not (clojure.string/includes? result "alert")))
|
||||
(t/is (clojure.string/includes? result "<a")))))
|
||||
|
||||
(t/deftest sanitize-svg-nested-script
|
||||
(t/testing "sanitize-svg removes script tags from nested elements"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><g><script>alert('xss')</script></g></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "<script")))
|
||||
(t/is (not (clojure.string/includes? result "alert")))
|
||||
(t/is (clojure.string/includes? result "<g")))))
|
||||
|
||||
(t/deftest sanitize-svg-smil-bypass
|
||||
(t/testing "sanitize-svg removes SMIL animation elements that can set on* attrs"
|
||||
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"100\" height=\"100\" id=\"r\"/><set attributeName=\"onmouseover\" to=\"alert('xss')\" xlink:href=\"#r\" begin=\"0s\"/></svg>"
|
||||
result (svg/sanitize-svg svg)]
|
||||
(t/is (not (clojure.string/includes? result "<set")))
|
||||
(t/is (not (clojure.string/includes? result "onmouseover")))
|
||||
(t/is (clojure.string/includes? result "<rect")))))
|
||||
|
||||
(t/deftest info-invalid-image
|
||||
(t/testing "info on invalid image raises error"
|
||||
(let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-binfile-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.binfile :as binfile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest import-binfile-schema-rejects-file-id
|
||||
;; N1-06: file-id parameter must be removed from schema for security
|
||||
;; The schema should not accept file-id as a valid parameter
|
||||
(let [schema @#'binfile/schema:import-binfile
|
||||
validator (sm/lazy-validator schema)
|
||||
|
||||
;; Valid params without file-id
|
||||
valid-params {:name "test"
|
||||
:project-id (uuid/random)
|
||||
:version 3
|
||||
:upload-id (uuid/random)}
|
||||
|
||||
;; Params with file-id (should be rejected after fix)
|
||||
params-with-file-id (assoc valid-params :file-id (uuid/random))]
|
||||
|
||||
;; Valid params without file-id should pass
|
||||
(t/is (true? (validator valid-params))
|
||||
"params without file-id should be valid")
|
||||
|
||||
;; Params with file-id should fail validation after fix
|
||||
;; (Currently this will fail because file-id is still in schema)
|
||||
(t/is (false? (validator params-with-file-id))
|
||||
"params with file-id should be rejected")))
|
||||
@@ -0,0 +1,39 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-feedback-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.rpc.commands.feedback :as feedback]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest send-user-feedback-schema-validation
|
||||
(let [schema feedback/schema:send-user-feedback]
|
||||
|
||||
(t/testing "accepts valid feedback with all fields"
|
||||
(let [params {:subject "Test subject"
|
||||
:content "Test content"
|
||||
:type "bug"
|
||||
:error-href "https://example.com/error"
|
||||
:error-report "Error details here"}]
|
||||
(t/is (sm/valid? schema params))))
|
||||
|
||||
(t/testing "accepts feedback without optional fields"
|
||||
(let [params {:subject "Test subject"
|
||||
:content "Test content"}]
|
||||
(t/is (sm/valid? schema params))))
|
||||
|
||||
(t/testing "accepts error-report up to 1MiB"
|
||||
(let [params {:subject "Test subject"
|
||||
:content "Test content"
|
||||
:error-report (apply str (repeat 1048576 "x"))}]
|
||||
(t/is (sm/valid? schema params))))
|
||||
|
||||
(t/testing "rejects error-report exceeding 1MiB"
|
||||
(let [params {:subject "Test subject"
|
||||
:content "Test content"
|
||||
:error-report (apply str (repeat 1048577 "x"))}]
|
||||
(t/is (not (sm/valid? schema params)))))))
|
||||
@@ -141,6 +141,31 @@
|
||||
(let [result (:result out)]
|
||||
(t/is (= 0 (count result))))))))
|
||||
|
||||
(t/deftest create-file-with-duplicate-id
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id prof)
|
||||
file-id (uuid/next)]
|
||||
|
||||
(t/testing "create file with specific id"
|
||||
(let [data {::th/type :create-file
|
||||
::rpc/profile-id (:id prof)
|
||||
:project-id proj-id
|
||||
:id file-id
|
||||
:name "first-file"}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "create file with duplicate id returns normalized error"
|
||||
(let [data {::th/type :create-file
|
||||
::rpc/profile-id (:id prof)
|
||||
:project-id proj-id
|
||||
:id file-id
|
||||
:name "duplicate-file"}
|
||||
out (th/command! data)
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))))
|
||||
|
||||
(t/deftest file-gc-with-fragments
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
@@ -983,6 +1008,38 @@
|
||||
(t/is (some? sync))
|
||||
(t/is (some? (:synced-at sync)))))
|
||||
|
||||
(t/deftest link-file-to-library-rejects-cross-team
|
||||
;; N1-08: A file in team2 must not be linked to a library in team1,
|
||||
;; even when the user has edit permissions on both (BOLA / CWE-639).
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
prof2 (th/create-profile* 2)
|
||||
team1 (th/create-team* 1 {:profile-id (:id prof1)})
|
||||
team2 (th/create-team* 2 {:profile-id (:id prof2)})
|
||||
proj1 (th/create-project* 1 {:profile-id (:id prof1)
|
||||
:team-id (:id team1)})
|
||||
proj2 (th/create-project* 2 {:profile-id (:id prof2)
|
||||
:team-id (:id team2)})
|
||||
lib (th/create-file* 1 {:project-id (:id proj1)
|
||||
:profile-id (:id prof1)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:project-id (:id proj2)
|
||||
:profile-id (:id prof2)})]
|
||||
|
||||
;; Add prof2 as editor to team1 so they have edit access to the library
|
||||
(th/db-insert! :team-profile-rel {:team-id (:id team1)
|
||||
:profile-id (:id prof2)
|
||||
:is-owner false
|
||||
:is-admin false
|
||||
:can-edit true})
|
||||
|
||||
;; prof2 tries to link file2 (team2) to lib (team1) — must fail
|
||||
(let [data {::th/type :link-file-to-library
|
||||
::rpc/profile-id (:id prof2)
|
||||
:file-id (:id file2)
|
||||
:library-id (:id lib)}
|
||||
out (th/command! data)]
|
||||
(t/is (some? (:error out))))))
|
||||
|
||||
(t/deftest update-file-library-sync-status-updates-sync-row
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :login-with-password
|
||||
:email "profile1.test@nodomain.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
#_(th/print-result! out)
|
||||
@@ -56,7 +56,7 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :login-with-password
|
||||
:email "profile1.test@nodomain.com"
|
||||
:password "123123"}
|
||||
:password "Test123!"}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(let [error (:error out)]
|
||||
@@ -69,7 +69,7 @@
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
data {::th/type :login-with-password
|
||||
:email "profile1.test@nodomain.com"
|
||||
:password "123123"}
|
||||
:password "Test123!"}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@@ -403,7 +403,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "user@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"
|
||||
:password "Foobar12!"
|
||||
:utm_campaign "utma"
|
||||
:mtm_campaign "mtma"}
|
||||
out (th/command! data)
|
||||
@@ -444,7 +444,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -463,7 +463,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -498,7 +498,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -521,7 +521,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -547,7 +547,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -576,7 +576,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
@@ -614,7 +614,7 @@
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
|
||||
{prep-result :result prep-error :error} (th/command! prep-data)]
|
||||
(t/is (nil? prep-error))
|
||||
@@ -659,7 +659,7 @@
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
|
||||
{prep-result :result prep-error :error} (th/command! prep-data)]
|
||||
(t/is (nil? prep-error))
|
||||
@@ -692,7 +692,7 @@
|
||||
:invitation-token itoken
|
||||
:email "user@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
@@ -712,7 +712,7 @@
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
@@ -733,7 +733,7 @@
|
||||
:invitation-token itoken
|
||||
:email "user@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
@@ -754,7 +754,7 @@
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
@@ -767,7 +767,7 @@
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
@@ -780,7 +780,7 @@
|
||||
data {::th/type :prepare-register-profile
|
||||
:fullname "foobar"
|
||||
:email (:email profile)
|
||||
:password "foobar"}
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
@@ -793,7 +793,7 @@
|
||||
data {::th/type :prepare-register-profile
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}]
|
||||
:password "Foobar12!"}]
|
||||
|
||||
(th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
|
||||
|
||||
@@ -808,7 +808,7 @@
|
||||
data {::th/type :prepare-register-profile
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}]
|
||||
:password "Foobar12!"}]
|
||||
|
||||
(th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
|
||||
|
||||
@@ -1131,8 +1131,8 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-password
|
||||
::rpc/profile-id (:id profile)
|
||||
:old-password "123123"
|
||||
:password "foobarfoobar"}
|
||||
:old-password "Test123!"
|
||||
:password "Foobar12!"}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))))
|
||||
@@ -1143,7 +1143,7 @@
|
||||
data {::th/type :update-profile-password
|
||||
::rpc/profile-id (:id profile)
|
||||
:old-password "badpassword"
|
||||
:password "foobarfoobar"}
|
||||
:password "Foobar12!"}
|
||||
{:keys [result error] :as out} (th/command! data)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
@@ -1154,7 +1154,7 @@
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-password
|
||||
::rpc/profile-id (:id profile)
|
||||
:old-password "123123"
|
||||
:old-password "Test123!"
|
||||
:password "profile1.test@nodomain.com"}
|
||||
{:keys [result error] :as out} (th/command! data)]
|
||||
(t/is (th/ex-info? error))
|
||||
@@ -1271,3 +1271,49 @@
|
||||
(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 prepare-register-profile-password-too-short
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "user@example.com"
|
||||
:fullname "foobar"
|
||||
:password "123"}
|
||||
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) :weak-password))))
|
||||
|
||||
|
||||
(t/deftest prepare-register-profile-weak-password
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "user@example.com"
|
||||
:fullname "foobar"
|
||||
:password "password123"}
|
||||
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) :weak-password))))
|
||||
|
||||
|
||||
(t/deftest update-profile-password-too-short
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-password
|
||||
::rpc/profile-id (:id profile)
|
||||
:old-password "Test123!"
|
||||
:password "123"}
|
||||
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) :weak-password))))
|
||||
|
||||
|
||||
(t/deftest update-profile-password-weak-password
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :update-profile-password
|
||||
::rpc/profile-id (:id profile)
|
||||
:old-password "Test123!"
|
||||
:password "qwerty"}
|
||||
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) :weak-password))))
|
||||
@@ -1015,6 +1015,46 @@
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
(t/deftest create-team-invitations-email-cooldown
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
|
||||
data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id profile1)
|
||||
:team-id (:id team)
|
||||
:role :editor
|
||||
:emails ["cooldown-test@example.com"]}]
|
||||
|
||||
;; First invitation sends email
|
||||
(let [out (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (:call-count @mock))))
|
||||
|
||||
;; Resending immediately should NOT send email (cooldown active)
|
||||
(th/reset-mock! mock)
|
||||
(let [out (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 0 (:call-count @mock))))
|
||||
|
||||
;; Resending to a different email should send email
|
||||
(th/reset-mock! mock)
|
||||
(let [data (assoc data :emails ["different@example.com"])
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (:call-count @mock))))
|
||||
|
||||
;; After cooldown expires, resending should send email
|
||||
(th/reset-mock! mock)
|
||||
(th/db-update! :team-invitation
|
||||
{:updated-at (ct/in-past "10m")}
|
||||
{:team-id (:id team)
|
||||
:email-to "cooldown-test@example.com"})
|
||||
(let [data (assoc data :emails ["cooldown-test@example.com"])
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (:call-count @mock)))))))
|
||||
|
||||
(t/deftest update-team-with-invalid-name
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})]
|
||||
|
||||
@@ -155,8 +155,7 @@
|
||||
:return {:status 200}}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
whook (volatile! nil)]
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id viewer)
|
||||
:role :viewer})
|
||||
@@ -164,52 +163,15 @@
|
||||
(let [roles (th/db-query :team-profile-rel {:team-id (:id team)})]
|
||||
(t/is (= 2 (count roles))))
|
||||
|
||||
(t/testing "viewer creates a webhook"
|
||||
(t/testing "viewer cannot create a webhook (requires editor role)"
|
||||
(let [viewers-webhook (create-webhook-params (:id viewer) (:id team))
|
||||
out (th/command! viewers-webhook)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:call-count @http-mock)))
|
||||
|
||||
(let [result (:result out)]
|
||||
(check-webhook-format result)
|
||||
(t/is (= (:uri viewers-webhook) (:uri result)))
|
||||
(t/is (= (:team-id viewers-webhook) (:team-id result)))
|
||||
(t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result)))
|
||||
(t/is (= (:mtype viewers-webhook) (:mtype result)))
|
||||
(vreset! whook result))))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "viewer updates it's own webhook (success)"
|
||||
(let [params {::th/type :update-webhook
|
||||
::rpc/profile-id (:id viewer)
|
||||
:id (:id @whook)
|
||||
:uri (:uri @whook)
|
||||
:mtype "application/transit+json"
|
||||
:is-active false}
|
||||
out (th/command! params)
|
||||
result (:result out)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 0 (:call-count @http-mock)))
|
||||
(check-webhook-format result)
|
||||
(t/is (= (:is-active params) (:is-active result)))
|
||||
(t/is (= (:team-id @whook) (:team-id result)))
|
||||
(t/is (= (:mtype params) (:mtype result)))
|
||||
(vreset! whook result)))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "viewer deletes it's own webhook (success)"
|
||||
(let [params {::th/type :delete-webhook
|
||||
::rpc/profile-id (:id viewer)
|
||||
:id (:id @whook)}
|
||||
out (th/command! params)]
|
||||
(t/is (= 0 (:call-count @http-mock)))
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))
|
||||
(let [rows (th/db-exec! ["select * from webhook"])]
|
||||
(t/is (= 0 (count rows))))))
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))
|
||||
(t/is (= (:code error-data) :object-not-found)))))
|
||||
|
||||
(th/reset-mock! http-mock))))
|
||||
|
||||
@@ -268,6 +230,26 @@
|
||||
(t/is (= (:type error-data) :not-found))
|
||||
(t/is (= (:code error-data) :object-not-found)))))))
|
||||
|
||||
(t/deftest webhooks-viewer-cannot-create
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200}}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id viewer)
|
||||
:role :viewer})
|
||||
|
||||
(t/testing "viewer cannot create a webhook on the team"
|
||||
(let [params (create-webhook-params (:id viewer) (:id team))
|
||||
out (th/command! params)]
|
||||
(t/is (= 0 (:call-count @http-mock)))
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))
|
||||
(t/is (= (:code error-data) :object-not-found))))))))
|
||||
|
||||
(t/deftest webhooks-quotes
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200}}]
|
||||
@@ -304,3 +286,91 @@
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :restriction))
|
||||
(t/is (= (:code error-data) :webhooks-quote-reached))))))
|
||||
|
||||
(t/deftest removed-user-cannot-edit-webhook
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200}}]
|
||||
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
editor (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
|
||||
(let [params {::th/type :create-webhook
|
||||
::rpc/profile-id (:id editor)
|
||||
:team-id (:id team)
|
||||
:uri (u/uri "http://example.com")
|
||||
:mtype "application/json"}
|
||||
out (th/command! params)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(let [whook (:result out)]
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "owner can edit editor's webhook (team owns it)"
|
||||
(let [params {::th/type :update-webhook
|
||||
::rpc/profile-id (:id owner)
|
||||
:id (:id whook)
|
||||
:uri (u/uri "http://example.com/updated")
|
||||
:mtype "application/transit+json"
|
||||
:is-active true}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:call-count @http-mock)))))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "remove editor from team"
|
||||
(let [params {::th/type :delete-team-member
|
||||
::rpc/profile-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:member-id (:id editor)}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "removed editor cannot update webhook"
|
||||
(let [params {::th/type :update-webhook
|
||||
::rpc/profile-id (:id editor)
|
||||
:id (:id whook)
|
||||
:uri (u/uri "http://example.com/evil")
|
||||
:mtype "application/transit+json"
|
||||
:is-active true}
|
||||
out (th/command! params)]
|
||||
(t/is (= 0 (:call-count @http-mock)))
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))
|
||||
(t/is (= (:code error-data) :object-not-found)))))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "removed editor cannot delete webhook"
|
||||
(let [params {::th/type :delete-webhook
|
||||
::rpc/profile-id (:id editor)
|
||||
:id (:id whook)}
|
||||
out (th/command! params)]
|
||||
(t/is (= 0 (:call-count @http-mock)))
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))
|
||||
(t/is (= (:code error-data) :object-not-found)))))
|
||||
|
||||
(th/reset-mock! http-mock)
|
||||
|
||||
(t/testing "owner can still delete editor's webhook"
|
||||
(let [params {::th/type :delete-webhook
|
||||
::rpc/profile-id (:id owner)
|
||||
:id (:id whook)}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))
|
||||
(let [rows (th/db-exec! ["select * from webhook"])]
|
||||
(t/is (= 0 (count rows)))))))))))
|
||||
@@ -1173,6 +1173,15 @@
|
||||
[key coll]
|
||||
(sort-by key natural-compare coll))
|
||||
|
||||
(defn normalize-string
|
||||
"Normalizes a string by trimming leading/trailing whitespace.
|
||||
Returns empty string for nil input. Non-string input is returned unchanged."
|
||||
[s]
|
||||
(cond
|
||||
(nil? s) ""
|
||||
(string? s) (str/trim s)
|
||||
:else s))
|
||||
|
||||
(defn sanitize-string [s]
|
||||
(if s
|
||||
(-> s
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
([^String s, ^String encoding]
|
||||
(.getBytes s encoding)))
|
||||
|
||||
;; --- DEPTH TRACKING
|
||||
|
||||
(def ^:dynamic *read-depth* 0)
|
||||
(def ^:const max-read-depth 128)
|
||||
|
||||
;; --- LOW LEVEL FRESSIAN API
|
||||
|
||||
(defn write-object!
|
||||
@@ -41,7 +46,13 @@
|
||||
|
||||
(defn read-object!
|
||||
[^Reader r]
|
||||
(.readObject r))
|
||||
(when (>= *read-depth* max-read-depth)
|
||||
(throw (ex-info "maximum Fressian read depth exceeded"
|
||||
{:type :validation
|
||||
:code :max-read-depth-reached
|
||||
:hint "maximum Fressian read depth exceeded"})))
|
||||
(binding [*read-depth* (inc *read-depth*)]
|
||||
(.readObject r)))
|
||||
|
||||
(defn write-tag!
|
||||
([^Writer w ^String n]
|
||||
|
||||
@@ -36,6 +36,24 @@
|
||||
(t/is (= "" (d/get-initials nil)))
|
||||
(t/is (= "" (d/get-initials "!!! ???"))))
|
||||
|
||||
(t/deftest normalize-string-test
|
||||
;; nil input returns empty string
|
||||
(t/is (= "" (d/normalize-string nil)))
|
||||
;; empty string returns empty string
|
||||
(t/is (= "" (d/normalize-string "")))
|
||||
;; leading whitespace is trimmed
|
||||
(t/is (= "hello" (d/normalize-string " hello")))
|
||||
;; trailing whitespace is trimmed
|
||||
(t/is (= "hello" (d/normalize-string "hello ")))
|
||||
;; both leading and trailing whitespace are trimmed
|
||||
(t/is (= "hello" (d/normalize-string " hello ")))
|
||||
;; internal whitespace is preserved
|
||||
(t/is (= "hello world" (d/normalize-string " hello world ")))
|
||||
;; non-string input is returned unchanged
|
||||
(t/is (= 42 (d/normalize-string 42)))
|
||||
(t/is (= :keyword (d/normalize-string :keyword)))
|
||||
(t/is (= true (d/normalize-string true))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Ordered Data Structures
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
(:import
|
||||
java.time.Instant
|
||||
java.time.OffsetDateTime
|
||||
java.time.ZoneOffset))
|
||||
java.time.ZoneOffset
|
||||
java.util.UUID))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Helpers
|
||||
@@ -524,3 +525,18 @@
|
||||
(t/is (d/ordered-map? rt))
|
||||
(t/is (= om rt))
|
||||
(t/is (= (keys om) (keys rt)))))
|
||||
|
||||
(t/deftest decode-rejects-excessive-recursion-depth
|
||||
;; N2-01: deeply nested structures must be rejected before stack overflow
|
||||
(let [depth (+ fres/max-read-depth 50)
|
||||
data (reduce (fn [acc _i] [acc])
|
||||
:leaf
|
||||
(range depth))
|
||||
encoded (fres/encode data)]
|
||||
(try
|
||||
(fres/decode encoded)
|
||||
(t/is false "expected exception for excessive recursion depth")
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [d (ex-data e)]
|
||||
(t/is (= :validation (:type d)))
|
||||
(t/is (= :max-read-depth-reached (:code d))))))))
|
||||
@@ -22,7 +22,8 @@ export const {
|
||||
} = pkg;
|
||||
|
||||
import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js';
|
||||
import {Map, OrderedSet} from "immutable";
|
||||
import Immutable from "immutable";
|
||||
const {Map, OrderedSet} = Immutable;
|
||||
|
||||
function isDefined(v) {
|
||||
return v !== undefined && v !== null;
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"author": "Andrey Antukh",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d",
|
||||
"immutable": "^5.1.9"
|
||||
"draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=0.17.0",
|
||||
|
||||
Generated
+14
-10
@@ -18,7 +18,6 @@ overrides:
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
yaml@>=2.0.0 <2.8.3: ^2.8.3
|
||||
playwright@>=1.61.1 <2.0.0-0: 1.62.1
|
||||
immutable@<4.3.9: ^4.3.9
|
||||
|
||||
patchedDependencies:
|
||||
'@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95
|
||||
@@ -271,9 +270,6 @@ importers:
|
||||
draft-js:
|
||||
specifier: penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d
|
||||
version: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
immutable:
|
||||
specifier: ^5.1.9
|
||||
version: 5.1.9
|
||||
react:
|
||||
specifier: '>=0.17.0'
|
||||
version: 19.2.8
|
||||
@@ -2193,6 +2189,11 @@ packages:
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@webcontainer/env@1.1.1':
|
||||
resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
|
||||
@@ -3510,8 +3511,9 @@ packages:
|
||||
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@4.3.9:
|
||||
resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==}
|
||||
immutable@3.8.3:
|
||||
resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
immutable@5.1.9:
|
||||
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
|
||||
@@ -7559,11 +7561,13 @@ snapshots:
|
||||
|
||||
'@volar/source-map@2.4.28': {}
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
'@volar/typescript@2.4.28(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@webcontainer/env@1.1.1': {}
|
||||
|
||||
@@ -8332,7 +8336,7 @@ snapshots:
|
||||
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
fbjs: 3.0.5(encoding@0.1.13)
|
||||
immutable: 4.3.9
|
||||
immutable: 3.8.3
|
||||
object-assign: 4.1.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
@@ -9081,7 +9085,7 @@ snapshots:
|
||||
|
||||
ignore@7.0.6: {}
|
||||
|
||||
immutable@4.3.9: {}
|
||||
immutable@3.8.3: {}
|
||||
|
||||
immutable@5.1.9: {}
|
||||
|
||||
@@ -11197,7 +11201,7 @@ snapshots:
|
||||
unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.61.1)
|
||||
'@volar/typescript': 2.4.28
|
||||
'@volar/typescript': 2.4.28(typescript@6.0.3)
|
||||
compare-versions: 6.1.1
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
kolorist: 1.8.0
|
||||
|
||||
@@ -32,4 +32,3 @@ overrides:
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
yaml@>=2.0.0 <2.8.3: ^2.8.3
|
||||
playwright@>=1.61.1 <2.0.0-0: "1.62.1"
|
||||
immutable@<4.3.9: ^4.3.9
|
||||
@@ -462,11 +462,14 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [{:keys [on-error on-success]
|
||||
:or {on-error rx/throw
|
||||
:or {on-error identity
|
||||
on-success identity}} (meta data)]
|
||||
(->> (rp/cmd! :recover-profile data)
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error)))))))
|
||||
(rx/catch (fn [err]
|
||||
(on-error err)
|
||||
(rx/empty)))
|
||||
(rx/ignore)))))))
|
||||
|
||||
;; --- EVENT: fetch-team-webhooks
|
||||
|
||||
|
||||
@@ -204,20 +204,27 @@
|
||||
|
||||
(defn- bind!
|
||||
[shortcuts]
|
||||
(->> shortcuts
|
||||
(remove #(:disabled (second %)))
|
||||
(run! (fn [[key {:keys [command fn type overwrite]}]]
|
||||
(let [callback (wrap-cb key fn)
|
||||
commands (if (vector? command)
|
||||
(into-array command)
|
||||
#js [command])]
|
||||
(if (vector? type)
|
||||
(do (mousetrap/bind commands callback (nth type 0) overwrite)
|
||||
(mousetrap/bind commands callback (nth type 1) overwrite))
|
||||
(let [undefined (js* "(void 0)")]
|
||||
(if type
|
||||
(mousetrap/bind commands callback type overwrite)
|
||||
(mousetrap/bind commands callback undefined overwrite)))))))))
|
||||
(let [entries (remove #(:disabled (second %)) shortcuts)
|
||||
bind-fn (fn [[key {:keys [command fn type overwrite]}]]
|
||||
(let [callback (wrap-cb key fn)
|
||||
commands (if (vector? command)
|
||||
(into-array command)
|
||||
#js [command])]
|
||||
(if (vector? type)
|
||||
(do (mousetrap/bind commands callback (nth type 0) overwrite)
|
||||
(mousetrap/bind commands callback (nth type 1) overwrite))
|
||||
(let [undefined (js* "(void 0)")]
|
||||
(if type
|
||||
(mousetrap/bind commands callback type overwrite)
|
||||
(mousetrap/bind commands callback undefined overwrite))))))]
|
||||
;; Bind non-overwrite entries first so that entries flagged with
|
||||
;; `:overwrite` are bound last and can reliably splice out the
|
||||
;; colliding callbacks bound earlier (mousetrap's overwrite only
|
||||
;; removes callbacks that were already registered for the same
|
||||
;; combo). Map iteration order is hash-based, so we must force the
|
||||
;; order explicitly.
|
||||
(run! bind-fn (remove (comp :overwrite second) entries))
|
||||
(run! bind-fn (filter (comp :overwrite second) entries))))
|
||||
|
||||
(defn- reset!
|
||||
([]
|
||||
|
||||
@@ -1217,7 +1217,7 @@
|
||||
(rx/mapcat (fn [blob]
|
||||
;; Resolve the deferred with the fetched blob; the browser
|
||||
;; will now complete the clipboard write it started earlier.
|
||||
(p/resolve! deferred blob)
|
||||
(p/resolve deferred blob)
|
||||
(rx/from write-promise)))
|
||||
(rx/map (fn [_]
|
||||
(ntf/success (tr "workspace.clipboard.image-copied"))))
|
||||
@@ -1225,5 +1225,5 @@
|
||||
(js/console.error "clipboard error:" e)
|
||||
;; Reject the deferred in case the error occurred before the
|
||||
;; blob was fetched, so the pending clipboard write is cancelled.
|
||||
(p/reject! deferred e)
|
||||
(p/reject deferred e)
|
||||
(rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed")))))))))))
|
||||
@@ -37,6 +37,7 @@
|
||||
:command "p"
|
||||
:subsections [:path-editor]
|
||||
:section [:workspace]
|
||||
:overwrite true
|
||||
:fn #(st/emit! (drp/change-edit-mode :draw))}
|
||||
|
||||
:add-node {:tooltip (ds/shift "+")
|
||||
@@ -49,7 +50,9 @@
|
||||
:command ["del" "backspace"]
|
||||
:subsections [:path-editor]
|
||||
:section [:workspace]
|
||||
:fn #(st/emit! (drp/remove-node))}
|
||||
:overwrite true
|
||||
:fn #(st/emit!
|
||||
(drp/remove-node))}
|
||||
|
||||
:merge-nodes {:tooltip (ds/meta "J")
|
||||
:command (ds/c-mod "j")
|
||||
@@ -67,6 +70,7 @@
|
||||
:command "k"
|
||||
:subsections [:path-editor]
|
||||
:section [:workspace]
|
||||
:overwrite true
|
||||
:fn #(st/emit! (drp/separate-nodes))}
|
||||
|
||||
:make-corner {:tooltip "X"
|
||||
@@ -79,6 +83,7 @@
|
||||
:command "c"
|
||||
:subsections [:path-editor]
|
||||
:section [:workspace]
|
||||
:overwrite true
|
||||
:fn #(st/emit! (drp/make-curve))}
|
||||
|
||||
:snap-nodes {:tooltip (ds/meta "'")
|
||||
@@ -91,6 +96,7 @@
|
||||
:escape {:tooltip (ds/esc)
|
||||
:command ["escape" "enter" "v"]
|
||||
:section [:workspace]
|
||||
:overwrite true
|
||||
:fn #(st/emit! (esc-pressed))}
|
||||
|
||||
:undo {:tooltip (ds/meta "Z")
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
["react-dom/server" :as rds]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
@@ -59,6 +60,7 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:const viewbox-decimal-precision 3)
|
||||
(def ^:const max-export-dimension 100000)
|
||||
(def ^:private default-color clr/canvas)
|
||||
|
||||
(mf/defc background
|
||||
@@ -82,12 +84,20 @@
|
||||
(let [bounds
|
||||
(->> root-objects
|
||||
(map (partial gsb/get-object-bounds objects))
|
||||
(grc/join-rects))]
|
||||
(grc/join-rects))
|
||||
bounds (-> bounds
|
||||
(update :x mth/finite 0)
|
||||
(update :y mth/finite 0)
|
||||
(update :width mth/finite 100000)
|
||||
(update :height mth/finite 100000))]
|
||||
(when (or (> (:width bounds) max-export-dimension)
|
||||
(> (:height bounds) max-export-dimension)
|
||||
(> (+ (:x bounds) (:width bounds)) max-export-dimension)
|
||||
(> (+ (:y bounds) (:height bounds)) max-export-dimension))
|
||||
(ex/raise :type :validation
|
||||
:code :export-area-too-large
|
||||
:hint "export area exceeds maximum allowed dimensions"))
|
||||
(-> bounds
|
||||
(update :x mth/finite 0)
|
||||
(update :y mth/finite 0)
|
||||
(update :width mth/finite 100000)
|
||||
(update :height mth/finite 100000)
|
||||
(grc/update-rect :position)
|
||||
(grc/fix-aspect-ratio aspect-ratio))))))
|
||||
|
||||
|
||||
@@ -28,8 +28,18 @@
|
||||
(= password-1 password-2))]])
|
||||
|
||||
(defn- on-error
|
||||
[_form _error]
|
||||
(st/emit! (ntf/error (tr "errors.invalid-recovery-token"))))
|
||||
[form error]
|
||||
(let [{:keys [type code] :as edata} (ex-data error)]
|
||||
(if (= [:validation :weak-password] [type code])
|
||||
(let [details (:details edata)
|
||||
options (when (seq details)
|
||||
(mapv tr details))]
|
||||
(swap! form assoc-in [:extra-errors :password-1]
|
||||
{:message (tr "errors.weak-password")
|
||||
:options options}))
|
||||
|
||||
(let [msg (tr "errors.invalid-recovery-token")]
|
||||
(st/emit! (ntf/error msg))))))
|
||||
|
||||
(defn- on-success
|
||||
[_]
|
||||
@@ -38,7 +48,7 @@
|
||||
|
||||
(defn- on-submit
|
||||
[form _event]
|
||||
(let [mdata {:on-error on-error
|
||||
(let [mdata {:on-error (partial on-error form)
|
||||
:on-success on-success}
|
||||
params {:token (get-in @form [:clean-data :token])
|
||||
:password (get-in @form [:clean-data :password-2])}]
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.storage :as storage]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- PAGE: Register
|
||||
@@ -103,8 +104,20 @@
|
||||
(st/emit! (ntf/error (tr "errors.email-already-exists")))
|
||||
|
||||
[:validation :email-as-password]
|
||||
(swap! form assoc-in [:errors :password]
|
||||
{:message (tr "errors.email-as-password")})
|
||||
(st/emit! (ntf/error (tr "errors.email-as-password")))
|
||||
|
||||
[:validation :weak-password]
|
||||
(let [details (:details edata)
|
||||
items (when (seq details)
|
||||
(->> details
|
||||
(map #(str "<li>" (tr %) "</li>"))
|
||||
(str/join "")))
|
||||
detail (when items
|
||||
(str "<ul>" items "</ul>"))]
|
||||
(st/emit! (ntf/show {:content (tr "errors.weak-password")
|
||||
:detail detail
|
||||
:type :toast
|
||||
:level :error})))
|
||||
|
||||
(do
|
||||
(when-let [explain (get edata :explain)]
|
||||
|
||||
@@ -180,11 +180,17 @@
|
||||
|
||||
(cond
|
||||
(and touched? (:message error) show-error)
|
||||
(let [message (:message error)]
|
||||
(let [message (:message error)
|
||||
options (:options error)]
|
||||
[:div {:id (dm/str "error-" input-name)
|
||||
:class (stl/css :error)
|
||||
:data-testid (dm/str data-testid "-error")}
|
||||
message])
|
||||
message
|
||||
(when (seq options)
|
||||
[:ul {:class (stl/css :error-options)}
|
||||
(for [opt options]
|
||||
[:li {:key opt
|
||||
:class (stl/css :error-option)} opt])])])
|
||||
|
||||
;; FIXME: DEPRECATED
|
||||
(and touched? (:code error) show-error)
|
||||
|
||||
@@ -168,6 +168,16 @@
|
||||
font-size: deprecated.$fs-14;
|
||||
}
|
||||
|
||||
.error-options {
|
||||
margin-block: var(--sp-xxs);
|
||||
padding-inline-start: var(--sp-l);
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.error-option {
|
||||
margin-block: var(--sp-xxs);
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
|
||||
@@ -734,8 +734,8 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: $sz-32;
|
||||
height: $sz-32;
|
||||
width: $sz-48;
|
||||
height: $sz-48;
|
||||
|
||||
&:hover {
|
||||
--icon-stroke: var(--color-accent-primary);
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
(swap! form assoc-in [:extra-errors :password-1]
|
||||
{:message (tr "errors.email-as-password")})
|
||||
|
||||
:weak-password
|
||||
(let [details (:details data)
|
||||
options (when (seq details)
|
||||
(mapv tr details))]
|
||||
(swap! form assoc-in [:extra-errors :password-1]
|
||||
{:message (tr "errors.weak-password")
|
||||
:options options}))
|
||||
|
||||
(let [msg (tr "generic.error")]
|
||||
(st/emit! (ntf/error msg))))))
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.section-title,
|
||||
.subsection-title {
|
||||
@include t.use-typography("title-small");
|
||||
@include t.use-typography("headline-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -43,6 +43,7 @@
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
block-size: $sz-32;
|
||||
text-transform: none;
|
||||
padding-inline-start: var(--sp-m);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime."
|
||||
[]
|
||||
(when (p/pending? runtime-ready-promise)
|
||||
(p/resolve! runtime-ready-promise true)))
|
||||
(p/resolve runtime-ready-promise true)))
|
||||
|
||||
;; Stores the installed plugins information
|
||||
(defonce ^:private registry (atom {}))
|
||||
|
||||
@@ -385,18 +385,18 @@
|
||||
theme-id (uuid/next)
|
||||
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
|
||||
emitted (atom [])
|
||||
invalid (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly nil)
|
||||
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
|
||||
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
|
||||
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
|
||||
st/emit! (fn ([event] (swap! emitted conj event) nil)
|
||||
([event & _] (swap! emitted conj event) nil))]
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly nil)
|
||||
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
|
||||
u/throw-validation-errors? (constantly true)
|
||||
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
|
||||
st/emit! (fn ([event] (swap! emitted conj event) nil)
|
||||
([event & _] (swap! emitted conj event) nil))]
|
||||
(let [theme-proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
;; Non-id, non-proxy arguments are rejected by the schema coercer.
|
||||
(.addSet theme-proxy 42)
|
||||
(.removeSet theme-proxy nil)
|
||||
(try (.addSet theme-proxy 42) (catch :default e (swap! errors conj e)))
|
||||
(try (.removeSet theme-proxy nil) (catch :default e (swap! errors conj e)))
|
||||
(t/is (empty? @emitted))
|
||||
(t/is (= 2 (count @invalid)))
|
||||
(t/is (every? #(= :error (first %)) @invalid))))))
|
||||
(t/is (= 2 (count @errors)))
|
||||
(t/is (every? #(instance? js/Error %) @errors))))))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.render-dimensions-test
|
||||
(:require
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes.bounds :as gsb]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.render :as render]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(defn- make-objects
|
||||
"Create a proper objects map with a root frame and the given shapes."
|
||||
[& shapes]
|
||||
(let [root-frame (cts/setup-shape {:id uuid/zero
|
||||
:type :frame
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:name "Root Frame"
|
||||
:shapes (mapv :id shapes)})
|
||||
objects {uuid/zero root-frame}]
|
||||
(reduce (fn [objs shape]
|
||||
(assoc objs (:id shape) (assoc shape :frame-id uuid/zero)))
|
||||
objects
|
||||
shapes)))
|
||||
|
||||
(t/deftest calculate-dimensions-normal-bounds
|
||||
(t/testing "Normal bounding box should pass"
|
||||
(let [shape1 (cts/setup-shape {:type :rect :x 100 :y 100 :width 200 :height 150})
|
||||
shape2 (cts/setup-shape {:type :rect :x 400 :y 300 :width 100 :height 100})
|
||||
objects (make-objects shape1 shape2)
|
||||
result (render/calculate-dimensions objects nil)]
|
||||
(t/is (some? result))
|
||||
(t/is (<= (:width result) render/max-export-dimension))
|
||||
(t/is (<= (:height result) render/max-export-dimension)))))
|
||||
|
||||
(t/deftest calculate-dimensions-extreme-width
|
||||
(t/testing "Extreme width should throw export-area-too-large"
|
||||
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 200000 :height 100})
|
||||
objects (make-objects shape)]
|
||||
(t/is (thrown-with-msg?
|
||||
js/Error
|
||||
#"export area exceeds maximum allowed dimensions"
|
||||
(render/calculate-dimensions objects nil))))))
|
||||
|
||||
(t/deftest calculate-dimensions-extreme-height
|
||||
(t/testing "Extreme height should throw export-area-too-large"
|
||||
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 100 :height 200000})
|
||||
objects (make-objects shape)]
|
||||
(t/is (thrown-with-msg?
|
||||
js/Error
|
||||
#"export area exceeds maximum allowed dimensions"
|
||||
(render/calculate-dimensions objects nil))))))
|
||||
|
||||
(t/deftest calculate-dimensions-extreme-position
|
||||
(t/testing "Shape at extreme position should throw export-area-too-large"
|
||||
(let [shape (cts/setup-shape {:type :rect :x 500000 :y 500000 :width 100 :height 100})
|
||||
objects (make-objects shape)]
|
||||
(t/is (thrown-with-msg?
|
||||
js/Error
|
||||
#"export area exceeds maximum allowed dimensions"
|
||||
(render/calculate-dimensions objects nil))))))
|
||||
|
||||
(t/deftest calculate-dimensions-exactly-at-limit
|
||||
(t/testing "Bounding box exactly at limit should pass"
|
||||
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width render/max-export-dimension :height render/max-export-dimension})
|
||||
objects (make-objects shape)
|
||||
result (render/calculate-dimensions objects nil)]
|
||||
(t/is (some? result))
|
||||
(t/is (<= (:width result) render/max-export-dimension))
|
||||
(t/is (<= (:height result) render/max-export-dimension)))))
|
||||
@@ -51,6 +51,7 @@
|
||||
[frontend-tests.plugins.tokens-test]
|
||||
[frontend-tests.plugins.utils-test]
|
||||
[frontend-tests.plugins.value-objects-test]
|
||||
[frontend-tests.render-dimensions-test]
|
||||
[frontend-tests.render-wasm.process-objects-test]
|
||||
[frontend-tests.render-wasm.text-editor-caret-color-test]
|
||||
[frontend-tests.svg-fills-test]
|
||||
@@ -160,6 +161,7 @@
|
||||
'frontend-tests.ui.gradient-handlers-test
|
||||
'frontend-tests.ui.layout-container-multiple-test
|
||||
'frontend-tests.ui.measures-menu-props-test
|
||||
'frontend-tests.render-dimensions-test
|
||||
'frontend-tests.text-editor-paste-guard-test
|
||||
'frontend-tests.ui.settings-password-schema-test
|
||||
'frontend-tests.ui.settings-shortcuts-test
|
||||
|
||||
@@ -1748,6 +1748,34 @@ msgstr "Confirmation password must match"
|
||||
msgid "errors.password-too-short"
|
||||
msgstr "Password should at least be 8 characters"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password"
|
||||
msgstr "Password does not meet the requirements"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.too-short"
|
||||
msgstr "At least 8 characters"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-lowercase"
|
||||
msgstr "At least 1 lowercase letter"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-uppercase"
|
||||
msgstr "At least 1 uppercase letter"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "At least 1 digit"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
msgstr "At least 1 special character"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.in-dictionary"
|
||||
msgstr "Password is too common"
|
||||
|
||||
#: src/app/main/errors.cljs:267
|
||||
msgid "errors.paste-data-validation"
|
||||
msgstr "Invalid data in clipboard"
|
||||
|
||||
@@ -1717,6 +1717,34 @@ msgstr "La contraseña de confirmación debe coincidir"
|
||||
msgid "errors.password-too-short"
|
||||
msgstr "La contraseña debe tener 8 caracteres como mínimo"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password"
|
||||
msgstr "La contraseña no cumple los requisitos"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.too-short"
|
||||
msgstr "Al menos 8 caracteres"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-lowercase"
|
||||
msgstr "Al menos 1 letra minúscula"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-uppercase"
|
||||
msgstr "Al menos 1 letra mayúscula"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "Al menos 1 dígito"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
msgstr "Al menos 1 carácter especial"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.in-dictionary"
|
||||
msgstr "La contraseña es demasiado común"
|
||||
|
||||
#: src/app/main/errors.cljs:267
|
||||
msgid "errors.paste-data-validation"
|
||||
msgstr "Datos inválidos en el portapapeles"
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugi
|
||||
# Module commands
|
||||
declare -A LINT_CMD=(
|
||||
[frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss"
|
||||
[backend]="pnpm run lint"
|
||||
[backend]="pnpm run lint:clj"
|
||||
[common]="pnpm run lint:clj"
|
||||
[render-wasm]="./lint"
|
||||
[exporter]="pnpm run lint"
|
||||
|
||||
Reference in new issue
Block a user