Compare commits

...
Author SHA1 Message Date
Eva Marco fc55bc2a48 🐛 Fix typography on shortcuts list 2026-08-06 13:41:58 +02:00
Eva Marco eb34e1c118 🐛 Fix delete path node 2026-08-06 13:38:10 +02:00
Andrey AntukhandLuis de Dios 81e44afbe3 🐛 Add backend password validation with complexity rules and dictionary check (#11059)
* 🐛 Add backend password validation with complexity rules and dictionary check

Enforce minimum 8-character password length, require at least 1 lowercase
letter, 1 uppercase letter, 1 digit, and 1 special character, and reject
common passwords using Passay library with a 10k-entry wordlist from
SecLists during registration and password change flows.

AI-assisted-by: mimo-v2.5-pro

*  Improve user feedback

When the password is invalid, the user now gets extra indications to make it stronger, so it can be valid.

* 🐛 Fix remove unneeded common password check

The dictionary check is only relevant for passwords that meet all other requirements, but all 10,000 common passwords would fail the character requirements, so this check is not needed

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-08-06 10:17:14 +02:00
Andrey Antukh 495e9f059e 🐛 Add minor fix on token tests 2026-08-06 09:35:21 +02:00
Andrey Antukh a60b648c6c 🐛 Fix issues with draft-js tests 2026-08-06 08:55:58 +02:00
Andrey Antukh b6656ee8dd 🐛 Enable SSRF check for organization SSO provider (#11064) (#11065)
Remove :skip-ssrf-check? true from prepare-organization-sso-provider so
SSRF protection is active when validating organization SSO configs.

The endpoint is already protected by shared-key authentication
(admin-console), but enabling SSRF protection prevents potential misuse
of internal network resources if the shared key were ever compromised
(defense-in-depth).

Add test prepare-organization-sso-provider-does-not-skip-ssrf-check to
verify the SSRF check is not skipped.

AI-assisted-by: qwen3.7-plus
2026-08-05 21:54:33 +02:00
Andrey Antukh 86aaf642b6 🐛 Fix scripts/ci issue with backend lintig 2026-08-05 21:52:59 +02:00
Andrey Antukh c4dd04353f 🐛 Sanitize SVG files on upload to prevent XSS
Add sanitize-svg function that removes dangerous elements and attributes:
- script tags
- foreignObject elements
- Event handler attributes (onload, onmouseover, etc.)
- javascript: URLs from href/xlink:href attributes

Apply sanitization in process-main-image before storing SVG files.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 21:52:59 +02:00
Andrey Antukh 0ac711aa68 🐛 Normalize string inputs to prevent unfiltered echo (#11061)
Add normalize-string helper in app.common.data that trims whitespace
and returns empty string for nil input. Apply to profile, team, and
project string fields (fullname, lang, theme, name) before storage.

AI-assisted-by: qwen3.7-plus
2026-08-05 17:54:07 +02:00
Andrey Antukh bf62e59f73 🐛 Add cooldown to prevent duplicate invitation emails (#11063) 2026-08-05 17:53:15 +02:00
Andrey Antukh 5906312dff 🐛 Normalize error response on duplicate file ID (#11050)
Capture unique constraint violation in insert-file! and return
generic :not-found error instead of propagating raw PostgreSQL
exception, preventing file existence oracle.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 17:52:14 +02:00
Andrey Antukh 25066c2f46 🐛 Require file read permissions for asset endpoints (#11036)
Add authorization check to generic-handler in assets.clj so that
/assets/by-file-media-id/:id and its /thumbnail variant verify the
requesting profile has read access to the parent file. Return 404
(not 403) when access is denied to avoid confirming existence.

Also switch get-file-media-object from db/get to db/get* so that
non-existent media objects return nil instead of raising.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 17:44:01 +02:00
Andrey Antukh 3d176d5390 🐛 Restrict webhook creation/edit/delete to team members only (#11029)
* 🐛 Restrict webhook edit/delete to team members only

Remove the creator-id fallback from get-webhooks-permissions.
Previously, the webhook creator could always edit/delete their
webhook even after being removed from the team. Now can-edit
comes from team role only — removed users get :not-found.

Webhooks are NOT deleted on member removal; the team owns them
and team admins/owners manage them.

AI-assisted-by: mimo-v2.5-pro

* 🐛 Restrict webhook creation to team editors

Use team role check (check-edition-permissions!) for create-webhook
instead of the custom check that allowed any team member to create
webhooks via creator-id self-match override.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 17:42:49 +02:00
Andrey Antukh 0481408531 🐛 Add recursion depth limit to Fressian reader (#11020)
Bound read depth at 128 levels to prevent StackOverflowError from
crafted deeply-nested payloads. All recursive read handlers go
through read-object!, so a single depth check covers all paths.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 17:41:59 +02:00
Andrey Antukh 689d3a1be2 🐛 Add max-object-size guard to read-obj! in v1 parser (#11018)
Prevent unbounded memory allocation when a crafted binfile specifies
an excessively large object size. Apply the same 100 MiB limit that
read-stream! already enforces.

AI-assisted-by: mimo-v2.5
2026-08-05 17:40:58 +02:00
Andrey Antukh fb07273897 🐛 Validate library belongs to same team in link/unlink/sync handlers (#11016)
Add check-library-team-ownership! helper that verifies both the file
and library share the same team before creating or modifying library
relations. This prevents cross-team library injection where a user
with edit permissions on files in different teams could link them
across team boundaries.

Applied to link-file-to-library, unlink-file-from-library, and
update-file-library-sync-status handlers.

AI-assisted-by: mimo-v2.5
2026-08-05 17:40:04 +02:00
Andrey Antukh 9242556da6 🐛 Close import-binfile schema and remove file-id parameter (#10994)
Add :closed true to schema:import-binfile to reject unknown keys.
Remove file-id from handler destructuring, config binding, and audit
props to prevent specifying a target file on import.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 17:37:27 +02:00
Andrey Antukh 4f7bb94bb1 🐛 Add size limit and rate limiting to send-user-feedback (#10979) (#10990)
Prevent email bombing attacks on the send-user-feedback endpoint by
limiting the error-report field to 1MiB and adding climit rate limits:
by-profile (1 permit, queue 3) and global (4 permits), configured in
climit.edn. Make the schema public so it can be exercised by tests,
and add schema validation tests covering the new size limit.

AI-assisted-by: qwen3.7-plus
2026-08-05 17:35:59 +02:00
Andrey Antukh 49276886f3 🐛 Fix some issues with immutablejs incompatibility 2026-08-05 17:28:58 +02:00
Andrey Antukh a2968defbe 🐛 Add bounding box dimension limit to prevent export DoS (#11042)
Add max-export-dimension constant (100000 units) and validate in
calculate-dimensions. Reject exports when bounding box width, height,
or position exceeds the limit to prevent resource exhaustion in the
Chromium export pool.

AI-assisted-by: mimo-v2.5-pro
2026-08-05 14:18:14 +02:00
Marina López 6628f0a134 🐛 Adjust button icon visibility (#11070) 2026-08-05 11:30:13 +02:00
58 changed files with 1311 additions and 285 deletions

No files matched your search

+1
View File
@@ -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"}
+7 -1
View File
@@ -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}}
+2 -3
View File
@@ -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.
+53
View File
@@ -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?))))))
+11 -3
View File
@@ -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))
+4
View File
@@ -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)))))
+17 -8
View File
@@ -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."
+3 -63
View File
@@ -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
+2 -2
View 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
+130
View 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)})))]))
+11 -2
View File
@@ -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
+5 -16
View File
@@ -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)}})))
+6 -3
View File
@@ -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)
+22
View File
@@ -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
+14 -4
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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})
+6 -3
View File
@@ -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
+5 -7
View File
@@ -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")))))
+28 -1
View File
@@ -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))))))))
+1 -1
View File
@@ -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*)
+82
View File
@@ -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)
+70 -24
View File
@@ -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)})]
+115 -45
View File
@@ -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)))))))))))
+9
View File
@@ -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
+12 -1
View File
@@ -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]
+18
View File
@@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+17 -1
View File
@@ -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))))))))
+2 -1
View File
@@ -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;
+1 -2
View File
@@ -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",
+14 -10
View File
@@ -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
-1
View File
@@ -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
+5 -2
View File
@@ -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
+21 -14
View File
@@ -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 -5
View File
@@ -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))))))
+13 -3
View File
@@ -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])}]
+15 -2
View File
@@ -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))))))
+2 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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)))))
+2
View File
@@ -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
+28
View File
@@ -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"
+28
View File
@@ -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
View File
@@ -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"