mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e7f2ec2e | ||
|
|
6da24aaf52 | ||
|
|
5f0175a641 | ||
|
|
41afcba297 | ||
|
|
c4b24743ea | ||
|
|
252683eac8 | ||
|
|
66bf6768e0 | ||
|
|
0655c4510a | ||
|
|
0064a1afb4 | ||
|
|
aac8a69951 | ||
|
|
c329defa5e | ||
|
|
7ccc52fbdf | ||
|
|
2a0b75fe09 | ||
|
|
22b6b6df57 | ||
|
|
1ed13d6d5e | ||
|
|
cc68024d43 | ||
|
|
5d4d5d0fac | ||
|
|
a91502f5e7 | ||
|
|
8806f92cd7 | ||
|
|
7807e34151 | ||
|
|
565b7ea878 | ||
|
|
461227fe16 |
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"}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
password
|
||||
123456
|
||||
12345678
|
||||
1234
|
||||
qwerty
|
||||
12345
|
||||
dragon
|
||||
pussy
|
||||
baseball
|
||||
football
|
||||
letmein
|
||||
monkey
|
||||
696969
|
||||
abc123
|
||||
mustang
|
||||
michael
|
||||
shadow
|
||||
master
|
||||
jennifer
|
||||
111111
|
||||
2000
|
||||
jordan
|
||||
superman
|
||||
harley
|
||||
1234567
|
||||
fuckme
|
||||
hunter
|
||||
fuckyou
|
||||
trustno1
|
||||
ranger
|
||||
buster
|
||||
thomas
|
||||
tigger
|
||||
robert
|
||||
soccer
|
||||
fuck
|
||||
batman
|
||||
test
|
||||
pass
|
||||
killer
|
||||
hockey
|
||||
george
|
||||
charlie
|
||||
andrew
|
||||
michelle
|
||||
love
|
||||
sunshine
|
||||
jessica
|
||||
asshole
|
||||
6969
|
||||
pepper
|
||||
daniel
|
||||
access
|
||||
123456789
|
||||
654321
|
||||
joshua
|
||||
maggie
|
||||
starwars
|
||||
silver
|
||||
william
|
||||
dallas
|
||||
yankees
|
||||
123123
|
||||
ashley
|
||||
666666
|
||||
hello
|
||||
amanda
|
||||
orange
|
||||
biteme
|
||||
freedom
|
||||
computer
|
||||
sexy
|
||||
thunder
|
||||
nicole
|
||||
ginger
|
||||
heather
|
||||
hammer
|
||||
summer
|
||||
corvette
|
||||
taylor
|
||||
fucker
|
||||
austin
|
||||
1111
|
||||
merlin
|
||||
matthew
|
||||
121212
|
||||
golfer
|
||||
cheese
|
||||
princess
|
||||
martin
|
||||
chelsea
|
||||
patrick
|
||||
richard
|
||||
diamond
|
||||
yellow
|
||||
bigdog
|
||||
secret
|
||||
asdfgh
|
||||
sparky
|
||||
cowboy
|
||||
camaro
|
||||
anthony
|
||||
matrix
|
||||
falcon
|
||||
iloveyou
|
||||
bailey
|
||||
guitar
|
||||
jackson
|
||||
purple
|
||||
scooter
|
||||
phoenix
|
||||
aaaaaa
|
||||
morgan
|
||||
tigers
|
||||
porsche
|
||||
mickey
|
||||
maverick
|
||||
cookie
|
||||
nascar
|
||||
peanut
|
||||
justin
|
||||
131313
|
||||
money
|
||||
horny
|
||||
samantha
|
||||
panties
|
||||
steelers
|
||||
joseph
|
||||
snoopy
|
||||
boomer
|
||||
whatever
|
||||
iceman
|
||||
smokey
|
||||
gateway
|
||||
dakota
|
||||
cowboys
|
||||
eagles
|
||||
chicken
|
||||
dick
|
||||
black
|
||||
zxcvbn
|
||||
please
|
||||
andrea
|
||||
ferrari
|
||||
knight
|
||||
hardcore
|
||||
compaq
|
||||
coffee
|
||||
booboo
|
||||
bitch
|
||||
bulldog
|
||||
xxxxxx
|
||||
welcome
|
||||
james
|
||||
player
|
||||
ncc1701
|
||||
wizard
|
||||
sbpnb
|
||||
december
|
||||
hello123
|
||||
admin
|
||||
qwerty123
|
||||
1q2w3e4r
|
||||
1q2w3e4r5t
|
||||
password1
|
||||
password123
|
||||
iloveu
|
||||
letmein1
|
||||
abc1234
|
||||
qwertyuiop
|
||||
asdfghjkl
|
||||
zxcvbnm
|
||||
1234567890
|
||||
0987654321
|
||||
11223344
|
||||
admin123
|
||||
root
|
||||
toor
|
||||
pass123
|
||||
test123
|
||||
guest
|
||||
default
|
||||
changeme
|
||||
temp
|
||||
temp123
|
||||
passwd
|
||||
passw0rd
|
||||
p@ssword
|
||||
p@ssw0rd
|
||||
qwerty1
|
||||
abc12345
|
||||
123321
|
||||
12345678910
|
||||
asdf
|
||||
asdfasdf
|
||||
qwer
|
||||
qwerqwer
|
||||
zxcv
|
||||
zxcvzxcv
|
||||
!@#$%^&*
|
||||
!@#$%
|
||||
qazwsx
|
||||
qazwsxedc
|
||||
1qaz2wsx
|
||||
1qazxsw2
|
||||
pass1234
|
||||
test1234
|
||||
admin1234
|
||||
letmein123
|
||||
welcome1
|
||||
welcome123
|
||||
monkey123
|
||||
dragon123
|
||||
master123
|
||||
shadow123
|
||||
sunshine1
|
||||
princess1
|
||||
football1
|
||||
baseball1
|
||||
soccer1
|
||||
hockey1
|
||||
batman1
|
||||
superman1
|
||||
@@ -38,5 +38,11 @@
|
||||
:create-file-snapshot/global
|
||||
{:permits 3}
|
||||
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}}
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:import-binfile/global
|
||||
{:permits 4}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2}}
|
||||
@@ -0,0 +1,62 @@
|
||||
;; 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]
|
||||
[clojure.java.io :as io])
|
||||
(:import
|
||||
[org.passay CharacterCharacteristicsRule CharacterRule DictionaryRule EnglishCharacterData PasswordData]
|
||||
[org.passay.dictionary ArrayWordList WordListDictionary]))
|
||||
|
||||
(defonce ^:private dictionary
|
||||
(let [lines (line-seq (io/reader (io/resource "app/common-passwords.txt")))
|
||||
words (into-array String (sort lines))
|
||||
word-list (ArrayWordList. words)]
|
||||
(WordListDictionary. word-list)))
|
||||
|
||||
(defonce ^:private dictionary-rule
|
||||
(DictionaryRule. dictionary))
|
||||
|
||||
(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
|
||||
- Not in common password dictionary"
|
||||
[password]
|
||||
(when (< (count password) 8)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password must be at least 8 characters"))
|
||||
|
||||
(let [password-data (PasswordData. password)]
|
||||
(let [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 (mapv #(.getErrorCode %) (.getDetails char-result)))))
|
||||
|
||||
(let [dict-result (.validate dictionary-rule password-data)]
|
||||
(when-not (.isValid dict-result)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password is too common"
|
||||
:details (mapv #(.getErrorCode %) (.getDetails dict-result)))))))
|
||||
@@ -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)))))
|
||||
|
||||
@@ -856,6 +856,15 @@
|
||||
:expected-size (:size object)
|
||||
:found-size (sto/get-size content)))
|
||||
|
||||
(when-let [max (::bfc/import-max-object-size cfg)]
|
||||
(when (> (sto/get-size content) max)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "storage object exceeds maximum size: " (sto/get-size content))
|
||||
:path path
|
||||
:max max
|
||||
:found (sto/get-size content))))
|
||||
|
||||
(when-let [hash (get object :hash)]
|
||||
(when (not= hash (sto/get-hash content))
|
||||
(ex/raise :type :validation
|
||||
@@ -938,6 +947,15 @@
|
||||
(let [manifest (-> (read-manifest input)
|
||||
(validate-manifest))
|
||||
entries (read-zip-entries input)
|
||||
|
||||
_ (when-let [max (::bfc/import-max-zip-entries cfg)]
|
||||
(when (> (count entries) max)
|
||||
(ex/raise :type :validation
|
||||
:code :too-many-zip-entries
|
||||
:hint (str "zip file has too many entries: " (count entries))
|
||||
:max max
|
||||
:found (count entries))))
|
||||
|
||||
cfg (-> cfg
|
||||
(assoc ::entries entries)
|
||||
(assoc ::manifest manifest)
|
||||
|
||||
@@ -94,7 +94,11 @@
|
||||
|
||||
;; SSRF protection
|
||||
:ssrf-allowed-hosts #{}
|
||||
:ssrf-extra-blocked-cidrs #{}})
|
||||
:ssrf-extra-blocked-cidrs #{}
|
||||
|
||||
;; Binfile import limits
|
||||
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
|
||||
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
|
||||
|
||||
(def schema:config
|
||||
(do #_sm/optional-keys
|
||||
@@ -147,6 +151,10 @@
|
||||
[:imagemagick-width-limit {:optional true} :string]
|
||||
[:imagemagick-height-limit {:optional true} :string]
|
||||
|
||||
;; Binfile import limits (PENPOT_BINFILE_IMPORT_*)
|
||||
[:binfile-import-max-object-size {:optional true} ::sm/int]
|
||||
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
|
||||
|
||||
[:deletion-delay {:optional true} ::ct/duration]
|
||||
[:file-clean-delay {:optional true} ::ct/duration]
|
||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||
@@ -190,6 +198,7 @@
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
|
||||
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
|
||||
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||
|
||||
@@ -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,18 +43,22 @@
|
||||
|
||||
(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]
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})
|
||||
bucket (-> obj meta :bucket)
|
||||
headers (cond-> {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{::yres/status 307
|
||||
::yres/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||
::yres/headers headers}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
@@ -61,9 +66,12 @@
|
||||
purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
bucket (:bucket mdata)
|
||||
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@@ -109,13 +117,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,6 +7,7 @@
|
||||
(ns app.http.websocket
|
||||
"A penpot notification service for file cooperative edition."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@@ -17,6 +18,8 @@
|
||||
[app.http.session :as session]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.util.websocket :as ws]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec.csp :as sp]
|
||||
@@ -131,8 +134,9 @@
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(let [prev-subs (get @state ::team-subscription)
|
||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
@@ -150,8 +154,10 @@
|
||||
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
||||
(bfc/check-file-exists pool file-id)
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [psub (::file-subscription @state)
|
||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.loggers.mattermost
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@@ -25,7 +26,7 @@
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" href "`\n"))
|
||||
(str "- href: `" (d/escape-markdown href) "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
|
||||
@@ -124,6 +124,47 @@
|
||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||
(xml/parse istream secure-parser-factory))))
|
||||
|
||||
(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}
|
||||
dangerous-attrs-pattern #"(?i)^(on\w+|xmlns:.*)$"
|
||||
javascript-href-pattern #"(?i)^javascript:"]
|
||||
(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 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))
|
||||
svg-text))
|
||||
(catch Exception _
|
||||
svg-text)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE THUMBNAILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -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]
|
||||
@@ -240,6 +241,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 +262,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 +364,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 +375,7 @@
|
||||
(import-profile-picture cfg))
|
||||
|
||||
params {:id id
|
||||
:fullname (:fullname params)
|
||||
:fullname fullname
|
||||
:email email
|
||||
:auth-backend backend
|
||||
:lang locale
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.media :as media-cmd]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
@@ -92,7 +93,9 @@
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name))
|
||||
(assoc ::bfc/name name)
|
||||
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
|
||||
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
|
||||
|
||||
input-path (:path file)
|
||||
owned? (some? upload-id)
|
||||
@@ -118,11 +121,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/schema:upload]
|
||||
[:upload-id {:optional true} ::sm/uuid]]
|
||||
@@ -131,38 +133,31 @@
|
||||
(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}]
|
||||
::sm/params schema:import-binfile
|
||||
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
|
||||
[:import-binfile/global]]}
|
||||
[{: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)]
|
||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
|
||||
(assoc params :file file))
|
||||
params)
|
||||
|
||||
@@ -174,6 +169,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)}})))
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,18 @@
|
||||
|
||||
(declare create-font-variant)
|
||||
|
||||
(defn- check-font-team-ownership!
|
||||
"When font-id already has variants belonging to a different team,
|
||||
raises :not-found to prevent cross-team font injection."
|
||||
[conn team-id font-id]
|
||||
(let [row (db/get* conn :team-font-variant
|
||||
{:font-id font-id}
|
||||
{::db/columns [:team-id]})]
|
||||
(when (and row (not= (:team-id row) team-id))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "font does not belong to this team"))))
|
||||
|
||||
(def ^:private schema:create-font-variant
|
||||
[:and
|
||||
[:map {:title "create-font-variant"}
|
||||
@@ -113,10 +125,10 @@
|
||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||
session-id}` map) into a temp file, validates the media type and
|
||||
size of every entry, and returns a `{mtype → path}` data map."
|
||||
[cfg {:keys [uploads] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id uploads] :as params}]
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg session-id)]
|
||||
(let [assembled (assemble-chunks cfg profile-id session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(media/validate-media-type! cm/font-types)
|
||||
(media/validate-font-size!))
|
||||
@@ -168,8 +180,9 @@
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id uploads] :as params}]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(check-font-team-ownership! pool team-id font-id)
|
||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.OutputStream))
|
||||
@@ -38,6 +39,12 @@
|
||||
|
||||
(declare create-file-media-object)
|
||||
|
||||
(def ^:private sql:get-team-id-for-file
|
||||
"SELECT p.team_id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE f.id = ?")
|
||||
|
||||
(def ^:private schema:upload-file-media-object
|
||||
[:map {:title "upload-file-media-object"}
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
@@ -56,6 +63,12 @@
|
||||
(media/validate-media-type! content)
|
||||
(media/validate-media-size! content)
|
||||
|
||||
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
|
||||
(quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id
|
||||
::quotes/incr (:size content)}))
|
||||
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
;; We get the minimal file for proper checking if
|
||||
;; file is not already deleted
|
||||
@@ -113,13 +126,22 @@
|
||||
|
||||
(defn- process-main-image
|
||||
[info]
|
||||
(let [hash (sto/calculate-hash (:path info))
|
||||
data (-> (sto/content (:path info))
|
||||
(let [path (:path info)
|
||||
mtype (:mtype info)
|
||||
path (if (= mtype "image/svg+xml")
|
||||
(let [content (slurp path)
|
||||
sanitized (media/sanitize-svg content)
|
||||
temp-path (fs/create-tempfile :prefix "penpot-svg-" :suffix ".svg")]
|
||||
(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
|
||||
@@ -391,9 +413,10 @@
|
||||
|
||||
Raises a :validation/:missing-chunks error when the number of stored
|
||||
chunks does not match `:total-chunks` recorded in the session row.
|
||||
Raises :not-found when the session does not belong to `profile-id`.
|
||||
Deletes the session row from `upload_session` on success."
|
||||
[{:keys [::db/conn] :as cfg} session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id})
|
||||
[{:keys [::db/conn] :as cfg} profile-id session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
|
||||
chunks (get-upload-chunks conn session-id)]
|
||||
|
||||
(when (not= (count chunks) (:total-chunks session))
|
||||
@@ -436,7 +459,7 @@
|
||||
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [content (assemble-chunks cfg session-id)
|
||||
(let [content (assemble-chunks cfg profile-id session-id)
|
||||
content (-> content
|
||||
(assoc :filename (str "upload:" name))
|
||||
(assoc :mtype mtype)
|
||||
|
||||
@@ -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]
|
||||
@@ -155,6 +156,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)
|
||||
@@ -200,6 +204,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))
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
(ns app.rpc.management.exporter
|
||||
(:require
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.media :refer [schema:upload]]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.storage :as sto]
|
||||
@@ -21,7 +22,7 @@
|
||||
(def ^:private
|
||||
schema:upload-tempfile-params
|
||||
[:map {:title "upload-templfile-params"}
|
||||
[:content schema:upload]])
|
||||
[:content media/schema:upload]])
|
||||
|
||||
(def ^:private
|
||||
schema:upload-tempfile-result
|
||||
@@ -32,6 +33,7 @@
|
||||
::sm/params schema:upload-tempfile-params
|
||||
::sm/result schema:upload-tempfile-result}
|
||||
[cfg {:keys [::rpc/profile-id content]}]
|
||||
(media/validate-media-type! content cm/tempfile-types)
|
||||
(let [storage (sto/resolve cfg)
|
||||
hash (sto/calculate-hash (:path content))
|
||||
data (-> (sto/content (:path content))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :as cto]
|
||||
@@ -135,6 +136,7 @@
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
(media/validate-media-type! content cm/image-types)
|
||||
(when previous-id
|
||||
(sto/touch-object! storage previous-id))
|
||||
(let [hash (sto/calculate-hash (:path content))
|
||||
|
||||
@@ -546,6 +546,76 @@
|
||||
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:media-storage-bytes-per-team
|
||||
[:map
|
||||
[::profile-id ::sm/uuid]
|
||||
[::team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private valid-media-storage-bytes-per-team-quote?
|
||||
(sm/lazy-validator schema:media-storage-bytes-per-team))
|
||||
|
||||
(def ^:private sql:get-media-storage-bytes-per-team
|
||||
"SELECT COALESCE(SUM(so.size), 0) AS total
|
||||
FROM (
|
||||
SELECT fmo.media_id AS so_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN file AS f ON (f.id = fmo.file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fmo.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT fmo.thumbnail_id AS so_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN file AS f ON (f.id = fmo.file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fmo.thumbnail_id IS NOT NULL
|
||||
AND fmo.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT v.otf_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.otf_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT v.ttf_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.ttf_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT v.woff1_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.woff1_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT v.woff2_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.woff2_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
) AS refs
|
||||
JOIN storage_object AS so ON (so.id = refs.so_id)
|
||||
WHERE so.deleted_at IS NULL")
|
||||
|
||||
(defmethod check-quote ::media-storage-bytes-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
|
||||
(* 20 1024 1024 1024)))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
|
||||
team-id team-id team-id team-id team-id team-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: DEFAULT
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -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.pprint :as pp]
|
||||
@@ -24,7 +25,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)
|
||||
@@ -105,3 +109,54 @@
|
||||
(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))))))))
|
||||
|
||||
(t/deftest import-rejects-too-many-zip-entries
|
||||
;; N1-09: import must reject ZIP files exceeding max-zip-entries
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (prepare-simple-file profile)
|
||||
output (tmp/tempfile :suffix ".zip")]
|
||||
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Import with max-zip-entries=1 — the exported ZIP has more entries
|
||||
(let [cfg (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(assoc ::bfc/import-max-zip-entries 1))
|
||||
out (try
|
||||
(v3/import-files! cfg)
|
||||
:no-error
|
||||
(catch Throwable e
|
||||
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
|
||||
d)))]
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :too-many-zip-entries (: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*)
|
||||
|
||||
@@ -55,6 +55,50 @@
|
||||
(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 (media/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 (media/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 (media/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 (media/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 (media/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 info-invalid-image
|
||||
(t/testing "info on invalid image raises error"
|
||||
(let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
;; 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.climit :as-alias climit]
|
||||
[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")))
|
||||
|
||||
(t/deftest import-binfile-has-concurrency-limit
|
||||
;; N1-10: import-binfile must have a concurrency limit to prevent
|
||||
;; connection pool exhaustion from concurrent imports
|
||||
(let [mdata (meta #'binfile/sm$import-binfile)]
|
||||
(t/is (some? (::climit/id mdata))
|
||||
"import-binfile must have ::climit/id metadata")))
|
||||
@@ -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)
|
||||
@@ -2320,8 +2377,6 @@
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
;; --- Security Fix Tests ---
|
||||
|
||||
(t/deftest link-file-to-library-circular-reference
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
@@ -2391,3 +2446,24 @@
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata))))))
|
||||
|
||||
(t/deftest get-file-libraries-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-file-libraries-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
@@ -922,3 +922,64 @@
|
||||
:name "Valid Font Name"}
|
||||
out (th/command! params)]
|
||||
(t/is (th/success? out))))))
|
||||
|
||||
(t/deftest create-font-variant-rejects-foreign-font-id
|
||||
;; N2-07: A user with edit permissions on their own team must not be
|
||||
;; able to create a font variant using a font-id that already belongs
|
||||
;; to another team (BOLA / CWE-639).
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof1 (th/create-profile* 1 {:is-active true})
|
||||
prof2 (th/create-profile* 2 {:is-active true})
|
||||
team1 (:default-team-id prof1)
|
||||
team2 (:default-team-id prof2)
|
||||
font-id (uuid/custom 10 999)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
|
||||
(io/read*))]
|
||||
|
||||
;; prof1 creates a font variant in team1 with font-id
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof1)
|
||||
:team-id team1
|
||||
:font-id font-id
|
||||
:font-family "SharedFont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; prof2 tries to create a variant using the same font-id but
|
||||
;; in team2 — must be rejected because font-id belongs to team1
|
||||
(let [params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof2)
|
||||
:team-id team2
|
||||
:font-id font-id
|
||||
:font-family "SharedFont"
|
||||
:font-weight 700
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" data}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
|
||||
|
||||
(t/deftest get-font-variants-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-font-variants-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
@@ -57,6 +57,22 @@
|
||||
(t/is (not= (get-in out1 [:result :id])
|
||||
(get-in out2 [:result :id])))))
|
||||
|
||||
(t/deftest upload-tempfile-rejects-html-content-type
|
||||
;; N2-13: upload-tempfile must reject non-allowed content types
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
|
||||
_ (io/write* path "<script>alert(1)</script>")
|
||||
params {::th/type :upload-tempfile
|
||||
::rpc/profile-id (:id profile)
|
||||
:content {:filename "evil.html"
|
||||
:path path
|
||||
:mtype "text/html"
|
||||
:size 27}}
|
||||
out (th/management-command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :media-type-not-allowed (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest duplicate-file
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
|
||||
@@ -548,6 +548,41 @@
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))))
|
||||
|
||||
(t/deftest chunked-upload-other-profile-cannot-assemble
|
||||
;; assemble-chunks must scope the session lookup to the requesting
|
||||
;; profile so that a different profile cannot assemble chunks from
|
||||
;; a session they do not own (BOLA / CWE-639).
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
prof2 (th/create-profile* 2)
|
||||
session-id (create-session! prof1 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}]
|
||||
|
||||
;; prof1 uploads a chunk into their own session
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof1)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; prof2 tries to assemble prof1's session via create-font-variant
|
||||
;; (which calls assemble-chunks without ownership check)
|
||||
(let [out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof2)
|
||||
:team-id (:default-team-id prof2)
|
||||
:font-id (uuid/next)
|
||||
:font-family "TestFont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest chunked-upload-invalid-media-type
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
|
||||
@@ -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))
|
||||
@@ -1202,3 +1202,49 @@
|
||||
(t/is (true? (get-in props [:props :onboarding-viewed])))
|
||||
(t/is (false? (get-in props [:props :newsletter-updates])))
|
||||
(t/is (= :wasm (get-in props [:props :renderer]))))))
|
||||
|
||||
|
||||
(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))))
|
||||
@@ -241,3 +241,24 @@
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))))))
|
||||
|
||||
(t/deftest get-project-nonexistent
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id prof)
|
||||
:id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-project-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
proj (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:default-team-id owner)})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id other)
|
||||
:id (:id proj)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
@@ -338,3 +338,96 @@
|
||||
|
||||
(check-ok! 4)
|
||||
(check-ko! 5))))
|
||||
|
||||
(t/deftest media-storage-bytes-per-team-quote
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-media-storage-bytes-per-team 1000})}]
|
||||
|
||||
(let [profile-1 (th/create-profile* 1)
|
||||
profile-2 (th/create-profile* 2)
|
||||
team-id (:default-team-id profile-1)
|
||||
data {::quotes/id ::quotes/media-storage-bytes-per-team
|
||||
::quotes/profile-id (:id profile-1)
|
||||
::quotes/team-id team-id
|
||||
::quotes/incr 500}
|
||||
|
||||
check-ok! (fn [msg]
|
||||
(quotes/check! th/*system* data)
|
||||
(t/is (true? true) msg))
|
||||
check-ko! (fn [msg]
|
||||
(try
|
||||
(quotes/check! th/*system* data)
|
||||
(t/is false (str msg " — expected exception but none thrown"))
|
||||
(catch Exception e
|
||||
(let [ed (ex-data e)]
|
||||
(t/is (= :restriction (:type ed)))
|
||||
(t/is (= :max-quote-reached (:code ed)))
|
||||
(t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
|
||||
|
||||
;; Under default limit (1000) with incr=500 and no existing storage — ok
|
||||
(check-ok! "first check under limit")
|
||||
|
||||
;; Insert a quote row for another profile on the same team — does not help
|
||||
(th/db-insert! :usage-quote
|
||||
{:profile-id (:id profile-2)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 100})
|
||||
|
||||
;; Insert a team+profile quote that is still too low
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:profile-id (:id profile-2)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 200})
|
||||
|
||||
;; Insert a team-level quote (no profile) that is still too low
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 400})
|
||||
|
||||
;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
|
||||
(check-ko! "blocked by team-level quote")
|
||||
|
||||
;; Insert a team+profile quote that allows it
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:profile-id (:id profile-1)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 1000})
|
||||
|
||||
;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
|
||||
(check-ok! "allowed by team+profile quote"))))
|
||||
|
||||
(t/deftest media-upload-enforces-storage-quote
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-media-storage-bytes-per-team 100})}]
|
||||
|
||||
(let [prof (th/create-profile* 1)
|
||||
proj (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:id proj)
|
||||
:is-shared false})
|
||||
mfile {:filename "sample.jpg"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
|
||||
params {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
|
||||
;; 312043 bytes > 100 byte limit → should be rejected
|
||||
(t/is (not (th/success? out)))
|
||||
(let [error (:error out)]
|
||||
(t/is (= :restriction (th/ex-type error)))
|
||||
(t/is (= :max-quote-reached (th/ex-code error)))
|
||||
(t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))
|
||||
@@ -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)))))))))))
|
||||
@@ -13,11 +13,25 @@
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest validate-url-allows-public-https
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "example.com")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "93.184.216.34")])
|
||||
(original hostname)))]
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))))
|
||||
|
||||
(t/deftest validate-url-allows-public-http
|
||||
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "example.com")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "93.184.216.34")])
|
||||
(original hostname)))]
|
||||
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))))
|
||||
|
||||
(t/deftest validate-url-blocks-disallowed-schemes
|
||||
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
|
||||
|
||||
@@ -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
|
||||
@@ -1183,6 +1192,15 @@
|
||||
str/trim)
|
||||
""))
|
||||
|
||||
(defn escape-markdown
|
||||
"Escapes Markdown special characters by prefixing them with backslash.
|
||||
Intended for user-controlled values embedded in Markdown messages
|
||||
(e.g. Mattermost notifications)."
|
||||
[s]
|
||||
(if s
|
||||
(str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" "\\\\$1")
|
||||
""))
|
||||
|
||||
(defn get-initials
|
||||
"Returns up to two uppercase initials extracted from a string.
|
||||
Non-letter prefixes in each token are ignored."
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"image/gif"
|
||||
"image/svg+xml"})
|
||||
|
||||
(def tempfile-types
|
||||
(conj image-types "application/pdf"))
|
||||
|
||||
(defn format->extension
|
||||
[format]
|
||||
(case format
|
||||
|
||||
@@ -36,6 +36,43 @@
|
||||
(t/is (= "" (d/get-initials nil)))
|
||||
(t/is (= "" (d/get-initials "!!! ???"))))
|
||||
|
||||
(t/deftest escape-markdown-test
|
||||
(t/is (= "hello" (d/escape-markdown "hello")))
|
||||
(t/is (= "" (d/escape-markdown nil)))
|
||||
(t/is (= "" (d/escape-markdown "")))
|
||||
(t/is (= "\\*bold\\*" (d/escape-markdown "*bold*")))
|
||||
(t/is (= "\\_italic\\_" (d/escape-markdown "_italic_")))
|
||||
(t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~")))
|
||||
(t/is (= "\\`code\\`" (d/escape-markdown "`code`")))
|
||||
(t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)")))
|
||||
(t/is (= "\\> quote" (d/escape-markdown "> quote")))
|
||||
(t/is (= "\\# heading" (d/escape-markdown "# heading")))
|
||||
(t/is (= "\\@channel" (d/escape-markdown "@channel")))
|
||||
(t/is (= "\\!bang" (d/escape-markdown "!bang")))
|
||||
(t/is (= "normal\\-text" (d/escape-markdown "normal-text")))
|
||||
(t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c")))
|
||||
(t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated")))
|
||||
(t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces")))
|
||||
(t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash"))))
|
||||
|
||||
(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))))))))
|
||||
@@ -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))))))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user