mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 19:28:12 +00:00
Compare commits
993 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700f3e9c10 | ||
|
|
debfe5490f | ||
|
|
01d68ec09b | ||
|
|
35f8e1b084 | ||
|
|
0b6416e53b | ||
|
|
d380efdb0c | ||
|
|
7e499c5e5f | ||
|
|
38d67c8e96 | ||
|
|
6c4ab8940d | ||
|
|
9ebd17f31f | ||
|
|
89a1ee7813 | ||
|
|
29ba336928 | ||
|
|
cfb076dd61 | ||
|
|
4a7140d82d | ||
|
|
4061673528 | ||
|
|
e05ea1392a | ||
|
|
58fae0a04d | ||
|
|
078663b0fa | ||
|
|
5a7ba7ee7e | ||
|
|
7532bf411c | ||
|
|
984d292ab2 | ||
|
|
25e6b939ba | ||
|
|
361c1c574b | ||
|
|
841b2e156e | ||
|
|
6c7843f4b6 | ||
|
|
8aacda2249 | ||
|
|
50bee5e176 | ||
|
|
20c6a28b52 | ||
|
|
7135782e7d | ||
|
|
fd38f5b431 | ||
|
|
2d5e50f352 | ||
|
|
e280168de9 | ||
|
|
7c1a29ccf7 | ||
|
|
cd417443f6 | ||
|
|
0c60db56a2 | ||
|
|
a3c330d6e7 | ||
|
|
96722fde4b | ||
|
|
4a549d0907 | ||
|
|
d6b341c053 | ||
|
|
5c9696e20c | ||
|
|
28b33b9acc | ||
|
|
c6b6b9ce00 | ||
|
|
5f7de04efe | ||
|
|
d43d1f431f | ||
|
|
dc8073f924 | ||
|
|
5bbb2c5cff | ||
|
|
9e990a975a | ||
|
|
ba42cc04b7 | ||
|
|
b60695f54a | ||
|
|
3c542a1abc | ||
|
|
3fd976c551 | ||
|
|
7dbd602d1e | ||
|
|
7d4092eeba | ||
|
|
f673b32567 | ||
|
|
d384f47253 | ||
|
|
8ad30e14b6 | ||
|
|
b0b2c0d264 | ||
|
|
f00ea8789f | ||
|
|
112e81c397 | ||
|
|
b6487015b8 | ||
|
|
88008ce16c | ||
|
|
75d99a0725 | ||
|
|
09637f9794 | ||
|
|
3225319e0c | ||
|
|
2579527e64 | ||
|
|
3561b2d1eb | ||
|
|
ba1842792f | ||
|
|
09fca1c820 | ||
|
|
e3981a0cf3 | ||
|
|
c02f0a2bc9 | ||
|
|
6de5370a0b | ||
|
|
448b5d4786 | ||
|
|
47b3667248 | ||
|
|
98e8160875 | ||
|
|
d549be3376 | ||
|
|
b67394199b | ||
|
|
6ea7a64e01 | ||
|
|
534701f04f | ||
|
|
ad974f4047 | ||
|
|
8bf8601d29 | ||
|
|
81faa5a728 | ||
|
|
7751d9a69b | ||
|
|
74d1288003 | ||
|
|
d28c0ea066 | ||
|
|
a94a7221fb | ||
|
|
b8aa243c2b | ||
|
|
dfd992aa49 | ||
|
|
466f27eb7c | ||
|
|
6c19c7c0c4 | ||
|
|
97d234a566 | ||
|
|
11c970a945 | ||
|
|
ca97a28408 | ||
|
|
6723e3bbea | ||
|
|
c259fbdb5b | ||
|
|
f331325941 | ||
|
|
f716995ffd | ||
|
|
e5f9c1e863 | ||
|
|
a395768987 | ||
|
|
8f2c467b82 | ||
|
|
c1688edf66 | ||
|
|
08247aec3f | ||
|
|
95ca68e2b8 | ||
|
|
e9e6796f05 | ||
|
|
0e5d3e2619 | ||
|
|
f0d6e8cb2f | ||
|
|
304a324529 | ||
|
|
14ff56bc89 | ||
|
|
d8340d765a | ||
|
|
2bbd63287f | ||
|
|
6eccffb8bb | ||
|
|
95b2d7b083 | ||
|
|
bb91c06390 | ||
|
|
e1d3106f61 | ||
|
|
aed2f8a8f8 | ||
|
|
77560b9305 | ||
|
|
cd320c0cd6 | ||
|
|
f9f3955503 | ||
|
|
f19c968bc6 | ||
|
|
d5cf7dcf9d | ||
|
|
bd82829cb7 | ||
|
|
66e34950b2 | ||
|
|
2901d00862 | ||
|
|
f18670ed00 | ||
|
|
719f4a5035 | ||
|
|
c636517499 | ||
|
|
04f29a0d72 | ||
|
|
78c48f1953 | ||
|
|
f89f4e0047 | ||
|
|
3da74ed864 | ||
|
|
612855452a | ||
|
|
62ec66b974 | ||
|
|
88ec9e4ff1 | ||
|
|
cd9151bf9f | ||
|
|
7efc4d6d53 | ||
|
|
0b49c1f3e9 | ||
|
|
0d17debde7 | ||
|
|
98c8bb1746 | ||
|
|
e9105f3670 | ||
|
|
eeeb698d91 | ||
|
|
3a39676969 | ||
|
|
c42eb6ff86 | ||
|
|
b5701923ba | ||
|
|
9ba8d4667c | ||
|
|
1d454f3790 | ||
|
|
876b8d645d | ||
|
|
adea81ceee | ||
|
|
bc9496deaa | ||
|
|
88dbfe7602 | ||
|
|
9cf787d154 | ||
|
|
3f0d103cb3 | ||
|
|
003b54421d | ||
|
|
73b55ee47e | ||
|
|
ae66317d6c | ||
|
|
b2c9e08d42 | ||
|
|
42ebee88d6 | ||
|
|
f0c68fb826 | ||
|
|
d772632b08 | ||
|
|
ec773703cc | ||
|
|
97496d8ad7 | ||
|
|
c5a2b592a2 | ||
|
|
a206d57443 | ||
|
|
f1f612f265 | ||
|
|
ea53d24dde | ||
|
|
bfa1ae051f | ||
|
|
b74d920d03 | ||
|
|
fb1f55c13e | ||
|
|
8775e234f3 | ||
|
|
c08c3bd160 | ||
|
|
6fa440cf92 | ||
|
|
974beca12d | ||
|
|
b38912f3cb | ||
|
|
697de53c16 | ||
|
|
32d9688c3c | ||
|
|
47abe09cfe | ||
|
|
b02e05e23d | ||
|
|
7f409eadd4 | ||
|
|
39f4c13493 | ||
|
|
65a0fcb15b | ||
|
|
ac472c615a | ||
|
|
81061013b1 | ||
|
|
b5922d32ca | ||
|
|
b2f173675e | ||
|
|
69e505a6a2 | ||
|
|
390796f36e | ||
|
|
78381873eb | ||
|
|
de27ea904d | ||
|
|
146219a439 | ||
|
|
fa89790fd6 | ||
|
|
71904c9ab6 | ||
|
|
d13e464ed1 | ||
|
|
7e9fac4f35 | ||
|
|
80124657b8 | ||
|
|
cf47d5e53e | ||
|
|
adfe4c3945 | ||
|
|
179bb51c76 | ||
|
|
3d4c914daa | ||
|
|
f5271dabee | ||
|
|
a7e362dbfe | ||
|
|
f8f7a0828e | ||
|
|
e186a27174 | ||
|
|
1477758656 | ||
|
|
41bc8c9b9d | ||
|
|
3829443046 | ||
|
|
b442ca2209 | ||
|
|
4d2d559383 | ||
|
|
e3bafab529 | ||
|
|
3f5226485b | ||
|
|
424b689dca | ||
|
|
77b4d07d1f | ||
|
|
6fd264051a | ||
|
|
c10f945473 | ||
|
|
f5591ed22e | ||
|
|
8f30a95ca0 | ||
|
|
e8547ab6dd | ||
|
|
628ce604c5 | ||
|
|
90d052464f | ||
|
|
fbee875d75 | ||
|
|
bf7c12ae75 | ||
|
|
175f122a0f | ||
|
|
b2f4e90a79 | ||
|
|
b4ec0a6d55 | ||
|
|
9cd1542dd9 | ||
|
|
2e97f01838 | ||
|
|
176edadb6f | ||
|
|
b26ef158ef | ||
|
|
95d4d42c91 | ||
|
|
bba3610b7b | ||
|
|
83da487b24 | ||
|
|
da8e44147c | ||
|
|
69e25a4998 | ||
|
|
eca9b63d68 | ||
|
|
29ea1cc495 | ||
|
|
d73ab3ec92 | ||
|
|
1cc860807e | ||
|
|
92dd5d9954 | ||
|
|
057c6ddc0d | ||
|
|
a2e6abcb72 | ||
|
|
431056404c | ||
|
|
5dec75fe62 | ||
|
|
988c277e37 | ||
|
|
1d8299a919 | ||
|
|
b0caa15516 | ||
|
|
c63b9583a2 | ||
|
|
de577a803c | ||
|
|
a3ea9fbecb | ||
|
|
909427d442 | ||
|
|
dfec9004bf | ||
|
|
6d1d044588 | ||
|
|
1e0f10814e | ||
|
|
db7c646568 | ||
|
|
caac452cd4 | ||
|
|
30931839b5 | ||
|
|
6da39bc9c7 | ||
|
|
2b67e114b6 | ||
|
|
8b08c8ecc9 | ||
|
|
8253738f01 | ||
|
|
c30c85ff07 | ||
|
|
ff41d08e3c | ||
|
|
08ca561667 | ||
|
|
7b0ea5968d | ||
|
|
8cc05d9579 | ||
|
|
f07b954b7e | ||
|
|
6c90ba1582 | ||
|
|
18f0ad246f | ||
|
|
dc5f222230 | ||
|
|
207cb87d5e | ||
|
|
650f725f11 | ||
|
|
39b0e011fc | ||
|
|
7c3a1a905e | ||
|
|
3469e867ff | ||
|
|
b211594ce8 | ||
|
|
62f3454607 | ||
|
|
3264bc746f | ||
|
|
68595e90eb | ||
|
|
6788df02ca | ||
|
|
8b14de2610 | ||
|
|
a81cded0aa | ||
|
|
c39609b991 | ||
|
|
d90e7f8164 | ||
|
|
19b9c696fc | ||
|
|
4703fe6e3b | ||
|
|
b3645658fb | ||
|
|
9106a994f1 | ||
|
|
bc47b992eb | ||
|
|
a3f7a1def6 | ||
|
|
2ccaa3f0c5 | ||
|
|
367262f5a0 | ||
|
|
dfc5a256b4 | ||
|
|
6b3d5d930f | ||
|
|
a52831aa8c | ||
|
|
bbd200f869 | ||
|
|
87179e806f | ||
|
|
e46b34efc7 | ||
|
|
94c6045dd9 | ||
|
|
f656266e5c | ||
|
|
a6c3767e2b | ||
|
|
2d07b9e77c | ||
|
|
0fc2050526 | ||
|
|
47eadab82e | ||
|
|
d85d63ef3c | ||
|
|
83e9f85ccf | ||
|
|
d91ce0f9d1 | ||
|
|
28f65fec91 | ||
|
|
9c44f5bf65 | ||
|
|
443fb60743 | ||
|
|
cbe9d31599 | ||
|
|
599a66979a | ||
|
|
5c761125f3 | ||
|
|
d6045c80a1 | ||
|
|
8d1906f56e | ||
|
|
2eaf117b56 | ||
|
|
e511576f66 | ||
|
|
707cc53ca4 | ||
|
|
a403175d5c | ||
|
|
bb85b312d6 | ||
|
|
78a16d99a9 | ||
|
|
8dccb2a427 | ||
|
|
6d1a2d449a | ||
|
|
e7e5a19db7 | ||
|
|
eb811621a9 | ||
|
|
3312bfe62c | ||
|
|
ef6eeb5693 | ||
|
|
9785a13e67 | ||
|
|
240e8ce50c | ||
|
|
8101f58651 | ||
|
|
9e4c8981be | ||
|
|
a87552bc45 | ||
|
|
a803bde2ff | ||
|
|
5eebc17ce2 | ||
|
|
434e27bbe8 | ||
|
|
e49b7ce14c | ||
|
|
5c67cd0a4b | ||
|
|
d2050d5331 | ||
|
|
5b78de3594 | ||
|
|
666313c2c3 | ||
|
|
290f37425f | ||
|
|
ef39afe9b5 | ||
|
|
d65f3b5396 | ||
|
|
fe2023dde5 | ||
|
|
a88f8f1394 | ||
|
|
b0a99b65e4 | ||
|
|
388775413e | ||
|
|
1c68810521 | ||
|
|
38a5a67b86 | ||
|
|
deb3af23d4 | ||
|
|
da6bd7509b | ||
|
|
c1d815f97c | ||
|
|
21217c5622 | ||
|
|
f8dd64611f | ||
|
|
e51e0c7933 | ||
|
|
62b59991a9 | ||
|
|
5937a8b0fc | ||
|
|
11fbd4cb21 | ||
|
|
dfa45ec8d8 | ||
|
|
27449139ad | ||
|
|
90fcc9f597 | ||
|
|
5a2c09f246 | ||
|
|
8f6133ddac | ||
|
|
6063c1c532 | ||
|
|
ffac8d2861 | ||
|
|
f97df3e8ab | ||
|
|
b63e4a297b | ||
|
|
6a0d131715 | ||
|
|
92de9ed258 | ||
|
|
2eaa2dc807 | ||
|
|
0dfa450cc8 | ||
|
|
cb33fe417e | ||
|
|
c8675c5b7e | ||
|
|
6ce2aadfae | ||
|
|
5502fe8df3 | ||
|
|
10cfd99525 | ||
|
|
e8e7900911 | ||
|
|
f6b8117fe9 | ||
|
|
6d5b97a7e9 | ||
|
|
b8be89f231 | ||
|
|
0b0e193b70 | ||
|
|
d190655e64 | ||
|
|
619bc5833d | ||
|
|
40dfeb169c | ||
|
|
61d319eaac | ||
|
|
0cc5f7c63e | ||
|
|
a27ef26279 | ||
|
|
f8c04949e1 | ||
|
|
e10bd6a8d3 | ||
|
|
52f28a1eee | ||
|
|
9a0ae32488 | ||
|
|
0c08dfb13d | ||
|
|
1e4ff4aa47 | ||
|
|
b99157a246 | ||
|
|
0558bab092 | ||
|
|
48e8c0bc65 | ||
|
|
3c639f41c4 | ||
|
|
a5055af538 | ||
|
|
e99b6ec213 | ||
|
|
67734c5835 | ||
|
|
d5855f355f | ||
|
|
83833896c9 | ||
|
|
11d9c09a2e | ||
|
|
101b2fe9e6 | ||
|
|
12382cfbb9 | ||
|
|
0f389fe3ad | ||
|
|
9aa2abff2e | ||
|
|
4205e283ea | ||
|
|
68760c8e26 | ||
|
|
cbe3a3f33e | ||
|
|
2ca7acfca6 | ||
|
|
d2a3b67053 | ||
|
|
3ff1acfb6a | ||
|
|
81b1b253f1 | ||
|
|
0337607a1b | ||
|
|
f7e1bcf87f | ||
|
|
650762556f | ||
|
|
8fcbfadd49 | ||
|
|
8c1cf3623b | ||
|
|
d3ac824912 | ||
|
|
350cc01b72 | ||
|
|
8289120ea4 | ||
|
|
103af0e31a | ||
|
|
c097c4a6da | ||
|
|
a04dd6cbfd | ||
|
|
0ad5baa5d9 | ||
|
|
d3c77130bc | ||
|
|
c200dc4040 | ||
|
|
04f98d7acd | ||
|
|
ad1e598efe | ||
|
|
2e24f1e2de | ||
|
|
94215447c9 | ||
|
|
6e2dc0c3dc | ||
|
|
084ca401fd | ||
|
|
e6ab57f719 | ||
|
|
667a995e66 | ||
|
|
9d703439bd | ||
|
|
d6dc0fe1a7 | ||
|
|
28cefa9cba | ||
|
|
5f474f9536 | ||
|
|
27313e6add | ||
|
|
8ce860cf0c | ||
|
|
f3cc6d0d72 | ||
|
|
905f4fa5dd | ||
|
|
56b28b5440 | ||
|
|
0122eaa391 | ||
|
|
114639ca1e | ||
|
|
e9d30bf2c1 | ||
|
|
a75e0c3071 | ||
|
|
153277d152 | ||
|
|
784ad8ab75 | ||
|
|
c1044ac522 | ||
|
|
5ed949f2b7 | ||
|
|
7ecfe77338 | ||
|
|
04f6307c69 | ||
|
|
04892dd688 | ||
|
|
ef3143dcb8 | ||
|
|
87bb1b8e74 | ||
|
|
264cd0aaac | ||
|
|
3767ee05bb | ||
|
|
62cc555084 | ||
|
|
e7e98255d9 | ||
|
|
36c23faae0 | ||
|
|
6264c0c217 | ||
|
|
d7e0b0cf9f | ||
|
|
b6524881e0 | ||
|
|
a149f31d56 | ||
|
|
e4cc7d72da | ||
|
|
932305cbd8 | ||
|
|
623608799a | ||
|
|
06aec4b3a3 | ||
|
|
1b68318c6b | ||
|
|
45b25c23ab | ||
|
|
6ca34908d8 | ||
|
|
dff381c4fe | ||
|
|
2f4a655523 | ||
|
|
508c67c930 | ||
|
|
486a08189e | ||
|
|
7f228e58c6 | ||
|
|
943757a36c | ||
|
|
d67c7f1c8e | ||
|
|
8cc6c40b87 | ||
|
|
1ecfbef6fb | ||
|
|
abe328973c | ||
|
|
3be1ae2ac1 | ||
|
|
19b1f508d3 | ||
|
|
8db63c9770 | ||
|
|
9c1f2e9af8 | ||
|
|
0da6b87b5f | ||
|
|
f3b762855b | ||
|
|
4174d6a05b | ||
|
|
51b9023640 | ||
|
|
4b4b99a949 | ||
|
|
6db3c6cf89 | ||
|
|
0dfa62a5b6 | ||
|
|
0ad3ae0620 | ||
|
|
3eaf67a385 | ||
|
|
1a4ca6d04b | ||
|
|
6403c8deee | ||
|
|
85425e2ccd | ||
|
|
1af2521f64 | ||
|
|
945efdb0b4 | ||
|
|
2ba3605f11 | ||
|
|
5fca9457cf | ||
|
|
448d85febb | ||
|
|
5ae4b21046 | ||
|
|
72cfd5d996 | ||
|
|
1641eec672 | ||
|
|
74af101462 | ||
|
|
ab404340f8 | ||
|
|
6fa0c5ceaa | ||
|
|
713ff6190b | ||
|
|
6e03a191a3 | ||
|
|
a7e3d7963a | ||
|
|
cd67dc42c4 | ||
|
|
52a576dc4d | ||
|
|
1740d2e3d1 | ||
|
|
b32a2d32d8 | ||
|
|
85cfb8161a | ||
|
|
a34a668f94 | ||
|
|
811d53be12 | ||
|
|
a60020ea98 | ||
|
|
d2c609f8a4 | ||
|
|
7c5aec4274 | ||
|
|
f01bfb7a26 | ||
|
|
efd6b95ff6 | ||
|
|
3c2430b16c | ||
|
|
a5d908629b | ||
|
|
737e04fe2c | ||
|
|
38bf6c3603 | ||
|
|
28b4c14b95 | ||
|
|
ba8b552df2 | ||
|
|
4e3dc6532a | ||
|
|
a2672a598c | ||
|
|
0a98100536 | ||
|
|
af4548a6ed | ||
|
|
d361a2ca6e | ||
|
|
b5b51e21c2 | ||
|
|
334039668d | ||
|
|
a59bd05c4f | ||
|
|
caa25c70fc | ||
|
|
6268a8aaf1 | ||
|
|
6b609566e1 | ||
|
|
0dfac801a4 | ||
|
|
01284e2a00 | ||
|
|
cc73a768d5 | ||
|
|
3ef100427b | ||
|
|
7461c5304c | ||
|
|
0f19bc02d7 | ||
|
|
53f4c6fede | ||
|
|
edfa437ce7 | ||
|
|
d4bc1d37f2 | ||
|
|
8928e274fc | ||
|
|
cc03f3f884 | ||
|
|
b6e300a6c7 | ||
|
|
44689d3f9c | ||
|
|
ccaeb49354 | ||
|
|
38f2ec1339 | ||
|
|
7b5699b59f | ||
|
|
1f7afcebe3 | ||
|
|
750e8a9d51 | ||
|
|
f88e287357 | ||
|
|
56f1fcdb53 | ||
|
|
d863c7065f | ||
|
|
1539c074b4 | ||
|
|
ca427bcd4e | ||
|
|
8729fed724 | ||
|
|
5d6eb3b3d6 | ||
|
|
3abd63c35a | ||
|
|
c3a0189af2 | ||
|
|
5f722d9183 | ||
|
|
5a73003c7f | ||
|
|
ccd28140bc | ||
|
|
2ceb2c8d95 | ||
|
|
bd37096637 | ||
|
|
0c6736e676 | ||
|
|
937032c790 | ||
|
|
dd6a3c291a | ||
|
|
55d763736f | ||
|
|
c920c092cc | ||
|
|
be437fbfa1 | ||
|
|
51fa5a5773 | ||
|
|
efd3efff00 | ||
|
|
d051a3ba45 | ||
|
|
577f00dd24 | ||
|
|
65ea27cbac | ||
|
|
43be994920 | ||
|
|
1442e4c246 | ||
|
|
852f9ce07f | ||
|
|
ee1c96f3a1 | ||
|
|
ce0553951f | ||
|
|
7afcd46e5c | ||
|
|
84ac86af5b | ||
|
|
7adac6df40 | ||
|
|
57be1428b3 | ||
|
|
13ee27b1ad | ||
|
|
2905905a9f | ||
|
|
405fd49d79 | ||
|
|
ff60503ce6 | ||
|
|
11ed09f431 | ||
|
|
43cdb91063 | ||
|
|
4345cfaec7 | ||
|
|
bfb331d230 | ||
|
|
884cdbbf8d | ||
|
|
72fd637ec2 | ||
|
|
dc56da9662 | ||
|
|
094ef3d6fe | ||
|
|
8406b5e9f8 | ||
|
|
9e4f4d5f7b | ||
|
|
b637f0a917 | ||
|
|
35125dfd79 | ||
|
|
52496243ac | ||
|
|
0c3b5895bf | ||
|
|
c6f3aa4f66 | ||
|
|
62b36f0153 | ||
|
|
e53ff6d20b | ||
|
|
02afd805ca | ||
|
|
9c3fbc59b9 | ||
|
|
dd10be1fb4 | ||
|
|
f068842a6c | ||
|
|
71b32b97f0 | ||
|
|
d8b1bd53f3 | ||
|
|
7a8824b826 | ||
|
|
1126ed37f1 | ||
|
|
0df6b30f79 | ||
|
|
353d8677b0 | ||
|
|
d8f4d38ac2 | ||
|
|
fb5ac5cd8b | ||
|
|
58d959a37e | ||
|
|
ee1dd80b6e | ||
|
|
8ad62c6800 | ||
|
|
f8913c755d | ||
|
|
e8ce2a43f2 | ||
|
|
8e7e6ffc2f | ||
|
|
e870497ae1 | ||
|
|
9e9c28fe3c | ||
|
|
93de83c427 | ||
|
|
3270d65491 | ||
|
|
a1a469449e | ||
|
|
0499cd6162 | ||
|
|
64b5fd7fb9 | ||
|
|
4abaae4f80 | ||
|
|
de04896266 | ||
|
|
d59aa03924 | ||
|
|
a28d47f437 | ||
|
|
2adf79a5eb | ||
|
|
e630be1509 | ||
|
|
5ba53f7296 | ||
|
|
b876417d5b | ||
|
|
81d90be4c9 | ||
|
|
a4ad940177 | ||
|
|
2a09f30199 | ||
|
|
1b91bbe64d | ||
|
|
8e2a52af50 | ||
|
|
4e1b940e04 | ||
|
|
ca72dcdcbb | ||
|
|
46c2d41218 | ||
|
|
72f5ecfe56 | ||
|
|
10359d39df | ||
|
|
66ba097ba2 | ||
|
|
619842152d | ||
|
|
df8194acf5 | ||
|
|
0597eef750 | ||
|
|
d2422e3a21 | ||
|
|
0484d23b12 | ||
|
|
04a3e236fe | ||
|
|
0d2ec687d2 | ||
|
|
5482ee211e | ||
|
|
757fb8e21d | ||
|
|
0f24cf26f6 | ||
|
|
4da332a5e2 | ||
|
|
de03f3883b | ||
|
|
5eecd52743 | ||
|
|
bf872fa766 | ||
|
|
c8b3407acd | ||
|
|
802cec1ee4 | ||
|
|
3c92c98c94 | ||
|
|
6079ef4e22 | ||
|
|
d6cc469027 | ||
|
|
ab4e195cca | ||
|
|
7480be0bda | ||
|
|
b86898eaf9 | ||
|
|
e018253c6b | ||
|
|
1b223359d9 | ||
|
|
0535ef0e39 | ||
|
|
2d5392327e | ||
|
|
0d236110e9 | ||
|
|
997f0c0e40 | ||
|
|
c27449e4f0 | ||
|
|
2276456295 | ||
|
|
a5f09e18a8 | ||
|
|
f796f7ccb9 | ||
|
|
27a934dcfd | ||
|
|
acc383ba31 | ||
|
|
46f50aab16 | ||
|
|
3bf145a749 | ||
|
|
31696de474 | ||
|
|
1b8871df8e | ||
|
|
8cb5c23a29 | ||
|
|
ce04780b6c | ||
|
|
98e989d7f3 | ||
|
|
5e519c6b4b | ||
|
|
f566c1950f | ||
|
|
8f35e451e6 | ||
|
|
d763484554 | ||
|
|
6e19548bac | ||
|
|
4f08580ced | ||
|
|
c4333341b1 | ||
|
|
4c9775e182 | ||
|
|
c7f63c4155 | ||
|
|
328b7739e0 | ||
|
|
a68e06ffe9 | ||
|
|
1ab1d4f6ca | ||
|
|
39dcad8f54 | ||
|
|
fc64dfe9d6 | ||
|
|
c4b4f8c63c | ||
|
|
fa5c853bca | ||
|
|
6d30989a2d | ||
|
|
50ce8c4739 | ||
|
|
cf94b56154 | ||
|
|
08845ad2d4 | ||
|
|
fe9f1d63ad | ||
|
|
3eabbffb0e | ||
|
|
dbe8304f0c | ||
|
|
87488f4a98 | ||
|
|
f6259708ca | ||
|
|
1229c2a5e5 | ||
|
|
4a6e6fce5b | ||
|
|
b8c319aa61 | ||
|
|
2d0058ef3b | ||
|
|
d14e3a9914 | ||
|
|
eebe90b2cd | ||
|
|
9fb6a3ab0e | ||
|
|
207bc795c0 | ||
|
|
4ccbc612cb | ||
|
|
b56885b8be | ||
|
|
a6e0113b25 | ||
|
|
24fc84054d | ||
|
|
e841dc60b7 | ||
|
|
85ffadf8d7 | ||
|
|
bb651e0c4e | ||
|
|
99151fe530 | ||
|
|
ec4f685aac | ||
|
|
c76985abee | ||
|
|
37cf099126 | ||
|
|
5a2e926c6b | ||
|
|
0e0029bd56 | ||
|
|
a079de1305 | ||
|
|
0c778d7278 | ||
|
|
86f2cbf45e | ||
|
|
93896d2263 | ||
|
|
6c7c584c9a | ||
|
|
ac6541d74a | ||
|
|
683468fa97 | ||
|
|
d2c9911eb2 | ||
|
|
ba138de53e | ||
|
|
bf87af1928 | ||
|
|
a928980d62 | ||
|
|
6ca8865e5b | ||
|
|
58d7e1de18 | ||
|
|
5c989d00d0 | ||
|
|
1512d53e7c | ||
|
|
c59df2e52d | ||
|
|
e72e2bf176 | ||
|
|
0d1b8dc1d6 | ||
|
|
70ef763bfe | ||
|
|
ecf525e094 | ||
|
|
3e60de9582 | ||
|
|
af7a9b4589 | ||
|
|
ade0b6b07b | ||
|
|
2de3ead14f | ||
|
|
0708b0f334 | ||
|
|
d8249cc3db | ||
|
|
2ca264496c | ||
|
|
920e66fd24 | ||
|
|
e380886f51 | ||
|
|
b314faa0e9 | ||
|
|
70dd46f8ce | ||
|
|
7248db28c8 | ||
|
|
5a1461a910 | ||
|
|
3141f67cd7 | ||
|
|
4bfd5194f6 | ||
|
|
bd28131357 | ||
|
|
0f34677ba7 | ||
|
|
024f779cab | ||
|
|
70030fa9e3 | ||
|
|
0de482da9d | ||
|
|
8d342e9374 | ||
|
|
5474b1890b | ||
|
|
3e0cef4a3c | ||
|
|
e5f321c8f1 | ||
|
|
657546a993 | ||
|
|
b0ad6d7fdb | ||
|
|
052417cd10 | ||
|
|
d948761090 | ||
|
|
a2c89a816a | ||
|
|
ab20019e81 | ||
|
|
6c20bfbc9b | ||
|
|
05c71f7b75 | ||
|
|
adc3fa41e9 | ||
|
|
bdfa176b2f | ||
|
|
84539dac1f | ||
|
|
0a5de10dff | ||
|
|
b3a6468697 | ||
|
|
40c9466718 | ||
|
|
321b53e936 | ||
|
|
a059284a30 | ||
|
|
2ace44c9e5 | ||
|
|
5102ae2a58 | ||
|
|
208b3329fd | ||
|
|
da372099f7 | ||
|
|
de5276d638 | ||
|
|
0b41a910bf | ||
|
|
ffae6d4281 | ||
|
|
4da9aa844b | ||
|
|
1ce295f5e5 | ||
|
|
c9d9e493e7 | ||
|
|
287b9d4597 | ||
|
|
336095486e | ||
|
|
ccb272784f | ||
|
|
52b4e803ff | ||
|
|
95aa63374c | ||
|
|
1800deddd5 | ||
|
|
eb5b3a3fe5 | ||
|
|
9de591d9d7 | ||
|
|
ab40f3c888 | ||
|
|
9fa027c1df | ||
|
|
cc2c104e16 | ||
|
|
0b8ac2508e | ||
|
|
c35f70edc5 | ||
|
|
c18375c66e | ||
|
|
585a2d7523 | ||
|
|
23e77b5f03 | ||
|
|
7067cc2286 | ||
|
|
0644bd817e | ||
|
|
b587e2e8ec | ||
|
|
d61e57099e | ||
|
|
cfe11a930c | ||
|
|
97d3e31593 | ||
|
|
740e790585 | ||
|
|
8882f18db4 | ||
|
|
a2f8fca6ea | ||
|
|
ed23c55550 | ||
|
|
5b5c868a87 | ||
|
|
35c829a981 | ||
|
|
b5874b365b | ||
|
|
1a3ac6bdf8 | ||
|
|
de5d4f4292 | ||
|
|
2bd7c10e09 | ||
|
|
495371c079 | ||
|
|
75b1c0c1b1 | ||
|
|
0ff5574b12 | ||
|
|
5ea4b03108 | ||
|
|
0fef5b7e5d | ||
|
|
8a1fdd9dd1 | ||
|
|
a080a9e646 | ||
|
|
a728d5a5f2 | ||
|
|
6072234230 | ||
|
|
41f2877801 | ||
|
|
e2576d049a | ||
|
|
4db9c373e6 | ||
|
|
09a9407867 | ||
|
|
7be03e2ea6 | ||
|
|
05521a84d4 | ||
|
|
e30c01db26 | ||
|
|
05165ce014 | ||
|
|
96677713fc | ||
|
|
c27f874e74 | ||
|
|
901aa9bf09 | ||
|
|
0aea699482 | ||
|
|
48d2135cf3 | ||
|
|
d680973c85 | ||
|
|
0d194decbf | ||
|
|
f41eca12f4 | ||
|
|
c08cff68d7 | ||
|
|
a75de11e70 | ||
|
|
701443c3d7 | ||
|
|
baa44119f4 | ||
|
|
7d3e434167 | ||
|
|
0974bca2c0 | ||
|
|
927455926f | ||
|
|
40233e3316 | ||
|
|
7e287bacfd | ||
|
|
e2b5f936f5 | ||
|
|
614c6ed300 | ||
|
|
4975f28a3d | ||
|
|
f5109c7df2 | ||
|
|
12a1cb1d32 | ||
|
|
84ba6f0002 | ||
|
|
a12b59d101 | ||
|
|
e4b69426e9 | ||
|
|
32d4026641 | ||
|
|
4477b2b4a0 | ||
|
|
bcc755b0be | ||
|
|
9e51fa198a | ||
|
|
4e577d37b8 | ||
|
|
40fb4edc4a | ||
|
|
e305ad1fa8 | ||
|
|
d159244ea6 | ||
|
|
f4e79af3cd | ||
|
|
3e758826fe | ||
|
|
2cf66c948d | ||
|
|
27c4ddba10 | ||
|
|
4ee908fc89 | ||
|
|
bdcf448f3f | ||
|
|
c58054d19c | ||
|
|
a7ab506c5c | ||
|
|
16a067c0ae | ||
|
|
c7f644ab2a | ||
|
|
90288e32d5 | ||
|
|
cb5cacbcee | ||
|
|
f43de05d3d | ||
|
|
d019972bca | ||
|
|
7fceb92673 | ||
|
|
337cfc2d3e | ||
|
|
426053ac17 | ||
|
|
a5da7ceb2f | ||
|
|
a7e3e78e0c | ||
|
|
a82cf34d35 | ||
|
|
3f277b7daf | ||
|
|
c2ee31e791 | ||
|
|
21a1320f16 | ||
|
|
0a54d25d5a | ||
|
|
a19860a77b | ||
|
|
360937f613 | ||
|
|
426c8ea714 | ||
|
|
75e8d226d9 | ||
|
|
d42f5db1f0 | ||
|
|
03d0c62de1 | ||
|
|
698852cbeb | ||
|
|
f6d0414449 | ||
|
|
4d05827fa9 | ||
|
|
48fb9fa6ea | ||
|
|
7cf88359fa | ||
|
|
ea4c6c3998 | ||
|
|
5cc5e8771e | ||
|
|
c74cf3fa37 | ||
|
|
f8dd02169c | ||
|
|
ebdae2cf65 | ||
|
|
79d3469f36 | ||
|
|
7c1ddd3d7d | ||
|
|
4965f6d859 | ||
|
|
a3cd90da7f | ||
|
|
942da56e78 | ||
|
|
2b130c7e52 | ||
|
|
c41b9214c5 | ||
|
|
fb80c8f45b | ||
|
|
009dc4485a | ||
|
|
b8f3bee3ac | ||
|
|
b28457860c | ||
|
|
23b268b414 | ||
|
|
32706a1460 | ||
|
|
cd4b9ddd47 | ||
|
|
f0e3f1a319 | ||
|
|
6a49b5df8c | ||
|
|
afb252f42e | ||
|
|
4185a7a6f3 | ||
|
|
141847585e | ||
|
|
0dda7bd9ee | ||
|
|
30106f8524 | ||
|
|
2b34767b2b | ||
|
|
082c8adb1d | ||
|
|
6cfaeb8a44 | ||
|
|
d192cf8893 | ||
|
|
7ef16a2b69 | ||
|
|
e6fde82609 | ||
|
|
ecc633efbe | ||
|
|
dafad0c124 | ||
|
|
71ec51919e | ||
|
|
1cb113dfeb | ||
|
|
b45aec13ab | ||
|
|
19592fadd8 | ||
|
|
11690e7428 | ||
|
|
c32a336c50 | ||
|
|
0b2dfe7297 | ||
|
|
fe6fb0534c | ||
|
|
b87d7e3de0 | ||
|
|
f2d09a6140 | ||
|
|
d09c909788 | ||
|
|
5ae2351e5a | ||
|
|
b5f4ce0a71 | ||
|
|
9fa77cd06c | ||
|
|
8c5ce4d318 | ||
|
|
3c0df27fe0 | ||
|
|
a278d54429 | ||
|
|
a1cc016727 | ||
|
|
3d38aeb089 | ||
|
|
43725a4abe | ||
|
|
a0236e8c7e | ||
|
|
caccf72c7f | ||
|
|
60ecb901b2 | ||
|
|
fbf1240998 | ||
|
|
c55c23c6dd | ||
|
|
7a52550889 | ||
|
|
08fc6fe917 | ||
|
|
926d573d3e | ||
|
|
bac04f8a73 | ||
|
|
b4e815e787 |
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|||||||
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@ -1,6 +1,7 @@
|
|||||||
name: _DEVELOP
|
name: _DEVELOP
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '16 5-20 * * 1-5'
|
- cron: '16 5-20 * * 1-5'
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/build-docker-devenv.yml
vendored
8
.github/workflows/build-docker-devenv.yml
vendored
@ -16,19 +16,19 @@ jobs:
|
|||||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push DevEnv Docker image
|
- name: Build and push DevEnv Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'penpotapp/devenv'
|
DOCKER_IMAGE: 'penpotapp/devenv'
|
||||||
with:
|
with:
|
||||||
|
|||||||
20
.github/workflows/build-docker.yml
vendored
20
.github/workflows/build-docker.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
|||||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
@ -63,10 +63,10 @@ jobs:
|
|||||||
popd
|
popd
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
@ -76,14 +76,14 @@ jobs:
|
|||||||
# images from DockerHub for unregistered users.
|
# images from DockerHub for unregistered users.
|
||||||
# https://docs.docker.com/docker-hub/usage/
|
# https://docs.docker.com/docker-hub/usage/
|
||||||
- name: Login to DockerHub Registry
|
- name: Login to DockerHub Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels)
|
- name: Extract metadata (tags, labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images:
|
images:
|
||||||
frontend
|
frontend
|
||||||
@ -95,7 +95,7 @@ jobs:
|
|||||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||||
|
|
||||||
- name: Build and push Backend Docker image
|
- name: Build and push Backend Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'backend'
|
DOCKER_IMAGE: 'backend'
|
||||||
BUNDLE_PATH: './bundle-backend'
|
BUNDLE_PATH: './bundle-backend'
|
||||||
@ -110,7 +110,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Frontend Docker image
|
- name: Build and push Frontend Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'frontend'
|
DOCKER_IMAGE: 'frontend'
|
||||||
BUNDLE_PATH: './bundle-frontend'
|
BUNDLE_PATH: './bundle-frontend'
|
||||||
@ -125,7 +125,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Exporter Docker image
|
- name: Build and push Exporter Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'exporter'
|
DOCKER_IMAGE: 'exporter'
|
||||||
BUNDLE_PATH: './bundle-exporter'
|
BUNDLE_PATH: './bundle-exporter'
|
||||||
@ -140,7 +140,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Storybook Docker image
|
- name: Build and push Storybook Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'storybook'
|
DOCKER_IMAGE: 'storybook'
|
||||||
BUNDLE_PATH: './bundle-storybook'
|
BUNDLE_PATH: './bundle-storybook'
|
||||||
@ -155,7 +155,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push MCP Docker image
|
- name: Build and push MCP Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'mcp'
|
DOCKER_IMAGE: 'mcp'
|
||||||
BUNDLE_PATH: './bundle-mcp'
|
BUNDLE_PATH: './bundle-mcp'
|
||||||
|
|||||||
22
.github/workflows/build-main-staging.yml
vendored
Normal file
22
.github/workflows/build-main-staging.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: _MAIN-STAGING
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '26 5-20 * * 1-5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-bundle:
|
||||||
|
uses: ./.github/workflows/build-bundle.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "main-staging"
|
||||||
|
build_wasm: "yes"
|
||||||
|
build_storybook: "yes"
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
needs: build-bundle
|
||||||
|
uses: ./.github/workflows/build-docker.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "main-staging"
|
||||||
14
.github/workflows/build-staging-render.yml
vendored
14
.github/workflows/build-staging-render.yml
vendored
@ -1,14 +0,0 @@
|
|||||||
name: _STAGING RENDER
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '36 5-20 * * 1-5'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-bundle:
|
|
||||||
uses: ./.github/workflows/build-bundle.yml
|
|
||||||
secrets: inherit
|
|
||||||
with:
|
|
||||||
gh_ref: "staging-render"
|
|
||||||
build_wasm: "yes"
|
|
||||||
build_storybook: "yes"
|
|
||||||
1
.github/workflows/build-staging.yml
vendored
1
.github/workflows/build-staging.yml
vendored
@ -1,6 +1,7 @@
|
|||||||
name: _STAGING
|
name: _STAGING
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '36 5-20 * * 1-5'
|
- cron: '36 5-20 * * 1-5'
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/build-tag.yml
vendored
1
.github/workflows/build-tag.yml
vendored
@ -1,6 +1,7 @@
|
|||||||
name: _TAG
|
name: _TAG
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
|
|||||||
3
.github/workflows/commit-checker.yml
vendored
3
.github/workflows/commit-checker.yml
vendored
@ -6,12 +6,14 @@ on:
|
|||||||
- edited
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- edited
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@ -20,6 +22,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-commit-message:
|
check-commit-message:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: Check Commit Message
|
name: Check Commit Message
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
4
.github/workflows/plugins-deploy-api-doc.yml
vendored
4
.github/workflows/plugins-deploy-api-doc.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -62,7 +62,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
4
.github/workflows/plugins-deploy-package.yml
vendored
4
.github/workflows/plugins-deploy-package.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
|||||||
runs-on: penpot-runner-01
|
runs-on: penpot-runner-01
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
@ -62,7 +62,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
@ -36,9 +36,9 @@ jobs:
|
|||||||
# [For new plugins]
|
# [For new plugins]
|
||||||
# Add more outputs here
|
# Add more outputs here
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- id: filter
|
- id: filter
|
||||||
uses: dorny/paths-filter@v3
|
uses: dorny/paths-filter@v4
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
colors_to_tokens:
|
colors_to_tokens:
|
||||||
|
|||||||
@ -35,7 +35,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -60,7 +60,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -64,13 +64,14 @@ jobs:
|
|||||||
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
||||||
|
|
||||||
IMAGES=("frontend" "backend" "exporter" "storybook")
|
IMAGES=("frontend" "backend" "exporter" "storybook")
|
||||||
|
SHORT_TAG=${TAG%.*}
|
||||||
|
|
||||||
for image in "${IMAGES[@]}"; do
|
for image in "${IMAGES[@]}"; do
|
||||||
skopeo copy --all \
|
skopeo copy --all \
|
||||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||||
docker://docker.io/penpotapp/$image:$TAG
|
docker://docker.io/penpotapp/$image:$TAG
|
||||||
|
|
||||||
for alias in main latest; do
|
for alias in main latest "$SHORT_TAG"; do
|
||||||
skopeo copy --all \
|
skopeo copy --all \
|
||||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||||
docker://docker.io/penpotapp/$image:$alias
|
docker://docker.io/penpotapp/$image:$alias
|
||||||
@ -93,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
# --- Create GitHub release ---
|
# --- Create GitHub release ---
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
8
.github/workflows/tests-mcp.yml
vendored
8
.github/workflows/tests-mcp.yml
vendored
@ -10,6 +10,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
- 'mcp/**'
|
- 'mcp/**'
|
||||||
@ -24,14 +25,15 @@ on:
|
|||||||
- 'mcp/**'
|
- 'mcp/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test-mcp:
|
||||||
name: "Test"
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
name: "Test MCP"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
working-directory: ./mcp
|
working-directory: ./mcp
|
||||||
|
|||||||
93
.github/workflows/tests.yml
vendored
93
.github/workflows/tests.yml
vendored
@ -9,6 +9,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
@ -20,13 +21,14 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Linter"
|
name: "Linter"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Lint Common
|
- name: Lint Common
|
||||||
working-directory: ./common
|
working-directory: ./common
|
||||||
@ -79,13 +81,14 @@ jobs:
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
|
||||||
test-common:
|
test-common:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Common Tests"
|
name: "Common Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./common
|
working-directory: ./common
|
||||||
@ -93,12 +96,13 @@ jobs:
|
|||||||
./scripts/test
|
./scripts/test
|
||||||
|
|
||||||
test-plugins:
|
test-plugins:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: Plugins Runtime Linter & Tests
|
name: Plugins Runtime Linter & Tests
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
id: setup-node
|
id: setup-node
|
||||||
@ -143,13 +147,14 @@ jobs:
|
|||||||
run: pnpm run build:styles-example
|
run: pnpm run build:styles-example
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@ -164,13 +169,14 @@ jobs:
|
|||||||
./scripts/test-components
|
./scripts/test-components
|
||||||
|
|
||||||
test-render-wasm:
|
test-render-wasm:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Render WASM Tests"
|
name: "Render WASM Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
working-directory: ./render-wasm
|
working-directory: ./render-wasm
|
||||||
@ -188,6 +194,7 @@ jobs:
|
|||||||
./test
|
./test
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Backend Tests"
|
name: "Backend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
@ -213,7 +220,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@ -227,13 +234,14 @@ jobs:
|
|||||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||||
|
|
||||||
test-library:
|
test-library:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Library Tests"
|
name: "Library Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./library
|
working-directory: ./library
|
||||||
@ -241,38 +249,39 @@ jobs:
|
|||||||
./scripts/test
|
./scripts/test
|
||||||
|
|
||||||
build-integration:
|
build-integration:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Build Integration Bundle"
|
name: "Build Integration Bundle"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Build Bundle
|
- name: Build Bundle
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/build 0.0.0
|
./scripts/build
|
||||||
|
|
||||||
- name: Store Bundle Cache
|
- name: Store Bundle Cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
|
|
||||||
|
|
||||||
test-integration-1:
|
test-integration-1:
|
||||||
name: "Integration Tests 1/4"
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
name: "Integration Tests 1/3"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -280,10 +289,10 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="1/4";
|
./scripts/test-e2e --shard="1/3";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-1
|
name: integration-tests-result-1
|
||||||
@ -292,17 +301,18 @@ jobs:
|
|||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
test-integration-2:
|
test-integration-2:
|
||||||
name: "Integration Tests 2/4"
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
name: "Integration Tests 2/3"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -310,10 +320,10 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="2/4";
|
./scripts/test-e2e --shard="2/3";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-2
|
name: integration-tests-result-2
|
||||||
@ -322,17 +332,18 @@ jobs:
|
|||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
test-integration-3:
|
test-integration-3:
|
||||||
name: "Integration Tests 3/4"
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
name: "Integration Tests 3/3"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -340,43 +351,13 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="3/4";
|
./scripts/test-e2e --shard="3/3";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-3
|
name: integration-tests-result-3
|
||||||
path: frontend/test-results/
|
path: frontend/test-results/
|
||||||
overwrite: true
|
overwrite: true
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
test-integration-4:
|
|
||||||
name: "Integration Tests 4/4"
|
|
||||||
runs-on: penpot-runner-02
|
|
||||||
container: penpotapp/devenv:latest
|
|
||||||
needs: build-integration
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Restore Cache
|
|
||||||
uses: actions/cache/restore@v4
|
|
||||||
with:
|
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
|
||||||
path: frontend/resources/public
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
working-directory: ./frontend
|
|
||||||
run: |
|
|
||||||
./scripts/test-e2e --shard="4/4";
|
|
||||||
|
|
||||||
- name: Upload test result
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: integration-tests-result-4
|
|
||||||
path: frontend/test-results/
|
|
||||||
overwrite: true
|
|
||||||
retention-days: 3
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,6 +50,7 @@
|
|||||||
/frontend/.storybook/preview-body.html
|
/frontend/.storybook/preview-body.html
|
||||||
/frontend/.storybook/preview-head.html
|
/frontend/.storybook/preview-head.html
|
||||||
/frontend/playwright-report/
|
/frontend/playwright-report/
|
||||||
|
/frontend/playwright/ui/visual-specs/
|
||||||
/frontend/text-editor/src/wasm/
|
/frontend/text-editor/src/wasm/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
/frontend/npm-debug.log
|
/frontend/npm-debug.log
|
||||||
@ -63,6 +64,7 @@
|
|||||||
/frontend/test-results/
|
/frontend/test-results/
|
||||||
/frontend/.shadow-cljs
|
/frontend/.shadow-cljs
|
||||||
/other/
|
/other/
|
||||||
|
/scripts/
|
||||||
/nexus/
|
/nexus/
|
||||||
/tmp/
|
/tmp/
|
||||||
/vendor/**/target
|
/vendor/**/target
|
||||||
@ -80,3 +82,4 @@
|
|||||||
/**/node_modules
|
/**/node_modules
|
||||||
/**/.yarn/*
|
/**/.yarn/*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
|
/.vscode
|
||||||
|
|||||||
27
.opencode/agents/commiter.md
Normal file
27
.opencode/agents/commiter.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: commiter
|
||||||
|
description: Git commit assistant following CONTRIBUTING.md commit rules
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
Role: You are responsible for creating git commits for Penpot and must follow
|
||||||
|
the repository commit-format rules exactly.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
* Read `CONTRIBUTING.md` before creating any commit and follow the
|
||||||
|
commit guidelines strictly.
|
||||||
|
* Use commit messages in the form `:emoji: <imperative subject>`.
|
||||||
|
* Keep the subject capitalized, concise, 70 characters or fewer, and
|
||||||
|
without a trailing period.
|
||||||
|
* Keep the description (commit body) with maximum line length of 80
|
||||||
|
characters. Use manual line breaks to wrap text before it exceeds
|
||||||
|
this limit.
|
||||||
|
* Separate the subject from the body with a blank line.
|
||||||
|
* Write a clear and concise body when needed.
|
||||||
|
* Use `git commit -s` so the commit includes the required
|
||||||
|
`Signed-off-by` line.
|
||||||
|
* Do not guess or hallucinate git author information (Name or
|
||||||
|
Email). Never include the `--author` flag in git commands unless
|
||||||
|
specifically instructed by the user for a unique case; assume the
|
||||||
|
local environment is already configured.
|
||||||
37
.opencode/agents/engineer.md
Normal file
37
.opencode/agents/engineer.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: engineer
|
||||||
|
description: Senior Full-Stack Software Engineer
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
Role: You are a high-autonomy Senior Full-Stack Software Engineer working on
|
||||||
|
Penpot, an open-source design tool. You have full permission to navigate the
|
||||||
|
codebase, modify files, and execute commands to fulfill your tasks. Your goal is
|
||||||
|
to solve complex technical tasks with high precision while maintaining a strong
|
||||||
|
focus on maintainability and performance.
|
||||||
|
|
||||||
|
Tech stack: Clojure (backend), ClojureScript (frontend/exporter), Rust/WASM
|
||||||
|
(render-wasm), TypeScript (plugins/mcp), SCSS.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
* Read the root `AGENTS.md` to understand the repository and application
|
||||||
|
architecture. Then read the `AGENTS.md` **only** for each affected module.
|
||||||
|
Not all modules have one — verify before reading.
|
||||||
|
* Before writing code, analyze the task in depth and describe your plan. If the
|
||||||
|
task is complex, break it down into atomic steps.
|
||||||
|
* When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||||
|
`.gitignore` by default.
|
||||||
|
* Do **not** touch unrelated modules unless the task explicitly requires it.
|
||||||
|
* Only reference functions, namespaces, or APIs that actually exist in the
|
||||||
|
codebase. Verify their existence before citing them. If unsure, search first.
|
||||||
|
* Be concise and autonomous — avoid unnecessary explanations.
|
||||||
|
* After making changes, run the applicable lint and format checks for the
|
||||||
|
affected module before considering the work done (see module `AGENTS.md` for
|
||||||
|
exact commands).
|
||||||
|
* Make small and logical commits following the commit guideline described in
|
||||||
|
`CONTRIBUTING.md`. Commit only when explicitly asked.
|
||||||
|
- Do not guess or hallucinate git author information (Name or Email). Never include the
|
||||||
|
`--author` flag in git commands unless specifically instructed by the user for a unique
|
||||||
|
case; assume the local environment is already configured. Allow git commit to
|
||||||
|
automatically pull the identity from the local git config `user.name` and `user.email`.
|
||||||
37
.opencode/agents/testing.md
Normal file
37
.opencode/agents/testing.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: testing
|
||||||
|
description: Senior Software Engineer specialized on testing
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
Role: You are a Senior Software Engineer specialized in testing Clojure and
|
||||||
|
ClojureScript codebases. You work on Penpot, an open-source design tool.
|
||||||
|
|
||||||
|
Tech stack: Clojure (backend/JVM), ClojureScript (frontend/Node.js), shared
|
||||||
|
Cljc (common module), Rust (render-wasm).
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
* Read the root `AGENTS.md` to understand the repository and application
|
||||||
|
architecture. Then read the `AGENTS.md` **only** for each affected module. Not all
|
||||||
|
modules have one — verify before reading.
|
||||||
|
* Before writing code, describe your plan. If the task is complex, break it down into
|
||||||
|
atomic steps.
|
||||||
|
* Tests should be exhaustive and include edge cases relevant to Penpot's domain:
|
||||||
|
nil/missing fields, empty collections, invalid UUIDs, boundary geometries, Malli schema
|
||||||
|
violations, concurrent state mutations, and timeouts.
|
||||||
|
* Tests must be deterministic — do not use `setTimeout`, real network calls, or rely on
|
||||||
|
execution order. Use synchronous mocks for asynchronous workflows.
|
||||||
|
* Use `with-redefs` or equivalent mocking utilities to isolate the logic under test. Avoid
|
||||||
|
testing through the UI (DOM); e2e tests cover that.
|
||||||
|
* Only reference functions, namespaces, or test utilities that actually exist in the
|
||||||
|
codebase. Verify their existence before citing them.
|
||||||
|
* After adding or modifying tests, run the applicable lint and format checks for the
|
||||||
|
affected module before considering the work done (see module `AGENTS.md` for exact
|
||||||
|
commands).
|
||||||
|
* Make small and logical commits following the commit guideline described in
|
||||||
|
`CONTRIBUTING.md`. Commit only when explicitly asked.
|
||||||
|
- Do not guess or hallucinate git author information (Name or Email). Never include the
|
||||||
|
`--author` flag in git commands unless specifically instructed by the user for a unique
|
||||||
|
case; assume the local environment is already configured. Allow git commit to
|
||||||
|
automatically pull the identity from the local git config `user.name` and `user.email`.
|
||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"files.exclude": {
|
|
||||||
"**/.clj-kondo": true,
|
|
||||||
"**/.cpcache": true,
|
|
||||||
"**/.lsp": true,
|
|
||||||
"**/.shadow-cljs": true,
|
|
||||||
"**/node_modules": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
AGENTS.md
184
AGENTS.md
@ -1,139 +1,93 @@
|
|||||||
# IA Agent guide for Penpot monorepo
|
# AI Agent Guide
|
||||||
|
|
||||||
This document provides comprehensive context and guidelines for AI
|
This document provides the core context and operating guidelines for AI agents
|
||||||
agents working on this repository.
|
working in this repository.
|
||||||
|
|
||||||
CRITICAL: When you encounter a file reference (e.g.,
|
## Before You Start
|
||||||
@rules/general.md), use your Read tool to load it on a need-to-know
|
|
||||||
basis. They're relevant to the SPECIFIC task at hand.
|
|
||||||
|
|
||||||
|
Before responding to any user request, you must:
|
||||||
|
|
||||||
## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS
|
1. Read this file completely.
|
||||||
|
2. Identify which modules are affected by the task.
|
||||||
|
3. Load the `AGENTS.md` file **only** for each affected module (see the
|
||||||
|
architecture table below). Not all modules have an `AGENTS.md` — verify the
|
||||||
|
file exists before attempting to read it.
|
||||||
|
4. Do **not** load `AGENTS.md` files for unrelated modules.
|
||||||
|
|
||||||
Before responding to ANY user request, you MUST:
|
## Role: Senior Software Engineer
|
||||||
|
|
||||||
1. **READ** the CONTRIBUTING.md file
|
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
|
||||||
2. **READ** this file and has special focus on your ROLE.
|
permission to navigate the codebase, modify files, and execute commands to
|
||||||
|
fulfill your tasks. Your goal is to solve complex technical tasks with high
|
||||||
|
precision while maintaining a strong focus on maintainability and performance.
|
||||||
|
|
||||||
|
### Operational Guidelines
|
||||||
|
|
||||||
## ROLE: SENIOR SOFTWARE ENGINEER
|
1. Before writing code, describe your plan. If the task is complex, break it
|
||||||
|
down into atomic steps.
|
||||||
|
2. Be concise and autonomous.
|
||||||
|
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
||||||
|
4. Commit only when explicitly asked. Follow the commit format rules in
|
||||||
|
`CONTRIBUTING.md`.
|
||||||
|
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||||
|
`.gitignore` by default.
|
||||||
|
|
||||||
You are a high-autonomy Senior Software Engineer. You have full
|
## GitHub Operations
|
||||||
permission to navigate the codebase, modify files, and execute
|
|
||||||
commands to fulfill your tasks. Your goal is to solve complex
|
|
||||||
technical tasks with high precision, focusing on maintainability and
|
|
||||||
performance.
|
|
||||||
|
|
||||||
|
To obtain the list of repository members/collaborators:
|
||||||
|
|
||||||
### OPERATIONAL GUIDELINES
|
```bash
|
||||||
|
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
|
||||||
1. Always begin by analyzing this document and understand the
|
|
||||||
architecture and read the additional context from AGENTS.md of the
|
|
||||||
affected modules.
|
|
||||||
2. Before writing code, describe your plan. If the task is complex,
|
|
||||||
break it down into atomic steps.
|
|
||||||
3. Be concise and autonomous as possible in your task.
|
|
||||||
4. Commit only if it explicitly asked, and use the CONTRIBUTING.md
|
|
||||||
document to understand the commit format guidelines.
|
|
||||||
5. Do not touch unrelated modules if not proceed or not explicitly
|
|
||||||
asked (per example you probably do not need to touch and read
|
|
||||||
docker/ directory unless the task explicitly requires it)
|
|
||||||
6. When searching code, always use `ripgrep` (rg) instead of grep if
|
|
||||||
available, as it respects `.gitignore` by default.
|
|
||||||
|
|
||||||
|
|
||||||
## ARCHITECTURE OVERVIEW
|
|
||||||
|
|
||||||
Penpot is a full-stack design tool composed of several distinct
|
|
||||||
components separated in modules and subdirectories:
|
|
||||||
|
|
||||||
| Component | Language | Role | IA Agent CONTEXT |
|
|
||||||
|-----------|----------|------|----------------
|
|
||||||
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | @frontend/AGENTS.md |
|
|
||||||
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | @backend/AGENTS.md |
|
|
||||||
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | @common/AGENTS.md |
|
|
||||||
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | @exporter/AGENTS.md |
|
|
||||||
| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | @render-wasm/AGENTS.md |
|
|
||||||
| `mcp/` | TypeScript | Model Context Protocol integration | @mcp/AGENTS.md |
|
|
||||||
| `plugins/` | TypeScript | Plugin runtime and example plugins | @plugins/AGENTS.md |
|
|
||||||
|
|
||||||
Several of the mentionend submodules are internall managed with `pnpm` workspaces.
|
|
||||||
|
|
||||||
|
|
||||||
## COMMIT FORMAT
|
|
||||||
|
|
||||||
We have very precise rules on how our git commit messages must be
|
|
||||||
formatted.
|
|
||||||
|
|
||||||
The commit message format is:
|
|
||||||
|
|
||||||
```
|
|
||||||
<type> <subject>
|
|
||||||
|
|
||||||
[body]
|
|
||||||
|
|
||||||
[footer]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Where type is:
|
To obtain the list of open PRs authored by members:
|
||||||
|
|
||||||
- :bug: `:bug:` a commit that fixes a bug
|
```bash
|
||||||
- :sparkles: `:sparkles:` a commit that adds an improvement
|
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
||||||
- :tada: `:tada:` a commit with a new feature
|
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
||||||
- :recycle: `:recycle:` a commit that introduces a refactor
|
($members | split("|")) as $m |
|
||||||
- :lipstick: `:lipstick:` a commit with cosmetic changes
|
.[] | select(.author.login as $a | $m | index($a)) |
|
||||||
- :ambulance: `:ambulance:` a commit that fixes a critical bug
|
"\(.number)\t\(.author.login)\t\(.title)"
|
||||||
- :books: `:books:` a commit that improves or adds documentation
|
'
|
||||||
- :construction: `:construction:` a WIP commit
|
|
||||||
- :boom: `:boom:` a commit with breaking changes
|
|
||||||
- :wrench: `:wrench:` a commit for config updates
|
|
||||||
- :zap: `:zap:` a commit with performance improvements
|
|
||||||
- :whale: `:whale:` a commit for Docker-related stuff
|
|
||||||
- :paperclip: `:paperclip:` a commit with other non-relevant changes
|
|
||||||
- :arrow_up: `:arrow_up:` a commit with dependency updates
|
|
||||||
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
|
|
||||||
- :fire: `:fire:` a commit that removes files or code
|
|
||||||
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
|
|
||||||
translations
|
|
||||||
|
|
||||||
The commit should contain a sign-off at the end of the patch/commit
|
|
||||||
description body. It can be automatically added by adding the `-s`
|
|
||||||
parameter to `git commit`.
|
|
||||||
|
|
||||||
This is an example of what the line should look like:
|
|
||||||
|
|
||||||
```
|
|
||||||
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Please, use your real name (sorry, no pseudonyms or anonymous
|
To obtain the list of open PRs from external contributors (non-members):
|
||||||
contributions are allowed).
|
|
||||||
|
|
||||||
CRITICAL: The commit Signed-off-by is mandatory and should match the commit author.
|
```bash
|
||||||
|
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
||||||
|
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
||||||
|
($members | split("|")) as $m |
|
||||||
|
.[] | select(.author.login as $a | $m | index($a) | not) |
|
||||||
|
"\(.number)\t\(.author.login)\t\(.title)"
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
Each commit should have:
|
## Architecture Overview
|
||||||
|
|
||||||
- A concise subject using the imperative mood.
|
Penpot is an open-source design tool composed of several modules:
|
||||||
- The subject should capitalize the first letter, omit the period
|
|
||||||
at the end, and be no longer than 65 characters.
|
|
||||||
- A blank line between the subject line and the body.
|
|
||||||
- An entry in the CHANGES.md file if applicable, referencing the
|
|
||||||
GitHub or Taiga issue/user story using these same rules.
|
|
||||||
|
|
||||||
Examples of good commit messages:
|
| Directory | Language | Purpose | Has `AGENTS.md` |
|
||||||
|
|-----------|----------|---------|:----------------:|
|
||||||
|
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes |
|
||||||
|
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes |
|
||||||
|
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes |
|
||||||
|
| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes |
|
||||||
|
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No |
|
||||||
|
| `mcp/` | TypeScript | Model Context Protocol integration | No |
|
||||||
|
| `plugins/` | TypeScript | Plugin runtime and example plugins | No |
|
||||||
|
|
||||||
- `:bug: Fix unexpected error on launching modal`
|
Some submodules use `pnpm` workspaces. The root `package.json` and
|
||||||
- `:bug: Set proper error message on generic error`
|
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
|
||||||
- `:sparkles: Enable new modal for profile`
|
|
||||||
- `:zap: Improve performance of dashboard navigation`
|
|
||||||
- `:wrench: Update default backend configuration`
|
|
||||||
- `:books: Add more documentation for authentication process`
|
|
||||||
- `:ambulance: Fix critical bug on user registration process`
|
|
||||||
- `:tada: Add new approach for user registration`
|
|
||||||
|
|
||||||
More info:
|
### Module Dependency Graph
|
||||||
|
|
||||||
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
|
||||||
- https://gist.github.com/rxaviers/7360908
|
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend ──> common
|
||||||
|
backend ──> common
|
||||||
|
exporter ──> common
|
||||||
|
frontend ──> render-wasm (loads compiled WASM)
|
||||||
|
```
|
||||||
|
|
||||||
|
`common` is referenced as a local dependency (`{:local/root "../common"}`) by
|
||||||
|
both `frontend` and `backend`. Changes to `common` can therefore affect multiple
|
||||||
|
modules — test across consumers when modifying shared code.
|
||||||
|
|||||||
215
CHANGES.md
215
CHANGES.md
@ -1,5 +1,214 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## 2.17.0 (Unreleased)
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
|
||||||
|
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)
|
||||||
|
- Add loader feedback while importing and exporting files [Github #9020](https://github.com/penpot/penpot/issues/9020)
|
||||||
|
- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912)
|
||||||
|
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
|
||||||
|
- Import Tokens from linked library (by @dfelinto) [Github #8391](https://github.com/penpot/penpot/pull/8391)
|
||||||
|
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
|
||||||
|
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
|
||||||
|
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
|
||||||
|
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498)
|
||||||
|
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
|
||||||
|
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
|
||||||
|
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
|
||||||
|
- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713)
|
||||||
|
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
|
||||||
|
- Add CSS linter [Taiga #13790](https://tree.taiga.io/project/penpot/us/13790)
|
||||||
|
- Save and restore selection state in undo/redo (by @eureka0928) [Github #6007](https://github.com/penpot/penpot/issues/6007)
|
||||||
|
- Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790)
|
||||||
|
- Add per-group add button for typographies (by @eureka0928) [Github #5275](https://github.com/penpot/penpot/issues/5275)
|
||||||
|
- Add Find & Replace for text content and layer names (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108)
|
||||||
|
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773)
|
||||||
|
- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602)
|
||||||
|
- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438)
|
||||||
|
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572)
|
||||||
|
- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240)
|
||||||
|
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794)
|
||||||
|
- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358)
|
||||||
|
- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647)
|
||||||
|
- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [Github #2910](https://github.com/penpot/penpot/issues/2910)
|
||||||
|
- Add customizable colors for ruler guides (by @Dexterity104) [Github #5199](https://github.com/penpot/penpot/issues/5199)
|
||||||
|
- Persist asset search query and section filter when switching sidebar tabs (by @eureka0928) [Github #2913](https://github.com/penpot/penpot/issues/2913)
|
||||||
|
- Add delete and duplicate buttons to typography dialog (by @eureka0928) [Github #5270](https://github.com/penpot/penpot/issues/5270)
|
||||||
|
- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [Github #2311](https://github.com/penpot/penpot/issues/2311)
|
||||||
|
- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653)
|
||||||
|
- Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027)
|
||||||
|
- Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806)
|
||||||
|
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored (`userinfo` / `token`) in the OIDC callback, causing "incomplete user info" failures during registration [Github #9108](https://github.com/penpot/penpot/issues/9108)
|
||||||
|
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
|
||||||
|
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877)
|
||||||
|
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
|
||||||
|
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
|
||||||
|
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
|
||||||
|
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
|
||||||
|
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
|
||||||
|
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
|
||||||
|
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
|
||||||
|
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
|
||||||
|
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
|
||||||
|
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
|
||||||
|
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
|
||||||
|
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
|
||||||
|
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
|
||||||
|
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
|
||||||
|
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
|
||||||
|
- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577)
|
||||||
|
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
|
||||||
|
- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901)
|
||||||
|
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
|
||||||
|
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
|
||||||
|
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
|
||||||
|
- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977)
|
||||||
|
- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979)
|
||||||
|
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
||||||
|
- Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067)
|
||||||
|
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516)
|
||||||
|
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
|
||||||
|
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137)
|
||||||
|
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
|
||||||
|
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
|
||||||
|
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
|
||||||
|
|
||||||
|
|
||||||
|
## 2.16.0 (Unreleased)
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
|
||||||
|
- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
|
||||||
|
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
|
||||||
|
- Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627)
|
||||||
|
- Fix title on shared button [Taiga #13730](https://tree.taiga.io/project/penpot/issue/13730)
|
||||||
|
- Fix hover on layers [Taiga #13799](https://tree.taiga.io/project/penpot/issue/13799)
|
||||||
|
- Fix highlight after name edition [Taiga #13783](https://tree.taiga.io/project/penpot/issue/13783)
|
||||||
|
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
|
||||||
|
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
|
||||||
|
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
|
||||||
|
- Fix color dropdown option update [Taiga #14035](https://tree.taiga.io/project/penpot/issue/14035)
|
||||||
|
- Fix themes modal height [Taiga #14046](https://tree.taiga.io/project/penpot/issue/14046)
|
||||||
|
|
||||||
|
|
||||||
|
## 2.15.0 (Unreleased)
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
|
||||||
|
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||||
|
|
||||||
|
|
||||||
|
## 2.14.4
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006)
|
||||||
|
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
|
||||||
|
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
|
||||||
|
|
||||||
|
|
||||||
|
## 2.14.3
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870)
|
||||||
|
- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
|
||||||
|
- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882)
|
||||||
|
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
|
||||||
|
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
|
||||||
|
- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938)
|
||||||
|
- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929)
|
||||||
|
- Fix path drawing preview passing shape instead of content to next-node
|
||||||
|
- Fix swapped arguments in CLJS PathData `-nth` with default
|
||||||
|
- Normalize PathData coordinates to safe integer bounds on read
|
||||||
|
- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962)
|
||||||
|
- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963)
|
||||||
|
- Fix "Move to" menu allowing same project as target when multiple files are selected
|
||||||
|
- Fix crash when index query param is duplicated in URL
|
||||||
|
- Fix wrong extremity point in path `calculate-extremities` for line-to segments
|
||||||
|
- Fix reversed args in DTCG shadow composite token conversion
|
||||||
|
- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?`
|
||||||
|
- Fix wrong `mapcat` call in `collect-main-shapes`
|
||||||
|
- Fix stale accumulator in `get-children-in-instance` recursion
|
||||||
|
- Fix typo `:podition` in swap-shapes grid cell
|
||||||
|
- Fix multiple selection on shapes with token applied to stroke color
|
||||||
|
|
||||||
|
|
||||||
|
## 2.14.2
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Add protection for stale JS asset cache to force reload on version mismatch [Github #8638](https://github.com/penpot/penpot/pull/8638)
|
||||||
|
- Normalize newsletter opt-in checkbox across different register flows [Github #8839](https://github.com/penpot/penpot/pull/8839)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix PathData corruption root causes across WASM and CLJS (unsafe transmute and byteOffset handling)
|
||||||
|
- Handle corrupted PathData segments gracefully instead of crashing
|
||||||
|
- Fix swapped move-to/line-to type codes in PathData binary readers
|
||||||
|
- Fix non-integer row/column values in grid cell position inputs [Github #8869](https://github.com/penpot/penpot/pull/8869)
|
||||||
|
- Fix nil path content crash by exposing safe public API [Github #8806](https://github.com/penpot/penpot/pull/8806)
|
||||||
|
- Fix infinite recursion in get-frame-ids for thumbnail extraction [Github #8807](https://github.com/penpot/penpot/pull/8807)
|
||||||
|
- Fix stale-asset detector missing protocol-dispatch errors
|
||||||
|
- Ignore Zone.js toString TypeError in uncaught error handler [Github #8804](https://github.com/penpot/penpot/pull/8804)
|
||||||
|
- Prevent thumbnail frame recursion overflow [Github #8763](https://github.com/penpot/penpot/pull/8763)
|
||||||
|
- Fix vector index out of bounds in viewer zoom-to-fit/fill [Github #8834](https://github.com/penpot/penpot/pull/8834)
|
||||||
|
- Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858)
|
||||||
|
- Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803)
|
||||||
|
|
||||||
|
## 2.14.1
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Add automatic retry with backoff for idempotent RPC requests on network failures [Github #8792](https://github.com/penpot/penpot/pull/8792)
|
||||||
|
- Add scroll and zoom throttling to one state update per animation frame [Github #8812](https://github.com/penpot/penpot/pull/8812)
|
||||||
|
- Improve error handling and exception formatting [Github #8757](https://github.com/penpot/penpot/pull/8757)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix crash in apply-text-modifier with nil selrect or modifier [Github #8762](https://github.com/penpot/penpot/pull/8762)
|
||||||
|
- Fix incorrect attrs references on generate-sync-shape [Github #8776](https://github.com/penpot/penpot/pull/8776)
|
||||||
|
- Fix regression on subpath support [Github #8793](https://github.com/penpot/penpot/pull/8793)
|
||||||
|
- Improve error reporting on request parsing failures [Github #8805](https://github.com/penpot/penpot/pull/8805)
|
||||||
|
- Fix fetch abort errors escaping the unhandled exception handler [Github #8801](https://github.com/penpot/penpot/pull/8801)
|
||||||
|
- Fix nil deref on missing bounds in layout modifier propagation [Github #8735](https://github.com/penpot/penpot/pull/8735)
|
||||||
|
- Fix TypeError when token error map lacks :error/fn key [Github #8767](https://github.com/penpot/penpot/pull/8767)
|
||||||
|
- Fix dissoc error when detaching stroke color from library [Github #8738](https://github.com/penpot/penpot/pull/8738)
|
||||||
|
- Fix crash when pasting image into text editor
|
||||||
|
- Fix null text crash on paste in text editor
|
||||||
|
- Ensure path content is always PathData when saving
|
||||||
|
- Fix error when get-parent-with-data encounters non-Element nodes
|
||||||
|
|
||||||
## 2.14.0
|
## 2.14.0
|
||||||
|
|
||||||
### :boom: Breaking changes & Deprecations
|
### :boom: Breaking changes & Deprecations
|
||||||
@ -15,6 +224,8 @@
|
|||||||
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
|
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
|
||||||
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
|
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
|
||||||
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
|
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
|
||||||
|
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
|
||||||
|
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
@ -32,6 +243,8 @@
|
|||||||
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
|
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
|
||||||
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
|
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
|
||||||
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
|
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
|
||||||
|
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
|
||||||
|
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
|
||||||
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
|
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
|
||||||
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
|
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
|
||||||
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
|
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
|
||||||
@ -66,6 +279,8 @@
|
|||||||
|
|
||||||
### :heart: Community contributions (Thank you!)
|
### :heart: Community contributions (Thank you!)
|
||||||
|
|
||||||
|
- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689)
|
||||||
|
|
||||||
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
|
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
### :sparkles: New features & Enhancements
|
||||||
|
|||||||
295
CONTRIBUTING.md
295
CONTRIBUTING.md
@ -1,211 +1,196 @@
|
|||||||
# Contributing Guide #
|
# Contributing Guide
|
||||||
|
|
||||||
Thank you for your interest in contributing to Penpot. This is a
|
Thank you for your interest in contributing to Penpot. This guide covers
|
||||||
generic guide that details how to contribute to the project in a way that
|
how to propose changes, submit fixes, and follow project conventions.
|
||||||
is efficient for everyone. If you are looking for specific documentation on
|
|
||||||
different parts of the platform, please refer to the `docs/` directory,
|
|
||||||
or the rendered version at the [Help Center](https://help.penpot.app/).
|
|
||||||
|
|
||||||
## Reporting Bugs ##
|
For architecture details, module-specific guidelines, and AI-agent
|
||||||
|
instructions, see [AGENTS.md](AGENTS.md). For final user technical
|
||||||
|
documentation, see the `docs/` directory or the rendered [Help
|
||||||
|
Center](https://help.penpot.app/).
|
||||||
|
|
||||||
We are using [GitHub Issues](https://github.com/penpot/penpot/issues)
|
## Table of Contents
|
||||||
for our public bugs. We keep a close eye on them and try to make it
|
|
||||||
clear when we have an internal fix in progress. Before filing a new
|
|
||||||
task, try to make sure your problem doesn't already exist.
|
|
||||||
|
|
||||||
If you found a bug, please report it, as far as possible, with:
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Pull Requests](#pull-requests)
|
||||||
|
- [Commit Guidelines](#commit-guidelines)
|
||||||
|
- [Formatting and Linting](#formatting-and-linting)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Developer's Certificate of Origin (DCO)](#developers-certificate-of-origin-dco)
|
||||||
|
|
||||||
- a detailed explanation of steps to reproduce the error
|
## Prerequisites
|
||||||
- the browser and browser version used
|
|
||||||
- a dev tools console exception stack trace (if available)
|
|
||||||
|
|
||||||
If you found a bug which you think is better to discuss in private (for
|
- **Language**: Penpot is written primarily in Clojure (backend), ClojureScript
|
||||||
example, security bugs), consider first sending an email to
|
(frontend/exporter), and Rust (render-wasm). Familiarity with the Clojure
|
||||||
`support@penpot.app`.
|
ecosystem is expected for most contributions.
|
||||||
|
- **Issue tracker**: We use [GitHub Issues](https://github.com/penpot/penpot/issues)
|
||||||
|
for public bugs and [Taiga](https://tree.taiga.io/project/penpot/) for
|
||||||
|
internal project management. Changelog entries reference both.
|
||||||
|
|
||||||
**We don't have a formal bug bounty program for security reports; this
|
## Reporting Bugs
|
||||||
is an open source application, and your contribution will be recognized
|
|
||||||
in the changelog.**
|
|
||||||
|
|
||||||
|
Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues).
|
||||||
|
Before filing, search existing issues to avoid duplicates.
|
||||||
|
|
||||||
## Pull Requests ##
|
Include the following when possible:
|
||||||
|
|
||||||
If you want to propose a change or bug fix via a pull request (PR),
|
1. Steps to reproduce the error.
|
||||||
you should first carefully read the section **Developer's Certificate of
|
2. Browser and browser version used.
|
||||||
Origin**. You must also format your code and commits according to the
|
3. DevTools console exception stack trace (if available).
|
||||||
instructions below.
|
|
||||||
|
|
||||||
If you intend to fix a bug, it's fine to submit a pull request right
|
For security bugs or issues better discussed in private, email
|
||||||
away, but we still recommend filing an issue detailing what you're
|
`support@penpot.app` or report them on [Github Security
|
||||||
fixing. This is helpful in case we don't accept that specific fix but
|
Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||||
want to keep track of the issue.
|
|
||||||
|
|
||||||
If you want to implement or start working on a new feature, please
|
> **Note:** We do not have a formal bug bounty program. Security
|
||||||
open a **question*- / **discussion*- issue for it. No PR
|
> contributions are recognized in the changelog.
|
||||||
will be accepted without a prior discussion about the changes,
|
|
||||||
whether it is a new feature, an already planned one, or a quick win.
|
|
||||||
|
|
||||||
If it is your first PR, you can learn how to proceed from
|
## Pull Requests
|
||||||
[this free video
|
|
||||||
series](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github)
|
|
||||||
|
|
||||||
We use the `easy fix` tag to indicate issues that are appropriate for beginners.
|
### Workflow
|
||||||
|
|
||||||
## Commit Guidelines ##
|
1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco)
|
||||||
|
below. All code patches must include a `Signed-off-by` line.
|
||||||
|
2. **Discuss before building** — open a question/discussion issue before
|
||||||
|
starting work on a new feature or significant change. No PR will be
|
||||||
|
accepted without prior discussion, whether it is a new feature, a planned
|
||||||
|
one, or a quick win.
|
||||||
|
3. **Bug fixes** — you may submit a PR directly, but we still recommend
|
||||||
|
filing an issue first so we can track it independently of your fix.
|
||||||
|
4. **Format and lint** — run the checks described in
|
||||||
|
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||||
|
|
||||||
We have very precise rules on how our git commit messages must be formatted.
|
### Good first issues
|
||||||
|
|
||||||
The commit message format is:
|
We use the `easy fix` label to mark issues appropriate for newcomers.
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
Commit messages must follow this format:
|
||||||
|
|
||||||
```
|
```
|
||||||
<type> <subject>
|
:emoji: <subject>
|
||||||
|
|
||||||
[body]
|
[body]
|
||||||
|
|
||||||
[footer]
|
[footer]
|
||||||
```
|
```
|
||||||
|
|
||||||
Where type is:
|
### Commit types
|
||||||
|
|
||||||
- :bug: `:bug:` a commit that fixes a bug
|
| Emoji | Description |
|
||||||
- :sparkles: `:sparkles:` a commit that adds an improvement
|
|-------|-------------|
|
||||||
- :tada: `:tada:` a commit with a new feature
|
| :bug: | Bug fix |
|
||||||
- :recycle: `:recycle:` a commit that introduces a refactor
|
| :sparkles: | Improvement or enhancement |
|
||||||
- :lipstick: `:lipstick:` a commit with cosmetic changes
|
| :tada: | New feature |
|
||||||
- :ambulance: `:ambulance:` a commit that fixes a critical bug
|
| :recycle: | Refactor |
|
||||||
- :books: `:books:` a commit that improves or adds documentation
|
| :lipstick: | Cosmetic changes |
|
||||||
- :construction: `:construction:` a WIP commit
|
| :ambulance: | Critical bug fix |
|
||||||
- :boom: `:boom:` a commit with breaking changes
|
| :books: | Documentation |
|
||||||
- :wrench: `:wrench:` a commit for config updates
|
| :construction: | Work in progress |
|
||||||
- :zap: `:zap:` a commit with performance improvements
|
| :boom: | Breaking change |
|
||||||
- :whale: `:whale:` a commit for Docker-related stuff
|
| :wrench: | Configuration update |
|
||||||
- :paperclip: `:paperclip:` a commit with other non-relevant changes
|
| :zap: | Performance improvement |
|
||||||
- :arrow_up: `:arrow_up:` a commit with dependency updates
|
| :whale: | Docker-related change |
|
||||||
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
|
| :paperclip: | Other non-relevant changes |
|
||||||
- :fire: `:fire:` a commit that removes files or code
|
| :arrow_up: | Dependency update |
|
||||||
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
|
| :arrow_down: | Dependency downgrade |
|
||||||
translations
|
| :fire: | Removal of code or files |
|
||||||
|
| :globe_with_meridians: | Add or update translations |
|
||||||
|
| :rocket: | Epic or highlight |
|
||||||
|
|
||||||
More info:
|
### Rules
|
||||||
|
|
||||||
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
- Use the **imperative mood** in the subject (e.g. "Fix", not "Fixed")
|
||||||
- https://gist.github.com/rxaviers/7360908
|
- Capitalize the first letter of the subject
|
||||||
|
- Add clear and concise description on the body
|
||||||
|
- Do not end the subject with a period
|
||||||
|
- Keep the subject to **70 characters** or fewer
|
||||||
|
- Separate the subject from the body with a **blank line**
|
||||||
|
|
||||||
Each commit should have:
|
### Examples
|
||||||
|
|
||||||
- A concise subject using the imperative mood.
|
```
|
||||||
- The subject should capitalize the first letter, omit the period
|
:bug: Fix unexpected error on launching modal
|
||||||
at the end, and be no longer than 65 characters.
|
:sparkles: Enable new modal for profile
|
||||||
- A blank line between the subject line and the body.
|
:zap: Improve performance of dashboard navigation
|
||||||
- An entry in the CHANGES.md file if applicable, referencing the
|
:ambulance: Fix critical bug on user registration process
|
||||||
GitHub or Taiga issue/user story using these same rules.
|
:tada: Add new approach for user registration
|
||||||
|
```
|
||||||
|
|
||||||
Examples of good commit messages:
|
## Formatting and Linting
|
||||||
|
|
||||||
- `:bug: Fix unexpected error on launching modal`
|
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
||||||
- `:bug: Set proper error message on generic error`
|
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
|
||||||
- `:sparkles: Enable new modal for profile`
|
|
||||||
- `:zap: Improve performance of dashboard navigation`
|
|
||||||
- `:wrench: Update default backend configuration`
|
|
||||||
- `:books: Add more documentation for authentication process`
|
|
||||||
- `:ambulance: Fix critical bug on user registration process`
|
|
||||||
- `:tada: Add new approach for user registration`
|
|
||||||
|
|
||||||
## Formatting and Linting ##
|
|
||||||
|
|
||||||
You will want to make sure your code is formatted and linted before submitting
|
|
||||||
a PR. We use [cljfmt](https://github.com/weavejester/cljfmt) and
|
|
||||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for this. After installing
|
|
||||||
them on your system, you can run them with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check formatting
|
# Check formatting (does not modify files)
|
||||||
|
./scripts/check-fmt
|
||||||
|
|
||||||
|
# Fix formatting (modifies files in place)
|
||||||
./scripts/fmt
|
./scripts/fmt
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
./scripts/lint
|
./scripts/lint
|
||||||
```
|
```
|
||||||
|
|
||||||
Ideally, you should run these commands as git pre-commit hooks. A convenient way
|
Ideally, run these as git pre-commit hooks.
|
||||||
of defining them is to use [Husky](https://typicode.github.io/husky/#/).
|
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||||
|
setting this up.
|
||||||
|
|
||||||
## Code of Conduct ##
|
## Changelog
|
||||||
|
|
||||||
As contributors and maintainers of this project, we pledge to respect
|
When your change is user-facing or otherwise notable, add an entry to
|
||||||
all people who contribute through reporting issues, posting feature
|
[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference
|
||||||
requests, updating documentation, submitting pull requests or patches,
|
the relevant GitHub issue or Taiga user story.
|
||||||
and other activities.
|
|
||||||
|
|
||||||
We are committed to making participation in this project a
|
## Code of Conduct
|
||||||
harassment-free experience for everyone, regardless of level of
|
|
||||||
experience, gender, gender identity and expression, sexual
|
|
||||||
orientation, disability, personal appearance, body size, race,
|
|
||||||
ethnicity, age, or religion.
|
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include the use of
|
This project follows the [Contributor Covenant](https://www.contributor-covenant.org/).
|
||||||
sexual language or imagery, derogatory comments or personal attacks,
|
The full Code of Conduct is available at
|
||||||
trolling, public or private harassment, insults, or other
|
[help.penpot.app/contributing-guide/coc](https://help.penpot.app/contributing-guide/coc/)
|
||||||
unprofessional conduct.
|
and in the repository's [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
Project maintainers have the right and responsibility to remove, edit,
|
To report unacceptable behavior, open an issue or contact a project maintainer
|
||||||
or reject comments, commits, code, wiki edits, issues, and other
|
directly.
|
||||||
contributions that are not aligned with this Code of Conduct. Project
|
|
||||||
maintainers who do not follow the Code of Conduct may be removed from
|
|
||||||
the project team.
|
|
||||||
|
|
||||||
This Code of Conduct applies both within project spaces and in public
|
|
||||||
spaces when an individual is representing the project or its
|
|
||||||
community.
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
|
||||||
may be reported by opening an issue or contacting one or more of the
|
|
||||||
project maintainers.
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the Contributor Covenant, version
|
|
||||||
1.1.0, available from [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/)
|
|
||||||
|
|
||||||
## Developer's Certificate of Origin (DCO)
|
## Developer's Certificate of Origin (DCO)
|
||||||
|
|
||||||
By submitting code you agree to and can certify the following:
|
By submitting code you agree to and can certify the following:
|
||||||
|
|
||||||
Developer's Certificate of Origin 1.1
|
> **Developer's Certificate of Origin 1.1**
|
||||||
|
>
|
||||||
|
> By making a contribution to this project, I certify that:
|
||||||
|
>
|
||||||
|
> (a) The contribution was created in whole or in part by me and I have the
|
||||||
|
> right to submit it under the open source license indicated in the file; or
|
||||||
|
>
|
||||||
|
> (b) The contribution is based upon previous work that, to the best of my
|
||||||
|
> knowledge, is covered under an appropriate open source license and I have
|
||||||
|
> the right under that license to submit that work with modifications,
|
||||||
|
> whether created in whole or in part by me, under the same open source
|
||||||
|
> license (unless I am permitted to submit under a different license), as
|
||||||
|
> indicated in the file; or
|
||||||
|
>
|
||||||
|
> (c) The contribution was provided directly to me by some other person who
|
||||||
|
> certified (a), (b) or (c) and I have not modified it.
|
||||||
|
>
|
||||||
|
> (d) I understand and agree that this project and the contribution are public
|
||||||
|
> and that a record of the contribution (including all personal information
|
||||||
|
> I submit with it, including my sign-off) is maintained indefinitely and
|
||||||
|
> may be redistributed consistent with this project or the open source
|
||||||
|
> license(s) involved.
|
||||||
|
|
||||||
By making a contribution to this project, I certify that:
|
### Signed-off-by
|
||||||
|
|
||||||
(a) The contribution was created in whole or in part by me and I
|
All code patches (**documentation is excluded**) must contain a sign-off line
|
||||||
have the right to submit it under the open source license
|
at the end of the commit body. Add it automatically with `git commit -s`.
|
||||||
indicated in the file; or
|
|
||||||
|
|
||||||
(b) The contribution is based upon previous work that, to the best
|
|
||||||
of my knowledge, is covered under an appropriate open source
|
|
||||||
license and I have the right under that license to submit that
|
|
||||||
work with modifications, whether created in whole or in part
|
|
||||||
by me, under the same open source license (unless I am
|
|
||||||
permitted to submit under a different license), as indicated
|
|
||||||
in the file; or
|
|
||||||
|
|
||||||
(c) The contribution was provided directly to me by some other
|
|
||||||
person who certified (a), (b) or (c) and I have not modified
|
|
||||||
it.
|
|
||||||
|
|
||||||
(d) I understand and agree that this project and the contribution
|
|
||||||
are public and that a record of the contribution (including all
|
|
||||||
personal information I submit with it, including my sign-off) is
|
|
||||||
maintained indefinitely and may be redistributed consistent with
|
|
||||||
this project or the open source license(s) involved.
|
|
||||||
|
|
||||||
Then, all your code patches (**documentation is excluded**) should
|
|
||||||
contain a sign-off at the end of the patch/commit description body. It
|
|
||||||
can be automatically added by adding the `-s` parameter to `git commit`.
|
|
||||||
|
|
||||||
This is an example of what the line should look like:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
|
Signed-off-by: Your Real Name <your.email@example.com>
|
||||||
```
|
```
|
||||||
|
|
||||||
Please, use your real name (sorry, no pseudonyms or anonymous
|
- Use your **real name** — pseudonyms and anonymous contributions are not
|
||||||
contributions are allowed).
|
allowed.
|
||||||
|
- The `Signed-off-by` line is **mandatory** and must match the commit author.
|
||||||
The commit Signed-off-by is mandatory and should match the commit author.
|
|
||||||
|
|
||||||
|
|||||||
93
README.md
93
README.md
@ -9,45 +9,39 @@
|
|||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
|
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
|
||||||
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
|
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
|
||||||
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
|
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
|
||||||
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
|
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://penpot.app/"><b>Website</b></a> •
|
<a href="https://penpot.app/"><b>Website</b></a> •
|
||||||
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a> •
|
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a> •
|
||||||
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a> •
|
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a> •
|
||||||
<a href="https://community.penpot.app/"><b>Community</b></a>
|
<a href="https://community.penpot.app/"><b>Community</b></a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a> •
|
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a> •
|
||||||
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a> •
|
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a> •
|
||||||
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a> •
|
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a> •
|
||||||
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a> •
|
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a> •
|
||||||
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a> •
|
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a> •
|
||||||
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a> •
|
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a> •
|
||||||
<a href="https://twitter.com/penpotapp"><b>X</b></a>
|
<a href="https://twitter.com/penpotapp"><b>X</b></a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332)
|
||||||
|
|
||||||
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332
|
|
||||||
)
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama.
|
Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama.
|
||||||
|
|
||||||
Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and it’s free!
|
Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and it’s free!
|
||||||
|
|
||||||
The latest updates take Penpot even further. It’s the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development.
|
The latest updates take Penpot even further. It’s the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development.
|
||||||
With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more.
|
|
||||||
For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us)
|
|
||||||
|
|
||||||
🎇 Design, code, and Open Source meet at [Penpot Fest](https://penpot.app/penpotfest)! Be part of the 2025 edition in Madrid, Spain, on October 9-10.
|
With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more.
|
||||||
|
|
||||||
|
For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us).
|
||||||
|
|
||||||
## Table of contents ##
|
## Table of contents ##
|
||||||
|
|
||||||
@ -63,43 +57,42 @@ For organizations that need extra service for its teams, [get in touch](https://
|
|||||||
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
|
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
|
||||||
|
|
||||||
### Plugin system ###
|
### Plugin system ###
|
||||||
|
|
||||||
[Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions.
|
[Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions.
|
||||||
|
|
||||||
### Designed for developers ###
|
### Designed for developers ###
|
||||||
|
|
||||||
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
|
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
|
||||||
|
|
||||||
### Inspect mode ###
|
### Inspect mode ###
|
||||||
|
|
||||||
Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code.
|
Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code.
|
||||||
|
|
||||||
### Self host your own instance ###
|
### Self host your own instance ###
|
||||||
|
|
||||||
Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server.
|
Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server.
|
||||||
|
|
||||||
### Integrations ###
|
### Integrations ###
|
||||||
|
|
||||||
Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
|
Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
|
||||||
|
|
||||||
### Building Design Systems: design tokens, components and variants ###
|
### Building Design Systems: design tokens, components and variants ###
|
||||||
|
|
||||||
Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
|
Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
|
||||||
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Getting started ##
|
## Getting started ##
|
||||||
|
|
||||||
Penpot is the only design & prototype platform that is deployment agnostic. You can use it in our [SAAS](https://design.penpot.app) or deploy it anywhere.
|
Penpot is the only design & prototype platform that is deployment agnostic. You can use it in our [SAAS](https://design.penpot.app) or deploy it anywhere.
|
||||||
|
|
||||||
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
|
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
|
||||||
<br />
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://site-assets.plasmic.app/2168cf524dd543caeff32384eb9ea0a1.svg" alt="Open Source" style="width: 65%;">
|
<img src="https://github.com/user-attachments/assets/93578500-2dbd-4045-a180-e640ea5b3bd5" style="width: 65%;">
|
||||||
</p>
|
</p>
|
||||||
<br />
|
|
||||||
|
|
||||||
## Community ##
|
## Community ##
|
||||||
|
|
||||||
@ -108,6 +101,7 @@ We love the Open Source software community. Contributing is our passion and if i
|
|||||||
If you need help or have any questions; if you’d like to share your experience using Penpot or get inspired; if you’d rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
|
If you need help or have any questions; if you’d like to share your experience using Penpot or get inspired; if you’d rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
|
||||||
|
|
||||||
You will find the following categories:
|
You will find the following categories:
|
||||||
|
|
||||||
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
|
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
|
||||||
- [Troubleshooting](https://community.penpot.app/c/technical/8)
|
- [Troubleshooting](https://community.penpot.app/c/technical/8)
|
||||||
- [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7)
|
- [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7)
|
||||||
@ -117,45 +111,36 @@ You will find the following categories:
|
|||||||
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
|
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
|
||||||
- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22)
|
- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22)
|
||||||
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://github.com/penpot/penpot/assets/5446186/6ac62220-a16c-46c9-ab21-d24ae357ed03" alt="Community" style="width: 65%;">
|
<img src="https://github.com/user-attachments/assets/7b7d0f6b-a579-4822-a9ae-d3d5a9fc9d19" alt="Community" style="width: 65%;">
|
||||||
</p>
|
</p>
|
||||||
<br />
|
|
||||||
|
|
||||||
### Code of Conduct ###
|
### Code of Conduct ###
|
||||||
|
|
||||||
Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the
|
Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the
|
||||||
[code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment.
|
[code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment.
|
||||||
|
|
||||||
|
|
||||||
## Contributing ##
|
## Contributing ##
|
||||||
|
|
||||||
Any contribution will make a difference to improve Penpot. How can you get involved?
|
Any contribution will make a difference to improve Penpot. How can you get involved?
|
||||||
|
|
||||||
Choose your way:
|
Choose your way:
|
||||||
|
|
||||||
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community
|
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community.
|
||||||
- Invite your [team to join](https://design.penpot.app/#/auth/register)
|
- Invite your [team to join](https://design.penpot.app/#/auth/register).
|
||||||
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app)
|
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app).
|
||||||
- Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others’ articles; opening your own conversations and following along on decisions affecting the project.
|
- Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others’ articles; opening your own conversations and following along on decisions affecting the project.
|
||||||
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues)
|
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues).
|
||||||
- Become a [translator](https://help.penpot.app/contributing-guide/translations)
|
- Become a [translator](https://help.penpot.app/contributing-guide/translations).
|
||||||
- Give feedback: [Email us](mailto:support@penpot.app)
|
- Give feedback: [Email us](mailto:support@penpot.app).
|
||||||
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpot’s repository and make changes in both front and back end
|
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpot’s repository and make changes in both front and back end.
|
||||||
|
|
||||||
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/).
|
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/).
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
|
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Resources ##
|
## Resources ##
|
||||||
|
|
||||||
You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.
|
You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.
|
||||||
@ -170,14 +155,14 @@ You can ask and answer questions, have open-ended conversations, and follow alon
|
|||||||
|
|
||||||
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
|
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
|
||||||
|
|
||||||
|
|
||||||
## License ##
|
## License ##
|
||||||
|
|
||||||
```
|
```text
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
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
|
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/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
Copyright (c) KALEIDOS INC
|
Copyright (c) KALEIDOS INC
|
||||||
```
|
```
|
||||||
|
|
||||||
Penpot is a Kaleidos’ [open source project](https://kaleidos.net/)
|
Penpot is a Kaleidos’ [open source project](https://kaleidos.net/)
|
||||||
|
|||||||
@ -7,8 +7,8 @@ Redis for messaging/caching.
|
|||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
This is a golden rule for backend development standards. To ensure consistency
|
To ensure consistency across the Penpot JVM stack, all contributions must adhere
|
||||||
across the Penpot JVM stack, all contributions must adhere to these criteria:
|
to these criteria:
|
||||||
|
|
||||||
### 1. Testing & Validation
|
### 1. Testing & Validation
|
||||||
|
|
||||||
@ -16,14 +16,14 @@ across the Penpot JVM stack, all contributions must adhere to these criteria:
|
|||||||
tests in `test/backend_tests/` must be added or updated.
|
tests in `test/backend_tests/` must be added or updated.
|
||||||
|
|
||||||
* **Execution:**
|
* **Execution:**
|
||||||
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific task.
|
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
|
||||||
* **Regression:** Run `clojure -M:dev:test` for ensure the suite passes without regressions in related functional areas.
|
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
|
||||||
|
|
||||||
### 2. Code Quality & Formatting
|
### 2. Code Quality & Formatting
|
||||||
|
|
||||||
* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`)
|
* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`)
|
||||||
* **Formatting:** All the code must pass the formatting check (run `pnpm run
|
* **Formatting:** All the code must pass the formatting check (run `pnpm run
|
||||||
check-fmt`). Use the `pnpm run fmt` fix the formatting issues. Avoid "dirty"
|
check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty"
|
||||||
diffs caused by unrelated whitespace changes.
|
diffs caused by unrelated whitespace changes.
|
||||||
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
|
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
|
||||||
performance-critical paths to avoid reflection overhead.
|
performance-critical paths to avoid reflection overhead.
|
||||||
@ -40,18 +40,18 @@ namespaces structure:
|
|||||||
- `app.db.*` – Database layer
|
- `app.db.*` – Database layer
|
||||||
- `app.tasks.*` – Background job tasks
|
- `app.tasks.*` – Background job tasks
|
||||||
- `app.main` – Integrant system setup and entrypoint
|
- `app.main` – Integrant system setup and entrypoint
|
||||||
- `app.loggers` – Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`)
|
- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`)
|
||||||
|
|
||||||
### RPC
|
### RPC
|
||||||
|
|
||||||
The PRC methods are implement in a some kind of multimethod structure using
|
The RPC methods are implemented using a multimethod-like structure via the
|
||||||
`app.util.serivices` namespace. The main RPC methods are collected under
|
`app.util.services` namespace. The main RPC methods are collected under
|
||||||
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
|
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
|
||||||
|
|
||||||
The RPC method accepts POST and GET requests indistinctly and uses `Accept`
|
The RPC method accepts POST and GET requests indistinctly and uses the `Accept`
|
||||||
header for negotiate the response encoding (which can be transit, the defaut or
|
header to negotiate the response encoding (which can be Transit — the default —
|
||||||
plain json). It also accepts transit (defaut) or json as input, which should be
|
or plain JSON). It also accepts Transit (default) or JSON as input, which should
|
||||||
indicated using `Content-Type` header.
|
be indicated using the `Content-Type` header.
|
||||||
|
|
||||||
The main convention is: use `get-` prefix on RPC name when we want READ
|
The main convention is: use `get-` prefix on RPC name when we want READ
|
||||||
operation.
|
operation.
|
||||||
@ -83,7 +83,52 @@ are config maps with `::ig/ref` for dependencies. Components implement
|
|||||||
`ig/init-key` / `ig/halt-key!`.
|
`ig/init-key` / `ig/halt-key!`.
|
||||||
|
|
||||||
|
|
||||||
### Database Access
|
### Connecting to the Database
|
||||||
|
|
||||||
|
Two PostgreSQL databases are used in this environment:
|
||||||
|
|
||||||
|
| Database | Purpose | Connection string |
|
||||||
|
|---------------|--------------------|----------------------------------------------------|
|
||||||
|
| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` |
|
||||||
|
| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` |
|
||||||
|
|
||||||
|
**Interactive psql session:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development DB
|
||||||
|
psql "postgresql://penpot:penpot@postgres/penpot"
|
||||||
|
|
||||||
|
# test DB
|
||||||
|
psql "postgresql://penpot:penpot@postgres/penpot_test"
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-shot query (non-interactive):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Useful psql meta-commands:**
|
||||||
|
|
||||||
|
```
|
||||||
|
\dt -- list all tables
|
||||||
|
\d <table> -- describe a table (columns, types, constraints)
|
||||||
|
\di -- list indexes
|
||||||
|
\q -- quit
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Migrations table:** Applied migrations are tracked in the `migrations` table
|
||||||
|
> with columns `module`, `step`, and `created_at`. When renaming a migration
|
||||||
|
> logical name, update this table in both databases to match the new name;
|
||||||
|
> otherwise the runner will attempt to re-apply the migration on next startup.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: fix a renamed migration entry in the test DB
|
||||||
|
psql "postgresql://penpot:penpot@postgres/penpot_test" \
|
||||||
|
-c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access (Clojure)
|
||||||
|
|
||||||
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
||||||
|
|
||||||
@ -107,7 +152,7 @@ are config maps with `::ig/ref` for dependencies. Components implement
|
|||||||
(db/insert! conn :table row)))
|
(db/insert! conn :table row)))
|
||||||
```
|
```
|
||||||
|
|
||||||
Almost all methods on `app.db` namespace accepts `pool`, `conn` or
|
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
|
||||||
`cfg` as params.
|
`cfg` as params.
|
||||||
|
|
||||||
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
||||||
@ -116,7 +161,7 @@ Migrations live in `src/app/migrations/` as numbered SQL files. They run automat
|
|||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
The exception helpers are defined on Common module, and are available under
|
The exception helpers are defined on Common module, and are available under
|
||||||
`app.commin.exceptions` namespace.
|
`app.common.exceptions` namespace.
|
||||||
|
|
||||||
Example of raising an exception:
|
Example of raising an exception:
|
||||||
|
|
||||||
@ -132,10 +177,11 @@ Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:inte
|
|||||||
|
|
||||||
### Performance Macros (`app.common.data.macros`)
|
### Performance Macros (`app.common.data.macros`)
|
||||||
|
|
||||||
Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript:
|
Always prefer these macros over their `clojure.core` equivalents — they provide
|
||||||
|
optimized implementations:
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
|
(dm/select-keys m [:a :b]) ;; faster than core/select-keys
|
||||||
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
||||||
(dm/str "a" "b" "c") ;; string concatenation
|
(dm/str "a" "b" "c") ;; string concatenation
|
||||||
```
|
```
|
||||||
@ -145,3 +191,69 @@ Always prefer these macros over their `clojure.core` equivalents — they compil
|
|||||||
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
|
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
|
||||||
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
|
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
|
||||||
:enable-smtp)`.
|
:enable-smtp)`.
|
||||||
|
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
|
||||||
|
Background tasks live in `src/app/tasks/`. Each task is an Integrant component
|
||||||
|
that exposes a `::handler` key and follows this three-method pattern:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defmethod ig/assert-key ::handler ;; validate config at startup
|
||||||
|
[_ params]
|
||||||
|
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||||
|
|
||||||
|
(defmethod ig/expand-key ::handler ;; inject defaults before init
|
||||||
|
[k v]
|
||||||
|
{k (assoc v ::my-option default-value)})
|
||||||
|
|
||||||
|
(defmethod ig/init-key ::handler ;; return the task fn
|
||||||
|
[_ cfg]
|
||||||
|
(fn [_task] ;; receives the task row from the worker
|
||||||
|
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
;; … do work …
|
||||||
|
))))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wiring a new task** requires two changes in `src/app/main.clj`:
|
||||||
|
|
||||||
|
1. **Handler config** – add an entry in `system-config` with the dependencies:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:app.tasks.my-task/handler
|
||||||
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Registry + cron** – register the handler name and schedule it:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; in ::wrk/registry ::wrk/tasks map:
|
||||||
|
:my-task (ig/ref :app.tasks.my-task/handler)
|
||||||
|
|
||||||
|
;; in worker-config ::wrk/cron ::wrk/entries vector:
|
||||||
|
{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight
|
||||||
|
:task :my-task}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow):
|
||||||
|
|
||||||
|
| Expression | Meaning |
|
||||||
|
|------------------------------|--------------------|
|
||||||
|
| `"0 0 0 * * ?"` | Daily at midnight |
|
||||||
|
| `"0 0 */6 * * ?"` | Every 6 hours |
|
||||||
|
| `"0 */5 * * * ?"` | Every 5 minutes |
|
||||||
|
|
||||||
|
**Time helpers** (`app.common.time`):
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(ct/now) ;; current instant
|
||||||
|
(ct/duration {:hours 1}) ;; java.time.Duration
|
||||||
|
(ct/minus (ct/now) some-duration) ;; subtract duration from instant
|
||||||
|
```
|
||||||
|
|
||||||
|
`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL
|
||||||
|
interval object suitable for use in SQL queries:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds"
|
||||||
|
```
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"author": "Kaleidos INC",
|
"author": "Kaleidos INC",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
|
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/penpot/penpot"
|
"url": "https://github.com/penpot/penpot"
|
||||||
|
|||||||
264
backend/resources/app/email/invite-to-org/en.html
Normal file
264
backend/resources/app/email/invite-to-org/en.html
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<b>{{invited-by|abbreviate:25}}</b> sent you an invitation to join the organization:
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
|
||||||
|
<tr>
|
||||||
|
<td width="20" height="20" align="center" valign="middle"
|
||||||
|
background="{{org-logo}}"
|
||||||
|
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
|
||||||
|
{{org-initials}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
||||||
|
“{{ organization-name|abbreviate:25 }}”
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
||||||
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
|
target="_blank"> ACCEPT INVITE </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Enjoy!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
1
backend/resources/app/email/invite-to-org/en.subj
Normal file
1
backend/resources/app/email/invite-to-org/en.subj
Normal file
@ -0,0 +1 @@
|
|||||||
|
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”
|
||||||
10
backend/resources/app/email/invite-to-org/en.txt
Normal file
10
backend/resources/app/email/invite-to-org/en.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Hello!
|
||||||
|
|
||||||
|
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”.
|
||||||
|
|
||||||
|
Accept invitation using this link:
|
||||||
|
|
||||||
|
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
||||||
|
|
||||||
|
Enjoy!
|
||||||
|
The Penpot team.
|
||||||
@ -186,7 +186,8 @@
|
|||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
|
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
|
||||||
|
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
Hello!
|
Hello!
|
||||||
|
|
||||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.
|
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
|
||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||||
|
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
||||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||||
|
|
||||||
# DEPRECATED: only used for subscriptions
|
# DEPRECATED: only used for subscriptions
|
||||||
@ -44,6 +45,10 @@ export PENPOT_FLAGS="\
|
|||||||
enable-redis-cache \
|
enable-redis-cache \
|
||||||
enable-subscriptions";
|
enable-subscriptions";
|
||||||
|
|
||||||
|
# Uncomment for nexus integration testing
|
||||||
|
# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive";
|
||||||
|
# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit";
|
||||||
|
|
||||||
# Default deletion delay for devenv
|
# Default deletion delay for devenv
|
||||||
export PENPOT_DELETION_DELAY="24h"
|
export PENPOT_DELETION_DELAY="24h"
|
||||||
|
|
||||||
|
|||||||
@ -401,8 +401,9 @@
|
|||||||
|
|
||||||
(defn- parse-attr-path
|
(defn- parse-attr-path
|
||||||
[provider path]
|
[provider path]
|
||||||
(let [[fitem & items] (str/split path "__")]
|
(let [separator (if (str/includes? path "__") "__" ".")
|
||||||
(into [(keyword (:type provider) fitem)] (map keyword) items)))
|
[fitem & items] (str/split path separator)]
|
||||||
|
(into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items)))
|
||||||
|
|
||||||
(defn- build-redirect-uri
|
(defn- build-redirect-uri
|
||||||
[]
|
[]
|
||||||
@ -423,7 +424,7 @@
|
|||||||
|
|
||||||
(defn- qualify-prop-key
|
(defn- qualify-prop-key
|
||||||
[provider k]
|
[provider k]
|
||||||
(keyword (:type provider) (name k)))
|
(keyword (:type provider) (-> k name str/kebab)))
|
||||||
|
|
||||||
(defn- qualify-props
|
(defn- qualify-props
|
||||||
[provider props]
|
[provider props]
|
||||||
@ -488,9 +489,9 @@
|
|||||||
(let [attr-ph (parse-attr-path provider "nickname")]
|
(let [attr-ph (parse-attr-path provider "nickname")]
|
||||||
(get-in props attr-ph))))]
|
(get-in props attr-ph))))]
|
||||||
|
|
||||||
(let [info (assoc info :provider-id (str (:id provider)))
|
(let [info (assoc info :provider-id (str (:id provider)))
|
||||||
props (qualify-props provider info)
|
props (qualify-props provider info)
|
||||||
email (get-email props)]
|
email (get-email props)]
|
||||||
{:backend (:type provider)
|
{:backend (:type provider)
|
||||||
:fullname (or (get-name props) email)
|
:fullname (or (get-name props) email)
|
||||||
:email email
|
:email email
|
||||||
@ -547,16 +548,29 @@
|
|||||||
(def ^:private valid-info?
|
(def ^:private valid-info?
|
||||||
(sm/validator schema:info))
|
(sm/validator schema:info))
|
||||||
|
|
||||||
|
(defn- select-user-info-source
|
||||||
|
"Normalise the provider's configured user-info source into a keyword the
|
||||||
|
dispatch below can match. The raw value comes from config as a string
|
||||||
|
per the malli schema in `app.config` (`\"token\"`, `\"userinfo\"`, or
|
||||||
|
`\"auto\"`) and from hard-coded per-provider maps as strings as well;
|
||||||
|
any unrecognised or missing value falls back to `:auto` (prefer claims,
|
||||||
|
use userinfo as fallback)."
|
||||||
|
[source]
|
||||||
|
(case source
|
||||||
|
"token" :token
|
||||||
|
"userinfo" :userinfo
|
||||||
|
:auto))
|
||||||
|
|
||||||
(defn- get-info
|
(defn- get-info
|
||||||
[cfg provider state code]
|
[cfg provider state code]
|
||||||
(let [tdata (fetch-access-token cfg provider code)
|
(let [tdata (fetch-access-token cfg provider code)
|
||||||
claims (get-id-token-claims provider tdata)
|
claims (get-id-token-claims provider tdata)
|
||||||
|
|
||||||
info (case (get provider :user-info-source)
|
info (case (select-user-info-source (get provider :user-info-source))
|
||||||
:token (dissoc claims :exp :iss :iat :aud :sub :sid)
|
:token (dissoc claims :exp :iss :iat :aud :sid)
|
||||||
:userinfo (fetch-user-info cfg provider tdata)
|
:userinfo (fetch-user-info cfg provider tdata)
|
||||||
(or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid))
|
:auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid))
|
||||||
(fetch-user-info cfg provider tdata)))
|
(fetch-user-info cfg provider tdata)))
|
||||||
|
|
||||||
info (process-user-info provider tdata info)]
|
info (process-user-info provider tdata info)]
|
||||||
|
|
||||||
|
|||||||
@ -40,8 +40,8 @@
|
|||||||
[promesa.util :as pu]
|
[promesa.util :as pu]
|
||||||
[yetti.adapter :as yt])
|
[yetti.adapter :as yt])
|
||||||
(:import
|
(:import
|
||||||
com.github.luben.zstd.ZstdIOException
|
|
||||||
com.github.luben.zstd.ZstdInputStream
|
com.github.luben.zstd.ZstdInputStream
|
||||||
|
com.github.luben.zstd.ZstdIOException
|
||||||
com.github.luben.zstd.ZstdOutputStream
|
com.github.luben.zstd.ZstdOutputStream
|
||||||
java.io.DataInputStream
|
java.io.DataInputStream
|
||||||
java.io.DataOutputStream
|
java.io.DataOutputStream
|
||||||
|
|||||||
@ -82,7 +82,10 @@
|
|||||||
:initial-project-skey "initial-project"
|
:initial-project-skey "initial-project"
|
||||||
|
|
||||||
;; time to avoid email sending after profile modification
|
;; time to avoid email sending after profile modification
|
||||||
:email-verify-threshold "15m"})
|
:email-verify-threshold "15m"
|
||||||
|
|
||||||
|
:quotes-upload-sessions-per-profile 5
|
||||||
|
:quotes-upload-chunks-per-session 20})
|
||||||
|
|
||||||
(def schema:config
|
(def schema:config
|
||||||
(do #_sm/optional-keys
|
(do #_sm/optional-keys
|
||||||
@ -103,6 +106,7 @@
|
|||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:nitrate-shared-key {:optional true} :string]
|
||||||
|
[:nexus-shared-key {:optional true} :string]
|
||||||
[:management-api-key {:optional true} :string]
|
[:management-api-key {:optional true} :string]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
@ -153,6 +157,8 @@
|
|||||||
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
|
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
[: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]
|
||||||
|
|
||||||
[:auth-token-cookie-name {:optional true} :string]
|
[:auth-token-cookie-name {:optional true} :string]
|
||||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||||
@ -326,7 +332,7 @@
|
|||||||
|
|
||||||
(defn logging-context
|
(defn logging-context
|
||||||
[]
|
[]
|
||||||
{:version/backend (:full version)})
|
{:backend/version (:full version)})
|
||||||
|
|
||||||
;; Set value for all new threads bindings.
|
;; Set value for all new threads bindings.
|
||||||
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
||||||
|
|||||||
@ -36,11 +36,11 @@
|
|||||||
java.sql.Connection
|
java.sql.Connection
|
||||||
java.sql.PreparedStatement
|
java.sql.PreparedStatement
|
||||||
java.sql.Savepoint
|
java.sql.Savepoint
|
||||||
org.postgresql.PGConnection
|
|
||||||
org.postgresql.geometric.PGpoint
|
org.postgresql.geometric.PGpoint
|
||||||
org.postgresql.jdbc.PgArray
|
org.postgresql.jdbc.PgArray
|
||||||
org.postgresql.largeobject.LargeObject
|
org.postgresql.largeobject.LargeObject
|
||||||
org.postgresql.largeobject.LargeObjectManager
|
org.postgresql.largeobject.LargeObjectManager
|
||||||
|
org.postgresql.PGConnection
|
||||||
org.postgresql.util.PGInterval
|
org.postgresql.util.PGInterval
|
||||||
org.postgresql.util.PGobject))
|
org.postgresql.util.PGobject))
|
||||||
|
|
||||||
|
|||||||
@ -22,13 +22,13 @@
|
|||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig])
|
[integrant.core :as ig])
|
||||||
(:import
|
(:import
|
||||||
jakarta.mail.Message$RecipientType
|
|
||||||
jakarta.mail.Session
|
|
||||||
jakarta.mail.Transport
|
|
||||||
jakarta.mail.internet.InternetAddress
|
jakarta.mail.internet.InternetAddress
|
||||||
jakarta.mail.internet.MimeBodyPart
|
jakarta.mail.internet.MimeBodyPart
|
||||||
jakarta.mail.internet.MimeMessage
|
jakarta.mail.internet.MimeMessage
|
||||||
jakarta.mail.internet.MimeMultipart
|
jakarta.mail.internet.MimeMultipart
|
||||||
|
jakarta.mail.Message$RecipientType
|
||||||
|
jakarta.mail.Session
|
||||||
|
jakarta.mail.Transport
|
||||||
java.util.Properties))
|
java.util.Properties))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -412,6 +412,21 @@
|
|||||||
:id ::invite-to-team
|
:id ::invite-to-team
|
||||||
:schema schema:invite-to-team))
|
:schema schema:invite-to-team))
|
||||||
|
|
||||||
|
(def ^:private schema:invite-to-org
|
||||||
|
[:map
|
||||||
|
[:invited-by ::sm/text]
|
||||||
|
[:organization-name ::sm/text]
|
||||||
|
[:org-initials ::sm/text]
|
||||||
|
[:org-logo ::sm/uri]
|
||||||
|
[:user-name [:maybe ::sm/text]]
|
||||||
|
[:token ::sm/text]])
|
||||||
|
|
||||||
|
(def invite-to-org
|
||||||
|
"Org member invitation email."
|
||||||
|
(template-factory
|
||||||
|
:id ::invite-to-org
|
||||||
|
:schema schema:invite-to-org))
|
||||||
|
|
||||||
(def ^:private schema:join-team
|
(def ^:private schema:join-team
|
||||||
[:map
|
[:map
|
||||||
[:invited-by ::sm/text]
|
[:invited-by ::sm/text]
|
||||||
|
|||||||
@ -36,10 +36,18 @@
|
|||||||
:cause cause)))))
|
:cause cause)))))
|
||||||
|
|
||||||
(defn contains?
|
(defn contains?
|
||||||
"Check if email is in the blacklist."
|
"Check if email is in the blacklist. Also matches subdomains: if
|
||||||
|
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
|
||||||
|
be rejected."
|
||||||
[{:keys [::email/blacklist]} email]
|
[{:keys [::email/blacklist]} email]
|
||||||
(let [[_ domain] (str/split email "@" 2)]
|
(let [[_ domain] (str/split email "@" 2)
|
||||||
(c/contains? blacklist (str/lower domain))))
|
parts (str/split (str/lower domain) #"\.")]
|
||||||
|
(loop [parts parts]
|
||||||
|
(if (empty? parts)
|
||||||
|
false
|
||||||
|
(if (c/contains? blacklist (str/join "." parts))
|
||||||
|
true
|
||||||
|
(recur (rest parts)))))))
|
||||||
|
|
||||||
(defn enabled?
|
(defn enabled?
|
||||||
"Check if the blacklist is enabled"
|
"Check if the blacklist is enabled"
|
||||||
|
|||||||
@ -112,8 +112,9 @@
|
|||||||
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
||||||
END"))
|
END"))
|
||||||
|
|
||||||
(defn- get-snapshot
|
(defn get-snapshot-data
|
||||||
"Get snapshot with decoded data"
|
"Get a fully decoded snapshot for read-only preview or restoration.
|
||||||
|
Returns the snapshot map with decoded :data field."
|
||||||
[cfg file-id snapshot-id]
|
[cfg file-id snapshot-id]
|
||||||
(let [now (ct/now)]
|
(let [now (ct/now)]
|
||||||
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
|
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
|
||||||
@ -326,7 +327,7 @@
|
|||||||
(sto/resolve cfg {::db/reuse-conn true})
|
(sto/resolve cfg {::db/reuse-conn true})
|
||||||
|
|
||||||
snapshot
|
snapshot
|
||||||
(get-snapshot cfg file-id snapshot-id)]
|
(get-snapshot-data cfg file-id snapshot-id)]
|
||||||
|
|
||||||
(when-not snapshot
|
(when-not snapshot
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
|
|||||||
@ -31,7 +31,6 @@
|
|||||||
[app.srepl.main :as srepl]
|
[app.srepl.main :as srepl]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[app.util.blob :as blob]
|
|
||||||
[app.util.template :as tmpl]
|
[app.util.template :as tmpl]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.io :as io]
|
[datoteka.io :as io]
|
||||||
@ -71,8 +70,7 @@
|
|||||||
|
|
||||||
(defn- get-resolved-file
|
(defn- get-resolved-file
|
||||||
[cfg file-id]
|
[cfg file-id]
|
||||||
(some-> (bfc/get-file cfg file-id :migrate? false)
|
(bfc/get-file cfg file-id :migrate? false :decode? false))
|
||||||
(update :data blob/encode)))
|
|
||||||
|
|
||||||
(defn prepare-download
|
(defn prepare-download
|
||||||
[file filename]
|
[file filename]
|
||||||
|
|||||||
@ -220,12 +220,14 @@
|
|||||||
(assoc :hint (ex-message error)))}))))
|
(assoc :hint (ex-message error)))}))))
|
||||||
|
|
||||||
(defmethod handle-exception java.io.IOException
|
(defmethod handle-exception java.io.IOException
|
||||||
[cause _ _]
|
[cause request _]
|
||||||
(l/wrn :hint "io exception" :cause cause)
|
(binding [l/*context* (request->context request)]
|
||||||
{::yres/status 500
|
(l/wrn :hint "io exception" :cause cause)
|
||||||
::yres/body {:type :server-error
|
{::yres/status 500
|
||||||
:code :io-exception
|
::yres/body {:type :server-error
|
||||||
:hint (ex-message cause)}})
|
:code :io-exception
|
||||||
|
:hint (ex-message cause)
|
||||||
|
:path (:path request)}}))
|
||||||
|
|
||||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||||
[cause request _]
|
[cause request _]
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
::yres/status 200
|
::yres/status 200
|
||||||
::yres/body (yres/stream-body
|
::yres/body (yres/stream-body
|
||||||
(fn [_ output]
|
(fn [_ output]
|
||||||
|
|
||||||
(let [channel (sp/chan :buf buf :xf (keep encode))
|
(let [channel (sp/chan :buf buf :xf (keep encode))
|
||||||
listener (events/spawn-listener
|
listener (events/spawn-listener
|
||||||
channel
|
channel
|
||||||
|
|||||||
@ -120,7 +120,7 @@
|
|||||||
;; an external storage and data cleared.
|
;; an external storage and data cleared.
|
||||||
|
|
||||||
(def ^:private schema:event
|
(def ^:private schema:event
|
||||||
[:map {:title "event"}
|
[:map {:title "AuditEvent"}
|
||||||
[::type ::sm/text]
|
[::type ::sm/text]
|
||||||
[::name ::sm/text]
|
[::name ::sm/text]
|
||||||
[::profile-id ::sm/uuid]
|
[::profile-id ::sm/uuid]
|
||||||
|
|||||||
@ -10,14 +10,11 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
[app.common.uuid :as uuid]
|
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.tokens :as tokens]
|
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[lambdaisland.uri :as u]
|
|
||||||
[promesa.exec :as px]))
|
[promesa.exec :as px]))
|
||||||
|
|
||||||
;; This is a task responsible to send the accumulated events to
|
;; This is a task responsible to send the accumulated events to
|
||||||
@ -52,19 +49,18 @@
|
|||||||
|
|
||||||
(defn- send!
|
(defn- send!
|
||||||
[{:keys [::uri] :as cfg} events]
|
[{:keys [::uri] :as cfg} events]
|
||||||
(let [token (tokens/generate cfg
|
(let [skey (-> cfg ::setup/shared-keys :nexus)
|
||||||
{:iss "authentication"
|
|
||||||
:uid uuid/zero})
|
|
||||||
body (t/encode {:events events})
|
body (t/encode {:events events})
|
||||||
headers {"content-type" "application/transit+json"
|
headers {"content-type" "application/transit+json"
|
||||||
"origin" (str (cf/get :public-uri))
|
"origin" (str (cf/get :public-uri))
|
||||||
"cookie" (u/map->query-string {:auth-token token})}
|
"x-shared-key" (str "nexus " skey)}
|
||||||
params {:uri uri
|
params {:uri uri
|
||||||
:timeout 12000
|
:timeout 12000
|
||||||
:method :post
|
:method :post
|
||||||
:headers headers
|
:headers headers
|
||||||
:body body}
|
:body body}
|
||||||
resp (http/req! cfg params)]
|
resp (http/req! cfg params)]
|
||||||
|
|
||||||
(if (= (:status resp) 204)
|
(if (= (:status resp) 204)
|
||||||
true
|
true
|
||||||
(do
|
(do
|
||||||
@ -85,7 +81,7 @@
|
|||||||
(def ^:private sql:get-audit-log-chunk
|
(def ^:private sql:get-audit-log-chunk
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM audit_log
|
FROM audit_log
|
||||||
WHERE archived_at is null
|
WHERE archived_at IS NULL
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 128
|
LIMIT 128
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@ -109,7 +105,7 @@
|
|||||||
(def ^:private schema:handler-params
|
(def ^:private schema:handler-params
|
||||||
[:map
|
[:map
|
||||||
::db/pool
|
::db/pool
|
||||||
::setup/props
|
::setup/shared-keys
|
||||||
::http/client])
|
::http/client])
|
||||||
|
|
||||||
(defmethod ig/assert-key ::handler
|
(defmethod ig/assert-key ::handler
|
||||||
|
|||||||
@ -50,9 +50,9 @@
|
|||||||
(ex-data cause))
|
(ex-data cause))
|
||||||
|
|
||||||
ctx (-> context
|
ctx (-> context
|
||||||
(assoc :service/tenant (cf/get :tenant))
|
(assoc :backend/tenant (cf/get :tenant))
|
||||||
(assoc :service/host (cf/get :host))
|
(assoc :backend/host (cf/get :host))
|
||||||
(assoc :service/public-uri (str (cf/get :public-uri)))
|
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :logger/name logger)
|
(assoc :logger/name logger)
|
||||||
(assoc :logger/level level)
|
(assoc :logger/level level)
|
||||||
|
|||||||
@ -388,6 +388,7 @@
|
|||||||
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
||||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||||
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
||||||
|
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
|
||||||
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
|
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
|
||||||
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
||||||
:session-gc (ig/ref ::session.tasks/gc)
|
:session-gc (ig/ref ::session.tasks/gc)
|
||||||
@ -423,6 +424,9 @@
|
|||||||
:app.tasks.tasks-gc/handler
|
:app.tasks.tasks-gc/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
|
:app.tasks.upload-session-gc/handler
|
||||||
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
:app.tasks.objects-gc/handler
|
:app.tasks.objects-gc/handler
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
::sto/storage (ig/ref ::sto/storage)}
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
@ -466,16 +470,17 @@
|
|||||||
|
|
||||||
::setup/shared-keys
|
::setup/shared-keys
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
:nitrate (cf/get :nitrate-shared-key)
|
:nexus (cf/get :nexus-shared-key)
|
||||||
:exporter (cf/get :exporter-shared-key)}
|
:nitrate (cf/get :nitrate-shared-key)
|
||||||
|
:exporter (cf/get :exporter-shared-key)}
|
||||||
|
|
||||||
::setup/clock
|
::setup/clock
|
||||||
{}
|
{}
|
||||||
|
|
||||||
:app.loggers.audit.archive-task/handler
|
:app.loggers.audit.archive-task/handler
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||||
::db/pool (ig/ref ::db/pool)
|
::http.client/client (ig/ref ::http.client/client)
|
||||||
::http.client/client (ig/ref ::http.client/client)}
|
::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
:app.loggers.audit.gc-task/handler
|
:app.loggers.audit.gc-task/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
@ -543,6 +548,9 @@
|
|||||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||||
:task :tasks-gc}
|
:task :tasks-gc}
|
||||||
|
|
||||||
|
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||||
|
:task :upload-session-gc}
|
||||||
|
|
||||||
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
||||||
:task :file-gc-scheduler}
|
:task :file-gc-scheduler}
|
||||||
|
|
||||||
|
|||||||
@ -31,8 +31,8 @@
|
|||||||
(:import
|
(:import
|
||||||
clojure.lang.XMLHandler
|
clojure.lang.XMLHandler
|
||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
javax.xml.XMLConstants
|
|
||||||
javax.xml.parsers.SAXParserFactory
|
javax.xml.parsers.SAXParserFactory
|
||||||
|
javax.xml.XMLConstants
|
||||||
org.apache.commons.io.IOUtils
|
org.apache.commons.io.IOUtils
|
||||||
org.im4java.core.ConvertCmd
|
org.im4java.core.ConvertCmd
|
||||||
org.im4java.core.IMOperation))
|
org.im4java.core.IMOperation))
|
||||||
@ -54,7 +54,7 @@
|
|||||||
[:path ::fs/path]
|
[:path ::fs/path]
|
||||||
[:mtype {:optional true} ::sm/text]])
|
[:mtype {:optional true} ::sm/text]])
|
||||||
|
|
||||||
(def ^:private check-input
|
(def check-input
|
||||||
(sm/check-fn schema:input))
|
(sm/check-fn schema:input))
|
||||||
|
|
||||||
(defn validate-media-type!
|
(defn validate-media-type!
|
||||||
@ -409,6 +409,22 @@
|
|||||||
(when (zero? (:exit res))
|
(when (zero? (:exit res))
|
||||||
(:out res))))
|
(:out res))))
|
||||||
|
|
||||||
|
(woff2->sfnt [data]
|
||||||
|
;; woff2_decompress outputs to same directory with .ttf extension
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||||
|
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (sh/sh "woff2_decompress" (str finput))]
|
||||||
|
(if (zero? (:exit res))
|
||||||
|
foutput
|
||||||
|
(do
|
||||||
|
(when (fs/exists? foutput)
|
||||||
|
(fs/delete foutput))
|
||||||
|
nil)))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
;; Documented here:
|
;; Documented here:
|
||||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||||
(get-sfnt-type [data]
|
(get-sfnt-type [data]
|
||||||
@ -458,4 +474,27 @@
|
|||||||
|
|
||||||
(= stype :ttf)
|
(= stype :ttf)
|
||||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||||
(assoc "font/ttf" sfnt)))))))))
|
(assoc "font/ttf" sfnt)))))
|
||||||
|
|
||||||
|
(contains? current "font/woff2")
|
||||||
|
(let [data (get input "font/woff2")
|
||||||
|
foutput (woff2->sfnt data)]
|
||||||
|
(when-not foutput
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-woff2-file
|
||||||
|
:hint "invalid woff2 file"))
|
||||||
|
(try
|
||||||
|
(let [sfnt (io/read* foutput)
|
||||||
|
type (get-sfnt-type sfnt)]
|
||||||
|
(cond-> input
|
||||||
|
(= type :otf)
|
||||||
|
(-> (assoc "font/otf" sfnt)
|
||||||
|
(assoc "font/ttf" (otf->ttf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||||
|
|
||||||
|
(= type :ttf)
|
||||||
|
(-> (assoc "font/ttf" sfnt)
|
||||||
|
(assoc "font/otf" (ttf->otf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||||
|
(finally
|
||||||
|
(fs/delete foutput))))))))
|
||||||
|
|||||||
@ -15,16 +15,16 @@
|
|||||||
io.prometheus.client.CollectorRegistry
|
io.prometheus.client.CollectorRegistry
|
||||||
io.prometheus.client.Counter
|
io.prometheus.client.Counter
|
||||||
io.prometheus.client.Counter$Child
|
io.prometheus.client.Counter$Child
|
||||||
|
io.prometheus.client.exporter.common.TextFormat
|
||||||
io.prometheus.client.Gauge
|
io.prometheus.client.Gauge
|
||||||
io.prometheus.client.Gauge$Child
|
io.prometheus.client.Gauge$Child
|
||||||
io.prometheus.client.Histogram
|
io.prometheus.client.Histogram
|
||||||
io.prometheus.client.Histogram$Child
|
io.prometheus.client.Histogram$Child
|
||||||
|
io.prometheus.client.hotspot.DefaultExports
|
||||||
io.prometheus.client.SimpleCollector
|
io.prometheus.client.SimpleCollector
|
||||||
io.prometheus.client.Summary
|
io.prometheus.client.Summary
|
||||||
io.prometheus.client.Summary$Builder
|
io.prometheus.client.Summary$Builder
|
||||||
io.prometheus.client.Summary$Child
|
io.prometheus.client.Summary$Child
|
||||||
io.prometheus.client.exporter.common.TextFormat
|
|
||||||
io.prometheus.client.hotspot.DefaultExports
|
|
||||||
java.io.StringWriter))
|
java.io.StringWriter))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
|
|||||||
@ -463,8 +463,19 @@
|
|||||||
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
||||||
|
|
||||||
{:name "0145-fix-plugins-uri-on-profile"
|
{:name "0145-fix-plugins-uri-on-profile"
|
||||||
:fn mg0145/migrate}])
|
:fn mg0145/migrate}
|
||||||
|
|
||||||
|
{:name "0145-mod-audit-log-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")}
|
||||||
|
|
||||||
|
{:name "0146-mod-access-token-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
|
||||||
|
|
||||||
|
{:name "0147-mod-team-invitation-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")}
|
||||||
|
|
||||||
|
{:name "0147-add-upload-session-table"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}])
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|||||||
@ -58,4 +58,3 @@
|
|||||||
(when (nil? (:data file))
|
(when (nil? (:data file))
|
||||||
(migrate-file conn file)))
|
(migrate-file conn file)))
|
||||||
(db/exec-one! conn ["drop table page cascade;"])))
|
(db/exec-one! conn ["drop table page cascade;"])))
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
CREATE INDEX audit_log__created_at__idx ON audit_log(created_at) WHERE archived_at IS NULL;
|
||||||
|
CREATE INDEX audit_log__archived_at__idx ON audit_log(archived_at) WHERE archived_at IS NOT NULL;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE access_token
|
||||||
|
ADD COLUMN type text NULL;
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE upload_session (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
|
total_chunks integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX upload_session__profile_id__idx
|
||||||
|
ON upload_session(profile_id);
|
||||||
|
|
||||||
|
CREATE INDEX upload_session__created_at__idx
|
||||||
|
ON upload_session(created_at);
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE team_invitation
|
||||||
|
ADD COLUMN org_id uuid NULL;
|
||||||
|
|
||||||
|
ALTER TABLE team_invitation
|
||||||
|
ALTER COLUMN team_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE team_invitation
|
||||||
|
ADD CONSTRAINT team_invitation_team_or_org_not_null
|
||||||
|
CHECK (team_id IS NOT NULL OR org_id IS NOT NULL);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX team_invitation_org_unique
|
||||||
|
ON team_invitation (org_id, email_to)
|
||||||
|
WHERE team_id IS NULL;
|
||||||
@ -1,13 +1,23 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
(ns app.nitrate
|
(ns app.nitrate
|
||||||
"Module that make calls to the external nitrate aplication"
|
"Module that make calls to the external nitrate aplication"
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.json :as json]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.common.schema.generators :as sg]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.organization :as cto]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.util.json :as json]
|
|
||||||
[clojure.core :as c]
|
[clojure.core :as c]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
@ -16,16 +26,16 @@
|
|||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defn- request-builder
|
(defn- request-builder
|
||||||
[cfg method uri shared-key profile-id]
|
[cfg method uri shared-key profile-id request-params]
|
||||||
(fn []
|
(fn []
|
||||||
(http/req! cfg {:method method
|
(http/req! cfg (cond-> {:method method
|
||||||
:headers {"content-type" "application/json"
|
:headers {"content-type" "application/json"
|
||||||
"accept" "application/json"
|
"accept" "application/json"
|
||||||
"x-shared-key" shared-key
|
"x-shared-key" shared-key
|
||||||
"x-profile-id" (str profile-id)}
|
"x-profile-id" (str profile-id)}
|
||||||
:uri uri
|
:uri uri
|
||||||
:version :http1.1})))
|
:version :http1.1}
|
||||||
|
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
|
||||||
|
|
||||||
(defn- with-retries
|
(defn- with-retries
|
||||||
[handler max-retries]
|
[handler max-retries]
|
||||||
@ -47,20 +57,41 @@
|
|||||||
|
|
||||||
(defn- with-validate [handler uri schema]
|
(defn- with-validate [handler uri schema]
|
||||||
(fn []
|
(fn []
|
||||||
(let [coercer-http (sm/coercer schema
|
(let [response (handler)
|
||||||
:type :validation
|
status (:status response)]
|
||||||
:hint (str "invalid data received calling " uri))]
|
(when-not status
|
||||||
(try
|
(l/error :hint "could't do the nitrate request, it is probably down"
|
||||||
(coercer-http (-> (handler) :body json/decode))
|
:uri uri)
|
||||||
(catch Exception e
|
;; TODO decide what to do when Nitrate is inaccesible
|
||||||
;; TODO Error handling
|
nil)
|
||||||
(l/error :hint "error validating json response" :cause e)
|
(cond
|
||||||
nil)))))
|
(>= status 400)
|
||||||
|
;; For error status codes (4xx, 5xx), fail immediately without validation
|
||||||
|
(do
|
||||||
|
(when (not= status 404) ;; Don't need to log 404
|
||||||
|
(l/error :hint "nitrate request failed with error status"
|
||||||
|
:uri uri
|
||||||
|
:status status
|
||||||
|
:body (:body response)))
|
||||||
|
nil)
|
||||||
|
(= status 204) ;; 204 doesn't return any body
|
||||||
|
nil
|
||||||
|
:else ;; For success status codes, validate the response
|
||||||
|
(let [coercer-http (sm/coercer schema
|
||||||
|
:type :validation
|
||||||
|
:hint (str "invalid data received calling " uri))
|
||||||
|
data (-> response :body (json/decode :key-fn json/read-kebab-key))]
|
||||||
|
(try
|
||||||
|
(coercer-http data)
|
||||||
|
(catch Exception e
|
||||||
|
;; TODO Error handling
|
||||||
|
(l/error :hint "error validating json response" :cause e)
|
||||||
|
nil)))))))
|
||||||
|
|
||||||
(defn- request-to-nitrate
|
(defn- request-to-nitrate
|
||||||
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
|
[cfg method uri schema {:keys [::rpc/profile-id request-params] :as params}]
|
||||||
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
||||||
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
|
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
||||||
(with-retries 3)
|
(with-retries 3)
|
||||||
(with-validate uri schema))]
|
(with-validate uri schema))]
|
||||||
(full-http-call)))
|
(full-http-call)))
|
||||||
@ -78,24 +109,226 @@
|
|||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private schema:organization
|
(def ^:private schema:org-summary
|
||||||
[:map
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:owner-id ::sm/uuid]
|
||||||
|
[:teams
|
||||||
|
[:vector
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:is-your-penpot :boolean]]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:profile-org
|
||||||
|
[:map
|
||||||
|
[:is-member :boolean]
|
||||||
|
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
|
||||||
|
|
||||||
|
|
||||||
|
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
||||||
|
(def ^:private schema:timestamp
|
||||||
|
(sm/type-schema
|
||||||
|
{:type ::timestamp
|
||||||
|
:pred ct/inst?
|
||||||
|
:type-properties
|
||||||
|
{:title "inst"
|
||||||
|
:description "The same as :app.common.time/inst but encodes to epoch"
|
||||||
|
:error/message "should be an instant"
|
||||||
|
:gen/gen (->> (sg/small-int)
|
||||||
|
(sg/fmap (fn [v] (ct/inst v))))
|
||||||
|
:decode/string ct/inst
|
||||||
|
:encode/string inst-ms
|
||||||
|
:decode/json ct/inst
|
||||||
|
:encode/json inst-ms}}))
|
||||||
|
|
||||||
|
(def ^:private schema:subscription
|
||||||
|
[:map {:title "Subscription"}
|
||||||
[:id ::sm/text]
|
[:id ::sm/text]
|
||||||
[:name ::sm/text]])
|
[:customer-id ::sm/text]
|
||||||
|
[:type [:enum
|
||||||
|
"unlimited"
|
||||||
|
"professional"
|
||||||
|
"enterprise"
|
||||||
|
"nitrate"]]
|
||||||
|
[:status [:enum
|
||||||
|
"active"
|
||||||
|
"canceled"
|
||||||
|
"incomplete"
|
||||||
|
"incomplete_expired"
|
||||||
|
"past_due"
|
||||||
|
"paused"
|
||||||
|
"trialing"
|
||||||
|
"unpaid"]]
|
||||||
|
|
||||||
(def ^:private schema:user
|
[:billing-period [:enum
|
||||||
|
"month"
|
||||||
|
"day"
|
||||||
|
"week"
|
||||||
|
"year"]]
|
||||||
|
[:quantity :int]
|
||||||
|
[:description [:maybe ::sm/text]]
|
||||||
|
[:created-at schema:timestamp]
|
||||||
|
[:start-date [:maybe schema:timestamp]]
|
||||||
|
[:ended-at [:maybe schema:timestamp]]
|
||||||
|
[:trial-end [:maybe schema:timestamp]]
|
||||||
|
[:trial-start [:maybe schema:timestamp]]
|
||||||
|
[:cancel-at [:maybe schema:timestamp]]
|
||||||
|
[:canceled-at [:maybe schema:timestamp]]
|
||||||
|
[:current-period-end [:maybe schema:timestamp]]
|
||||||
|
[:current-period-start [:maybe schema:timestamp]]
|
||||||
|
[:cancel-at-period-end :boolean]
|
||||||
|
|
||||||
|
[:cancellation-details
|
||||||
|
[:map {:title "CancellationDetails"}
|
||||||
|
[:comment [:maybe ::sm/text]]
|
||||||
|
[:reason [:maybe ::sm/text]]
|
||||||
|
[:feedback [:maybe
|
||||||
|
[:enum
|
||||||
|
"customer_service"
|
||||||
|
"low_quality"
|
||||||
|
"missing_feature"
|
||||||
|
"other"
|
||||||
|
"switched_service"
|
||||||
|
"too_complex"
|
||||||
|
"too_expensive"
|
||||||
|
"unused"]]]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:connectivity
|
||||||
[:map
|
[:map
|
||||||
[:valid ::sm/boolean]])
|
[:licenses ::sm/boolean]])
|
||||||
|
|
||||||
(defn- get-team-org
|
(defn- get-team-org-api
|
||||||
[cfg {:keys [team-id] :as params}]
|
[cfg {:keys [team-id] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)))
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/teams/"
|
||||||
|
team-id)
|
||||||
|
cto/schema:team-with-organization params)))
|
||||||
|
|
||||||
(defn- is-valid-user
|
(defn- get-org-membership-api
|
||||||
|
[cfg {:keys [profile-id organization-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/members/"
|
||||||
|
profile-id)
|
||||||
|
schema:profile-org params)))
|
||||||
|
|
||||||
|
(defn- get-org-membership-by-team-api
|
||||||
|
[cfg {:keys [profile-id team-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/teams/"
|
||||||
|
team-id
|
||||||
|
"/users/"
|
||||||
|
profile-id)
|
||||||
|
schema:profile-org params)))
|
||||||
|
|
||||||
|
|
||||||
|
(defn- get-org-summary-api
|
||||||
|
[cfg {:keys [organization-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/summary")
|
||||||
|
schema:org-summary params)))
|
||||||
|
|
||||||
|
|
||||||
|
(defn- set-team-org-api
|
||||||
|
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
params (assoc params :request-params {:team-id team-id
|
||||||
|
:is-your-penpot (true? is-default)})
|
||||||
|
team (request-to-nitrate cfg :post
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/add-team")
|
||||||
|
cto/schema:team-with-organization params)
|
||||||
|
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])]
|
||||||
|
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
|
||||||
|
(cond-> team
|
||||||
|
custom-photo
|
||||||
|
(assoc-in [:organization :custom-photo] custom-photo))))
|
||||||
|
|
||||||
|
(defn- add-profile-to-org-api
|
||||||
|
[cfg {:keys [profile-id organization-id team-id email] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||||
|
(some? email) (assoc :email email))
|
||||||
|
params (assoc params :request-params request-params)]
|
||||||
|
(request-to-nitrate cfg :post
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/add-user")
|
||||||
|
schema:profile-org params)))
|
||||||
|
|
||||||
|
(defn- remove-profile-from-org-api
|
||||||
|
[cfg {:keys [profile-id organization-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
params (assoc params :request-params {:user-id profile-id})]
|
||||||
|
(request-to-nitrate cfg :post
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/remove-user")
|
||||||
|
nil params)))
|
||||||
|
|
||||||
|
(defn- remove-profile-from-all-orgs-api
|
||||||
[cfg {:keys [profile-id] :as params}]
|
[cfg {:keys [profile-id] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
|
(request-to-nitrate cfg :post
|
||||||
|
(str baseuri
|
||||||
|
"/api/users/"
|
||||||
|
profile-id
|
||||||
|
"/remove-organizations")
|
||||||
|
nil params)))
|
||||||
|
|
||||||
|
(defn- remove-team-from-org-api
|
||||||
|
[cfg {:keys [team-id organization-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
params (assoc params :request-params {:team-id team-id})]
|
||||||
|
(request-to-nitrate cfg :post
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/remove-team")
|
||||||
|
nil params)))
|
||||||
|
|
||||||
|
(defn- delete-team-api
|
||||||
|
[cfg {:keys [team-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :delete
|
||||||
|
(str baseuri
|
||||||
|
"/api/teams/"
|
||||||
|
team-id)
|
||||||
|
nil params)))
|
||||||
|
|
||||||
|
(defn- get-subscription-api
|
||||||
|
[cfg {:keys [profile-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/subscriptions/"
|
||||||
|
profile-id)
|
||||||
|
schema:subscription params)))
|
||||||
|
|
||||||
|
(defn- get-connectivity-api
|
||||||
|
[cfg params]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/connectivity")
|
||||||
|
schema:connectivity params)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; INITIALIZATION
|
;; INITIALIZATION
|
||||||
@ -104,8 +337,18 @@
|
|||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :nitrate)
|
||||||
{:get-team-org (partial get-team-org cfg)
|
{:get-team-org (partial get-team-org-api cfg)
|
||||||
:is-valid-user (partial is-valid-user cfg)}))
|
:set-team-org (partial set-team-org-api cfg)
|
||||||
|
:get-org-membership (partial get-org-membership-api cfg)
|
||||||
|
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
|
||||||
|
:get-org-summary (partial get-org-summary-api cfg)
|
||||||
|
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||||
|
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||||
|
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
|
||||||
|
:delete-team (partial delete-team-api cfg)
|
||||||
|
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||||
|
:get-subscription (partial get-subscription-api cfg)
|
||||||
|
:connectivity (partial get-connectivity-api cfg)}))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; UTILS
|
;; UTILS
|
||||||
@ -113,18 +356,57 @@
|
|||||||
|
|
||||||
|
|
||||||
(defn add-nitrate-licence-to-profile
|
(defn add-nitrate-licence-to-profile
|
||||||
|
"Enriches a profile map with subscription information from Nitrate.
|
||||||
|
Adds a :subscription field containing the user's license details.
|
||||||
|
Returns the original profile unchanged if the request fails."
|
||||||
[cfg profile]
|
[cfg profile]
|
||||||
(try
|
(try
|
||||||
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
|
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
||||||
(assoc profile :nitrate-licence (:valid nitrate-licence)))
|
(assoc profile :subscription subscription))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "failed to get nitrate licence"
|
(l/error :hint "failed to get nitrate licence"
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:cause cause)
|
:cause cause)
|
||||||
profile)))
|
profile)))
|
||||||
|
|
||||||
(defn add-org-to-team
|
(defn add-org-info-to-team
|
||||||
|
"Enriches a team map with organization information from Nitrate.
|
||||||
|
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||||
|
Returns the original team unchanged if the request fails or org data is nil."
|
||||||
[cfg team params]
|
[cfg team params]
|
||||||
(let [params (assoc (or params {}) :team-id (:id team))
|
(try
|
||||||
org (call cfg :get-team-org params)]
|
(let [params (assoc (or params {}) :team-id (:id team))
|
||||||
(assoc team :organization-id (:id org) :organization-name (:name org))))
|
team-with-org (call cfg :get-team-org params)
|
||||||
|
org (:organization team-with-org)]
|
||||||
|
(if (some? org)
|
||||||
|
(-> (cto/apply-organization team (assoc org :custom-photo
|
||||||
|
(when-let [logo-id (:logo-id org)]
|
||||||
|
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
|
||||||
|
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
||||||
|
team))
|
||||||
|
(catch Throwable cause
|
||||||
|
(l/error :hint "failed to get team organization info"
|
||||||
|
:team-id (:id team)
|
||||||
|
:cause cause)
|
||||||
|
team)))
|
||||||
|
|
||||||
|
(defn set-team-organization
|
||||||
|
"Associates a team with an organization in Nitrate.
|
||||||
|
Requires organization-id and is-default in params.
|
||||||
|
Throws an exception if the request fails."
|
||||||
|
[cfg team params]
|
||||||
|
(let [params (assoc (or params {})
|
||||||
|
:team-id (:id team)
|
||||||
|
:organization-id (:organization-id params)
|
||||||
|
:is-default (:is-default params))
|
||||||
|
result (call cfg :set-team-org params)]
|
||||||
|
(when (nil? result)
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :failed-to-set-team-org
|
||||||
|
:context {:team-id (:id team)
|
||||||
|
:organization-id (:organization-id params)}))
|
||||||
|
team))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -24,28 +24,28 @@
|
|||||||
[integrant.core :as ig])
|
[integrant.core :as ig])
|
||||||
(:import
|
(:import
|
||||||
clojure.lang.MapEntry
|
clojure.lang.MapEntry
|
||||||
io.lettuce.core.KeyValue
|
|
||||||
io.lettuce.core.RedisClient
|
|
||||||
io.lettuce.core.RedisCommandInterruptedException
|
|
||||||
io.lettuce.core.RedisCommandTimeoutException
|
|
||||||
io.lettuce.core.RedisException
|
|
||||||
io.lettuce.core.RedisURI
|
|
||||||
io.lettuce.core.ScriptOutputType
|
|
||||||
io.lettuce.core.SetArgs
|
|
||||||
io.lettuce.core.api.StatefulRedisConnection
|
io.lettuce.core.api.StatefulRedisConnection
|
||||||
io.lettuce.core.api.sync.RedisCommands
|
io.lettuce.core.api.sync.RedisCommands
|
||||||
io.lettuce.core.api.sync.RedisScriptingCommands
|
io.lettuce.core.api.sync.RedisScriptingCommands
|
||||||
io.lettuce.core.codec.RedisCodec
|
io.lettuce.core.codec.RedisCodec
|
||||||
io.lettuce.core.codec.StringCodec
|
io.lettuce.core.codec.StringCodec
|
||||||
|
io.lettuce.core.KeyValue
|
||||||
|
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
|
||||||
io.lettuce.core.pubsub.RedisPubSubListener
|
io.lettuce.core.pubsub.RedisPubSubListener
|
||||||
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
||||||
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
|
io.lettuce.core.RedisClient
|
||||||
|
io.lettuce.core.RedisCommandInterruptedException
|
||||||
|
io.lettuce.core.RedisCommandTimeoutException
|
||||||
|
io.lettuce.core.RedisException
|
||||||
|
io.lettuce.core.RedisURI
|
||||||
io.lettuce.core.resource.ClientResources
|
io.lettuce.core.resource.ClientResources
|
||||||
io.lettuce.core.resource.DefaultClientResources
|
io.lettuce.core.resource.DefaultClientResources
|
||||||
|
io.lettuce.core.ScriptOutputType
|
||||||
|
io.lettuce.core.SetArgs
|
||||||
io.netty.channel.nio.NioEventLoopGroup
|
io.netty.channel.nio.NioEventLoopGroup
|
||||||
|
io.netty.util.concurrent.EventExecutorGroup
|
||||||
io.netty.util.HashedWheelTimer
|
io.netty.util.HashedWheelTimer
|
||||||
io.netty.util.Timer
|
io.netty.util.Timer
|
||||||
io.netty.util.concurrent.EventExecutorGroup
|
|
||||||
java.lang.AutoCloseable
|
java.lang.AutoCloseable
|
||||||
java.time.Duration))
|
java.time.Duration))
|
||||||
|
|
||||||
|
|||||||
@ -73,9 +73,13 @@
|
|||||||
(if (nil? result)
|
(if (nil? result)
|
||||||
204
|
204
|
||||||
200))
|
200))
|
||||||
headers (cond-> (::http/headers mdata {})
|
|
||||||
(yres/stream-body? result)
|
headers (::http/headers mdata {})
|
||||||
|
headers (cond-> headers
|
||||||
|
(and (yres/stream-body? result)
|
||||||
|
(not (contains? headers "content-type")))
|
||||||
(assoc "content-type" "application/octet-stream"))]
|
(assoc "content-type" "application/octet-stream"))]
|
||||||
|
|
||||||
{::yres/status status
|
{::yres/status status
|
||||||
::yres/headers headers
|
::yres/headers headers
|
||||||
::yres/body result}))]
|
::yres/body result}))]
|
||||||
@ -92,6 +96,7 @@
|
|||||||
(fn [{:keys [params path-params method] :as request}]
|
(fn [{:keys [params path-params method] :as request}]
|
||||||
(let [handler-name (:method-name path-params)
|
(let [handler-name (:method-name path-params)
|
||||||
etag (yreq/get-header request "if-none-match")
|
etag (yreq/get-header request "if-none-match")
|
||||||
|
session-id (yreq/get-header request "x-session-id")
|
||||||
|
|
||||||
key-id (get request ::http/auth-key-id)
|
key-id (get request ::http/auth-key-id)
|
||||||
profile-id (or (::session/profile-id request)
|
profile-id (or (::session/profile-id request)
|
||||||
@ -104,6 +109,7 @@
|
|||||||
(assoc ::handler-name handler-name)
|
(assoc ::handler-name handler-name)
|
||||||
(assoc ::ip-addr ip-addr)
|
(assoc ::ip-addr ip-addr)
|
||||||
(assoc ::request-at (ct/now))
|
(assoc ::request-at (ct/now))
|
||||||
|
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||||
(assoc ::cond/key etag)
|
(assoc ::cond/key etag)
|
||||||
(cond-> (uuid? profile-id)
|
(cond-> (uuid? profile-id)
|
||||||
(assoc ::profile-id profile-id)))
|
(assoc ::profile-id profile-id)))
|
||||||
@ -258,6 +264,7 @@
|
|||||||
'app.rpc.commands.ldap
|
'app.rpc.commands.ldap
|
||||||
'app.rpc.commands.management
|
'app.rpc.commands.management
|
||||||
'app.rpc.commands.media
|
'app.rpc.commands.media
|
||||||
|
'app.rpc.commands.nitrate
|
||||||
'app.rpc.commands.profile
|
'app.rpc.commands.profile
|
||||||
'app.rpc.commands.projects
|
'app.rpc.commands.projects
|
||||||
'app.rpc.commands.search
|
'app.rpc.commands.search
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
(dissoc row :perms))
|
(dissoc row :perms))
|
||||||
|
|
||||||
(defn create-access-token
|
(defn create-access-token
|
||||||
[{:keys [::db/conn] :as cfg} profile-id name expiration]
|
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||||
(let [token-id (uuid/next)
|
(let [token-id (uuid/next)
|
||||||
expires-at (some-> expiration (ct/in-future))
|
expires-at (some-> expiration (ct/in-future))
|
||||||
created-at (ct/now)
|
created-at (ct/now)
|
||||||
@ -36,6 +36,7 @@
|
|||||||
{:id token-id
|
{:id token-id
|
||||||
:name name
|
:name name
|
||||||
:token token
|
:token token
|
||||||
|
:type type
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:created-at created-at
|
:created-at created-at
|
||||||
:updated-at created-at
|
:updated-at created-at
|
||||||
@ -50,17 +51,18 @@
|
|||||||
(def ^:private schema:create-access-token
|
(def ^:private schema:create-access-token
|
||||||
[:map {:title "create-access-token"}
|
[:map {:title "create-access-token"}
|
||||||
[:name [:string {:max 250 :min 1}]]
|
[:name [:string {:max 250 :min 1}]]
|
||||||
[:expiration {:optional true} ::ct/duration]])
|
[:expiration {:optional true} ::ct/duration]
|
||||||
|
[:type {:optional true} :string]])
|
||||||
|
|
||||||
(sv/defmethod ::create-access-token
|
(sv/defmethod ::create-access-token
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::sm/params schema:create-access-token}
|
::sm/params schema:create-access-token}
|
||||||
[cfg {:keys [::rpc/profile-id name expiration]}]
|
[cfg {:keys [::rpc/profile-id name expiration type]}]
|
||||||
|
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
(db/tx-run! cfg create-access-token profile-id name expiration type))
|
||||||
|
|
||||||
(def ^:private schema:delete-access-token
|
(def ^:private schema:delete-access-token
|
||||||
[:map {:title "delete-access-token"}
|
[:map {:title "delete-access-token"}
|
||||||
@ -83,5 +85,22 @@
|
|||||||
(->> (db/query pool :access-token
|
(->> (db/query pool :access-token
|
||||||
{:profile-id profile-id}
|
{:profile-id profile-id}
|
||||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||||
:columns [:id :name :perms :created-at :updated-at :expires-at]})
|
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
|
||||||
(mapv decode-row)))
|
(mapv decode-row)))
|
||||||
|
|
||||||
|
(def ^:private schema:get-current-mcp-token
|
||||||
|
[:map {:title "get-current-mcp-token"}])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-current-mcp-token
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:get-current-mcp-token}
|
||||||
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
|
||||||
|
(->> (db/query pool :access-token
|
||||||
|
{:profile-id profile-id
|
||||||
|
:type "mcp"}
|
||||||
|
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||||
|
:columns [:token :expires-at]})
|
||||||
|
(remove #(and (some? (:expires-at %))
|
||||||
|
(ct/is-after? request-at (:expires-at %))))
|
||||||
|
(map decode-row)
|
||||||
|
(first)))
|
||||||
|
|||||||
@ -253,12 +253,15 @@
|
|||||||
:hint "email has complaint reports")))
|
:hint "email has complaint reports")))
|
||||||
|
|
||||||
(defn prepare-register
|
(defn prepare-register
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [fullname email accept-newsletter-updates] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [fullname email] :as params}]
|
||||||
|
|
||||||
(validate-register-attempt! cfg params)
|
(validate-register-attempt! cfg params)
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
profile (profile/get-profile-by-email pool email)
|
profile (profile/get-profile-by-email pool email)
|
||||||
|
props (-> (audit/extract-utm-params params)
|
||||||
|
(cond-> (:accept-newsletter-updates params)
|
||||||
|
(assoc :newsletter-updates true)))
|
||||||
params {:email email
|
params {:email email
|
||||||
:fullname fullname
|
:fullname fullname
|
||||||
:password (:password params)
|
:password (:password params)
|
||||||
@ -267,13 +270,12 @@
|
|||||||
:iss :prepared-register
|
:iss :prepared-register
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:exp (ct/in-future {:days 7})
|
:exp (ct/in-future {:days 7})
|
||||||
:props {:newsletter-updates (or accept-newsletter-updates false)}}
|
:props props}
|
||||||
|
|
||||||
params (d/without-nils params)
|
params (d/without-nils params)
|
||||||
token (tokens/generate cfg params)]
|
token (tokens/generate cfg params)]
|
||||||
|
|
||||||
(with-meta {:token token}
|
(-> {:token token}
|
||||||
{::audit/profile-id uuid/zero})))
|
(with-meta {::audit/profile-id uuid/zero}))))
|
||||||
|
|
||||||
(def schema:prepare-register-profile
|
(def schema:prepare-register-profile
|
||||||
[:map {:title "prepare-register-profile"}
|
[:map {:title "prepare-register-profile"}
|
||||||
@ -281,6 +283,7 @@
|
|||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:password schema:password]
|
[:password schema:password]
|
||||||
[:create-welcome-file {:optional true} :boolean]
|
[:create-welcome-file {:optional true} :boolean]
|
||||||
|
[:accept-newsletter-updates {:optional true} :boolean]
|
||||||
[:invitation-token {:optional true} schema:token]])
|
[:invitation-token {:optional true} schema:token]])
|
||||||
|
|
||||||
(sv/defmethod ::prepare-register-profile
|
(sv/defmethod ::prepare-register-profile
|
||||||
@ -317,8 +320,7 @@
|
|||||||
attrs (all the other attrs are filled with default values)."
|
attrs (all the other attrs are filled with default values)."
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
||||||
(let [id (or (:id params) (uuid/next))
|
(let [id (or (:id params) (uuid/next))
|
||||||
props (-> (audit/extract-utm-params params)
|
props (-> (:props params)
|
||||||
(merge (:props params))
|
|
||||||
(merge {:viewed-tutorial? false
|
(merge {:viewed-tutorial? false
|
||||||
:viewed-walkthrough? false
|
:viewed-walkthrough? false
|
||||||
:nudge {:big 10 :small 1}
|
:nudge {:big 10 :small 1}
|
||||||
@ -369,11 +371,12 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
(throw cause))))))
|
(throw cause))))))
|
||||||
|
|
||||||
|
|
||||||
(defn create-profile-rels
|
(defn create-profile-rels
|
||||||
[conn {:keys [id] :as profile}]
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as profile}]
|
||||||
|
(assert (db/connection-map? cfg)
|
||||||
|
"expected cfg with valid connection")
|
||||||
(let [features (cfeat/get-enabled-features cf/flags)
|
(let [features (cfeat/get-enabled-features cf/flags)
|
||||||
team (teams/create-team conn
|
team (teams/create-team cfg
|
||||||
{:profile-id id
|
{:profile-id id
|
||||||
:name "Default"
|
:name "Default"
|
||||||
:features features
|
:features features
|
||||||
@ -409,7 +412,9 @@
|
|||||||
(defn register-profile
|
(defn register-profile
|
||||||
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
||||||
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
||||||
params (into claims params)
|
params (cond-> claims
|
||||||
|
(:accept-newsletter-updates params)
|
||||||
|
(update :props assoc :newsletter-updates true))
|
||||||
|
|
||||||
profile (if-let [profile-id (:profile-id claims)]
|
profile (if-let [profile-id (:profile-id claims)]
|
||||||
(profile/get-profile conn profile-id)
|
(profile/get-profile conn profile-id)
|
||||||
@ -426,7 +431,7 @@
|
|||||||
(assoc :is-active is-active)
|
(assoc :is-active is-active)
|
||||||
(update :password auth/derive-password))
|
(update :password auth/derive-password))
|
||||||
profile (->> (create-profile cfg params)
|
profile (->> (create-profile cfg params)
|
||||||
(create-profile-rels conn))]
|
(create-profile-rels cfg))]
|
||||||
(vary-meta profile assoc :created true))))
|
(vary-meta profile assoc :created true))))
|
||||||
|
|
||||||
created? (-> profile meta :created true?)
|
created? (-> profile meta :created true?)
|
||||||
@ -443,6 +448,7 @@
|
|||||||
(when (:create-welcome-file params)
|
(when (:create-welcome-file params)
|
||||||
(let [cfg (dissoc cfg ::db/conn)]
|
(let [cfg (dissoc cfg ::db/conn)]
|
||||||
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
;; When profile is blocked, we just ignore it and return plain data
|
;; When profile is blocked, we just ignore it and return plain data
|
||||||
(:is-blocked profile)
|
(:is-blocked profile)
|
||||||
@ -450,7 +456,8 @@
|
|||||||
(l/wrn :hint "register attempt for already blocked profile"
|
(l/wrn :hint "register attempt for already blocked profile"
|
||||||
:profile-id (str (:id profile))
|
:profile-id (str (:id profile))
|
||||||
:profile-email (:email profile))
|
:profile-email (:email profile))
|
||||||
(rph/with-meta {:email (:email profile)}
|
(rph/with-meta {:id (:id profile)
|
||||||
|
:email (:email profile)}
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
::audit/context {:action "ignore-because-blocked"}
|
::audit/context {:action "ignore-because-blocked"}
|
||||||
::audit/profile-id (:id profile)
|
::audit/profile-id (:id profile)
|
||||||
@ -466,7 +473,9 @@
|
|||||||
(:member-email invitation)))
|
(:member-email invitation)))
|
||||||
(let [invitation (assoc invitation :member-id (:id profile))
|
(let [invitation (assoc invitation :member-id (:id profile))
|
||||||
token (tokens/generate cfg invitation)]
|
token (tokens/generate cfg invitation)]
|
||||||
(-> {:invitation-token token}
|
(-> {:id (:id profile)
|
||||||
|
:email (:email profile)
|
||||||
|
:invitation-token token}
|
||||||
(rph/with-transform (session/create-fn cfg profile claims))
|
(rph/with-transform (session/create-fn cfg profile claims))
|
||||||
(rph/with-meta {::audit/replace-props props
|
(rph/with-meta {::audit/replace-props props
|
||||||
::audit/context {:action "accept-invitation"}
|
::audit/context {:action "accept-invitation"}
|
||||||
@ -489,7 +498,8 @@
|
|||||||
(when-not (eml/has-reports? conn (:email profile))
|
(when-not (eml/has-reports? conn (:email profile))
|
||||||
(send-email-verification! cfg profile))
|
(send-email-verification! cfg profile))
|
||||||
|
|
||||||
(-> {:email (:email profile)}
|
(-> {:id (:id profile)
|
||||||
|
:email (:email profile)}
|
||||||
(rph/with-defer create-welcome-file-when-needed)
|
(rph/with-defer create-welcome-file-when-needed)
|
||||||
(rph/with-meta
|
(rph/with-meta
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
@ -516,7 +526,8 @@
|
|||||||
{:id (:id profile)})
|
{:id (:id profile)})
|
||||||
(send-email-verification! cfg profile))
|
(send-email-verification! cfg profile))
|
||||||
|
|
||||||
(rph/with-meta {:email (:email profile)}
|
(rph/with-meta {:email (:email profile)
|
||||||
|
:id (:id profile)}
|
||||||
{::audit/replace-props (audit/profile->props profile)
|
{::audit/replace-props (audit/profile->props profile)
|
||||||
::audit/context {:action action}
|
::audit/context {:action action}
|
||||||
::audit/profile-id (:id profile)
|
::audit/profile-id (:id profile)
|
||||||
@ -524,7 +535,8 @@
|
|||||||
|
|
||||||
(def schema:register-profile
|
(def schema:register-profile
|
||||||
[:map {:title "register-profile"}
|
[:map {:title "register-profile"}
|
||||||
[:token schema:token]])
|
[:token schema:token]
|
||||||
|
[:accept-newsletter-updates {:optional true} :boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::register-profile
|
(sv/defmethod ::register-profile
|
||||||
{::rpc/auth false
|
{::rpc/auth false
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
|
[app.rpc.commands.media :as media-cmd]
|
||||||
[app.rpc.commands.projects :as projects]
|
[app.rpc.commands.projects :as projects]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
@ -80,20 +81,33 @@
|
|||||||
;; --- Command: import-binfile
|
;; --- Command: import-binfile
|
||||||
|
|
||||||
(defn- import-binfile
|
(defn- import-binfile
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}]
|
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}]
|
||||||
(let [team (teams/get-team pool
|
(let [team
|
||||||
:profile-id profile-id
|
(teams/get-team pool
|
||||||
:project-id project-id)
|
:profile-id profile-id
|
||||||
cfg (-> cfg
|
:project-id project-id)
|
||||||
(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/input (:path file)))
|
|
||||||
|
|
||||||
result (case (int version)
|
cfg
|
||||||
1 (bf.v1/import-files! cfg)
|
(-> cfg
|
||||||
3 (bf.v3/import-files! cfg))]
|
(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))
|
||||||
|
|
||||||
|
input-path (:path file)
|
||||||
|
owned? (some? upload-id)
|
||||||
|
|
||||||
|
cfg
|
||||||
|
(assoc cfg ::bfc/input input-path)
|
||||||
|
|
||||||
|
result
|
||||||
|
(try
|
||||||
|
(case (int version)
|
||||||
|
1 (bf.v1/import-files! cfg)
|
||||||
|
3 (bf.v3/import-files! cfg))
|
||||||
|
(finally
|
||||||
|
(when owned?
|
||||||
|
(fs/delete input-path))))]
|
||||||
|
|
||||||
(db/update! pool :project
|
(db/update! pool :project
|
||||||
{:modified-at (ct/now)}
|
{:modified-at (ct/now)}
|
||||||
@ -103,13 +117,18 @@
|
|||||||
result))
|
result))
|
||||||
|
|
||||||
(def ^:private schema:import-binfile
|
(def ^:private schema:import-binfile
|
||||||
[:map {:title "import-binfile"}
|
[:and
|
||||||
[:name [:or [:string {:max 250}]
|
[:map {:title "import-binfile"}
|
||||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
[:name [:or [:string {:max 250}]
|
||||||
[:project-id ::sm/uuid]
|
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||||
[:file-id {:optional true} ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:version {:optional true} ::sm/int]
|
[:file-id {:optional true} ::sm/uuid]
|
||||||
[:file media/schema:upload]])
|
[:version {:optional true} ::sm/int]
|
||||||
|
[:file {:optional true} media/schema:upload]
|
||||||
|
[:upload-id {:optional true} ::sm/uuid]]
|
||||||
|
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||||
|
(fn [{:keys [file upload-id]}]
|
||||||
|
(or (some? file) (some? upload-id)))]])
|
||||||
|
|
||||||
(sv/defmethod ::import-binfile
|
(sv/defmethod ::import-binfile
|
||||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
"Import a penpot file in a binary format. If `file-id` is provided,
|
||||||
@ -117,28 +136,40 @@
|
|||||||
|
|
||||||
The in-place imports are only supported for binfile-v3 and when a
|
The in-place imports are only supported for binfile-v3 and when a
|
||||||
.penpot file only contains one penpot file.
|
.penpot file only contains one penpot file.
|
||||||
|
|
||||||
|
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/added "1.15"
|
||||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
::doc/changes ["1.20" "Add file-id param for in-place import"
|
||||||
"1.20" "Set default version to 3"]
|
"1.20" "Set default version to 3"
|
||||||
|
"2.15" "Add upload-id param for chunked upload support"]
|
||||||
|
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sse/stream? true
|
::sse/stream? true
|
||||||
::sm/params schema:import-binfile}
|
::sm/params schema:import-binfile}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
|
||||||
(projects/check-edition-permissions! pool profile-id project-id)
|
(projects/check-edition-permissions! pool profile-id project-id)
|
||||||
(let [version (or version 3)
|
(let [version (or version 3)
|
||||||
params (-> params
|
params (-> params
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :version version))
|
(assoc :version version))
|
||||||
|
|
||||||
cfg (cond-> cfg
|
cfg (cond-> cfg
|
||||||
(uuid? file-id)
|
(uuid? file-id)
|
||||||
(assoc ::bfc/file-id file-id))
|
(assoc ::bfc/file-id file-id))
|
||||||
|
|
||||||
manifest (case (int version)
|
params
|
||||||
1 nil
|
(if (some? upload-id)
|
||||||
3 (bf.v3/get-manifest (:path file)))]
|
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
||||||
|
(assoc params :file file))
|
||||||
|
params)
|
||||||
|
|
||||||
|
manifest
|
||||||
|
(case (int version)
|
||||||
|
1 nil
|
||||||
|
3 (bf.v3/get-manifest (-> params :file :path)))]
|
||||||
|
|
||||||
(with-meta
|
(with-meta
|
||||||
(sse/response (partial import-binfile cfg params))
|
(sse/response (partial import-binfile cfg params))
|
||||||
|
|||||||
@ -49,9 +49,9 @@
|
|||||||
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
||||||
:password (derive-password password)
|
:password (derive-password password)
|
||||||
:props {}}
|
:props {}}
|
||||||
profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
profile (db/tx-run! cfg (fn [cfg]
|
||||||
(->> (auth/create-profile cfg params)
|
(->> (auth/create-profile cfg params)
|
||||||
(auth/create-profile-rels conn))))]
|
(auth/create-profile-rels cfg))))]
|
||||||
(with-meta {:email email
|
(with-meta {:email email
|
||||||
:password password}
|
:password password}
|
||||||
{::audit/profile-id (:id profile)})))
|
{::audit/profile-id (:id profile)})))
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
[app.common.files.helpers :as cfh]
|
[app.common.files.helpers :as cfh]
|
||||||
[app.common.files.migrations :as fmg]
|
[app.common.files.migrations :as fmg]
|
||||||
|
[app.common.files.stats :as cfs]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.schema.desc-js-like :as-alias smdj]
|
[app.common.schema.desc-js-like :as-alias smdj]
|
||||||
@ -606,6 +607,76 @@
|
|||||||
(get-file-summary cfg id))
|
(get-file-summary cfg id))
|
||||||
|
|
||||||
|
|
||||||
|
;; --- COMMAND QUERY: get-file-stats
|
||||||
|
|
||||||
|
(def ^:private sql:file-stats-library-counts
|
||||||
|
"SELECT
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM file_library_rel AS flr
|
||||||
|
JOIN file AS fl ON (fl.id = flr.library_file_id)
|
||||||
|
WHERE flr.file_id = ?::uuid
|
||||||
|
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM file_library_rel AS flr
|
||||||
|
JOIN file AS fl ON (fl.id = flr.file_id)
|
||||||
|
WHERE flr.library_file_id = ?::uuid
|
||||||
|
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")
|
||||||
|
|
||||||
|
(defn- get-file-stats-library-counts
|
||||||
|
[conn file-id]
|
||||||
|
(let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
|
||||||
|
{:library-count (or (:library-count row) 0)
|
||||||
|
:referenced-by-count (or (:referenced-by-count row) 0)}))
|
||||||
|
|
||||||
|
(defn- get-file-stats
|
||||||
|
[{:keys [::db/conn] :as cfg} file-id]
|
||||||
|
(let [file (bfc/get-file cfg file-id)
|
||||||
|
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
||||||
|
(cfs/calc-file-stats (:data file)))
|
||||||
|
lib-cnt (get-file-stats-library-counts conn file-id)]
|
||||||
|
(-> base
|
||||||
|
(merge lib-cnt)
|
||||||
|
(assoc :file-id file-id
|
||||||
|
:revn (:revn file)
|
||||||
|
:updated-at (:modified-at file)))))
|
||||||
|
|
||||||
|
(def ^:private schema:shape-counts
|
||||||
|
[:map {:title "FileStatsShapeCounts"}
|
||||||
|
[:total [::sm/int {:min 0}]]
|
||||||
|
[:by-type [:map-of :keyword [::sm/int {:min 0}]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-file-stats-result
|
||||||
|
[:map {:title "FileStats"}
|
||||||
|
[:file-id ::sm/uuid]
|
||||||
|
[:page-count [::sm/int {:min 0}]]
|
||||||
|
[:shape-counts schema:shape-counts]
|
||||||
|
[:component-count [::sm/int {:min 0}]]
|
||||||
|
[:deleted-component-count [::sm/int {:min 0}]]
|
||||||
|
[:color-count [::sm/int {:min 0}]]
|
||||||
|
[:typography-count [::sm/int {:min 0}]]
|
||||||
|
[:library-count [::sm/int {:min 0}]]
|
||||||
|
[:referenced-by-count [::sm/int {:min 0}]]
|
||||||
|
[:revn [::sm/int {:min 0}]]
|
||||||
|
[:updated-at ::ct/inst]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-file-stats
|
||||||
|
[:map {:title "get-file-stats"}
|
||||||
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-file-stats
|
||||||
|
"Return aggregate statistics for a single file: page count, shape
|
||||||
|
counts by type, component/color/typography counts, and inbound and
|
||||||
|
outbound library reference counts. Cheap alternative to `get-file`
|
||||||
|
when only metrics are needed."
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:get-file-stats
|
||||||
|
::sm/result schema:get-file-stats-result
|
||||||
|
::db/transaction true}
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||||
|
(check-read-permissions! conn profile-id id)
|
||||||
|
(get-file-stats cfg id))
|
||||||
|
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-file-libraries
|
;; --- COMMAND QUERY: get-file-libraries
|
||||||
|
|
||||||
(def ^:private schema:get-file-libraries
|
(def ^:private schema:get-file-libraries
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.binfile.common :as bfc]
|
[app.binfile.common :as bfc]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.features :as-alias cfeat]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
@ -35,6 +36,43 @@
|
|||||||
(files/check-read-permissions! conn profile-id file-id)
|
(files/check-read-permissions! conn profile-id file-id)
|
||||||
(fsnap/get-visible-snapshots conn file-id))))
|
(fsnap/get-visible-snapshots conn file-id))))
|
||||||
|
|
||||||
|
;; --- COMMAND QUERY: get-file-snapshot
|
||||||
|
|
||||||
|
(def ^:private schema:get-file-snapshot
|
||||||
|
[:map {:title "get-file-snapshot"}
|
||||||
|
[:file-id ::sm/uuid]
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:features {:optional true} ::cfeat/features]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-file-snapshot
|
||||||
|
"Retrieve a file bundle with data from a specific snapshot for
|
||||||
|
read-only preview. Does not modify any database state."
|
||||||
|
{::doc/added "2.16"
|
||||||
|
::sm/params schema:get-file-snapshot
|
||||||
|
::sm/result files/schema:file-with-permissions
|
||||||
|
::db/transaction true}
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
||||||
|
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
|
||||||
|
(files/check-read-permissions! perms)
|
||||||
|
(let [snapshot (fsnap/get-snapshot-data cfg file-id id)]
|
||||||
|
(when-not snapshot
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :snapshot-not-found
|
||||||
|
:hint "unable to find snapshot with the provided id"
|
||||||
|
:snapshot-id id
|
||||||
|
:file-id file-id))
|
||||||
|
;; Load current file metadata only (no data decoding) then overlay
|
||||||
|
;; the snapshot data so the client receives the same shape as a
|
||||||
|
;; normal get-file response but with historical page/object content.
|
||||||
|
(let [base-file (bfc/get-file cfg file-id :load-data? false)]
|
||||||
|
(-> base-file
|
||||||
|
(assoc :data (:data snapshot))
|
||||||
|
(assoc :version (:version snapshot))
|
||||||
|
(assoc :features (:features snapshot))
|
||||||
|
(assoc :revn (:revn snapshot))
|
||||||
|
(assoc :vern (rand-int 100000))
|
||||||
|
(assoc :permissions perms))))))
|
||||||
|
|
||||||
(def ^:private schema:create-file-snapshot
|
(def ^:private schema:create-file-snapshot
|
||||||
[:map
|
[:map
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
@ -71,7 +109,7 @@
|
|||||||
{::doc/added "1.20"
|
{::doc/added "1.20"
|
||||||
::sm/params schema:restore-file-snapshot
|
::sm/params schema:restore-file-snapshot
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
|
||||||
(files/check-edition-permissions! conn profile-id file-id)
|
(files/check-edition-permissions! conn profile-id file-id)
|
||||||
(let [file (bfc/get-file cfg file-id)
|
(let [file (bfc/get-file cfg file-id)
|
||||||
team (teams/get-team conn
|
team (teams/get-team conn
|
||||||
@ -88,7 +126,8 @@
|
|||||||
;; Send to the clients a notification to reload the file
|
;; Send to the clients a notification to reload the file
|
||||||
(mbus/pub! msgbus
|
(mbus/pub! msgbus
|
||||||
:topic (:id file)
|
:topic (:id file)
|
||||||
:message {:type :file-restore
|
:message {:type :file-restored
|
||||||
|
:session-id session-id
|
||||||
:file-id (:id file)
|
:file-id (:id file)
|
||||||
:vern vern})
|
:vern vern})
|
||||||
nil)))
|
nil)))
|
||||||
|
|||||||
@ -9,12 +9,14 @@
|
|||||||
[app.binfile.common :as bfc]
|
[app.binfile.common :as bfc]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cmedia]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
[app.features.logical-deletion :as ldel]
|
[app.features.logical-deletion :as ldel]
|
||||||
|
[app.http :as-alias http]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
@ -34,7 +36,9 @@
|
|||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
java.io.OutputStream
|
java.io.OutputStream
|
||||||
java.io.SequenceInputStream
|
java.io.SequenceInputStream
|
||||||
java.util.Collections))
|
java.util.Collections
|
||||||
|
java.util.zip.ZipEntry
|
||||||
|
java.util.zip.ZipOutputStream))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
|
|
||||||
@ -296,3 +300,98 @@
|
|||||||
(rph/with-meta (rph/wrap)
|
(rph/with-meta (rph/wrap)
|
||||||
{::audit/props {:font-family (:font-family variant)
|
{::audit/props {:font-family (:font-family variant)
|
||||||
:font-id (:font-id variant)}})))
|
:font-id (:font-id variant)}})))
|
||||||
|
|
||||||
|
;; --- DOWNLOAD FONT
|
||||||
|
|
||||||
|
(defn- make-temporal-storage-object
|
||||||
|
[cfg profile-id content]
|
||||||
|
(let [storage (sto/resolve cfg)
|
||||||
|
content (media/check-input content)
|
||||||
|
hash (sto/calculate-hash (:path content))
|
||||||
|
data (-> (sto/content (:path content))
|
||||||
|
(sto/wrap-with-hash hash))
|
||||||
|
mtype (:mtype content "application/octet-stream")
|
||||||
|
content {::sto/content data
|
||||||
|
::sto/deduplicate? true
|
||||||
|
::sto/touched-at (ct/in-future {:minutes 30})
|
||||||
|
:profile-id profile-id
|
||||||
|
:content-type mtype
|
||||||
|
:bucket "tempfile"}]
|
||||||
|
|
||||||
|
(sto/put-object! storage content)))
|
||||||
|
|
||||||
|
(defn- make-variant-filename
|
||||||
|
[v mtype]
|
||||||
|
(str (:font-family v) "-" (:font-weight v)
|
||||||
|
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
|
||||||
|
(cmedia/mtype->extension mtype)))
|
||||||
|
|
||||||
|
(def ^:private schema:download-font
|
||||||
|
[:map {:title "download-font"}
|
||||||
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::download-font
|
||||||
|
"Download the font file. Returns a http redirect to the asset resource uri."
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:download-font}
|
||||||
|
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||||
|
(let [variant (db/get pool :team-font-variant {:id id})]
|
||||||
|
(teams/check-read-permissions! pool profile-id (:team-id variant))
|
||||||
|
|
||||||
|
;; Try to get the best available font format (prefer TTF for broader compatibility).
|
||||||
|
(let [media-id (or (:ttf-file-id variant)
|
||||||
|
(:otf-file-id variant)
|
||||||
|
(:woff2-file-id variant)
|
||||||
|
(:woff1-file-id variant))
|
||||||
|
sobj (sto/get-object storage media-id)
|
||||||
|
mtype (-> sobj meta :content-type)]
|
||||||
|
|
||||||
|
{:id (:id sobj)
|
||||||
|
:uri (files/resolve-public-uri (:id sobj))
|
||||||
|
:name (make-variant-filename variant mtype)})))
|
||||||
|
|
||||||
|
(def ^:private schema:download-font-family
|
||||||
|
[:map {:title "download-font-family"}
|
||||||
|
[:font-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::download-font-family
|
||||||
|
"Download the entire font family as a zip file. Returns the zip
|
||||||
|
bytes on the body, without encoding it on transit or json."
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:download-font-family}
|
||||||
|
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}]
|
||||||
|
(let [variants (db/query pool :team-font-variant
|
||||||
|
{:font-id font-id
|
||||||
|
:deleted-at nil})]
|
||||||
|
|
||||||
|
(when-not (seq variants)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found))
|
||||||
|
|
||||||
|
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
|
||||||
|
|
||||||
|
(let [tempfile (tmp/tempfile :suffix ".zip")
|
||||||
|
ffamily (-> variants first :font-family)]
|
||||||
|
|
||||||
|
(with-open [^OutputStream output (io/output-stream tempfile)
|
||||||
|
^OutputStream output (ZipOutputStream. output)]
|
||||||
|
(doseq [v variants]
|
||||||
|
(let [media-id (or (:ttf-file-id v)
|
||||||
|
(:otf-file-id v)
|
||||||
|
(:woff2-file-id v)
|
||||||
|
(:woff1-file-id v))
|
||||||
|
sobj (sto/get-object storage media-id)
|
||||||
|
mtype (-> sobj meta :content-type)
|
||||||
|
name (make-variant-filename v mtype)]
|
||||||
|
|
||||||
|
(with-open [input (sto/get-object-data storage sobj)]
|
||||||
|
(.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name))
|
||||||
|
(io/copy input output :size (:size sobj))
|
||||||
|
(.closeEntry ^ZipOutputStream output)))))
|
||||||
|
|
||||||
|
(let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id
|
||||||
|
{:mtype "application/zip"
|
||||||
|
:path tempfile})]
|
||||||
|
{:id id
|
||||||
|
:uri (files/resolve-public-uri id)
|
||||||
|
:name (str ffamily ".zip")}))))
|
||||||
|
|||||||
@ -84,5 +84,5 @@
|
|||||||
(profile/get-profile-by-email conn))
|
(profile/get-profile-by-email conn))
|
||||||
(->> (assoc info :is-active true :is-demo false)
|
(->> (assoc info :is-active true :is-demo false)
|
||||||
(auth/create-profile cfg)
|
(auth/create-profile cfg)
|
||||||
(auth/create-profile-rels conn)
|
(auth/create-profile-rels cfg)
|
||||||
(profile/strip-private-attrs))))))
|
(profile/strip-private-attrs))))))
|
||||||
|
|||||||
@ -207,8 +207,7 @@
|
|||||||
(update :team-id bfc/lookup-index)
|
(update :team-id bfc/lookup-index)
|
||||||
(assoc :created-at timestamp)
|
(assoc :created-at timestamp)
|
||||||
(assoc :modified-at timestamp))]
|
(assoc :modified-at timestamp))]
|
||||||
(db/insert! conn :team-profile-rel params
|
(teams/add-profile-to-team! cfg params {::db/return-keys false})))
|
||||||
{::db/return-keys false})))
|
|
||||||
|
|
||||||
;; Duplicate team fonts
|
;; Duplicate team fonts
|
||||||
(doseq [font fonts]
|
(doseq [font fonts]
|
||||||
@ -339,6 +338,21 @@
|
|||||||
;; --- COMMAND: Move project
|
;; --- COMMAND: Move project
|
||||||
|
|
||||||
(defn move-project
|
(defn move-project
|
||||||
|
"Moves a project from one team to another.
|
||||||
|
|
||||||
|
Performs comprehensive validation including:
|
||||||
|
- Permission checks on both source and destination teams
|
||||||
|
- Team compatibility verification between source and destination
|
||||||
|
- File features compatibility with destination team
|
||||||
|
|
||||||
|
The operation also:
|
||||||
|
- Updates the project's team assignment
|
||||||
|
- Cleans up any broken library relations after the move
|
||||||
|
|
||||||
|
Throws:
|
||||||
|
- :cant-move-to-same-team if trying to move project to its current team
|
||||||
|
- Permission exceptions if user lacks required permissions
|
||||||
|
- Team compatibility exceptions if teams are incompatible"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}]
|
||||||
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})
|
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})
|
||||||
pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]})
|
pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]})
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
(ns app.rpc.commands.media
|
(ns app.rpc.commands.media
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
@ -17,8 +19,13 @@
|
|||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
|
[app.rpc.quotes :as quotes]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.util.services :as sv]))
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.services :as sv]
|
||||||
|
[datoteka.io :as io])
|
||||||
|
(:import
|
||||||
|
java.io.OutputStream))
|
||||||
|
|
||||||
(def thumbnail-options
|
(def thumbnail-options
|
||||||
{:width 100
|
{:width 100
|
||||||
@ -236,3 +243,182 @@
|
|||||||
:width (:width mobj)
|
:width (:width mobj)
|
||||||
:height (:height mobj)
|
:height (:height mobj)
|
||||||
:mtype (:mtype mobj)})))
|
:mtype (:mtype mobj)})))
|
||||||
|
|
||||||
|
;; --- Chunked Upload: Create an upload session
|
||||||
|
|
||||||
|
(def ^:private schema:create-upload-session
|
||||||
|
[:map {:title "create-upload-session"}
|
||||||
|
[:total-chunks ::sm/int]])
|
||||||
|
|
||||||
|
(def ^:private schema:create-upload-session-result
|
||||||
|
[:map {:title "create-upload-session-result"}
|
||||||
|
[:session-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::create-upload-session
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:create-upload-session
|
||||||
|
::sm/result schema:create-upload-session-result}
|
||||||
|
[{:keys [::db/pool] :as cfg}
|
||||||
|
{:keys [::rpc/profile-id total-chunks]}]
|
||||||
|
|
||||||
|
(let [max-chunks (cf/get :quotes-upload-chunks-per-session)]
|
||||||
|
(when (> total-chunks max-chunks)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :max-quote-reached
|
||||||
|
:target "upload-chunks-per-session"
|
||||||
|
:quote max-chunks
|
||||||
|
:count total-chunks)))
|
||||||
|
|
||||||
|
(quotes/check! cfg {::quotes/id ::quotes/upload-sessions-per-profile
|
||||||
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
|
(let [session-id (uuid/next)]
|
||||||
|
(db/insert! pool :upload-session
|
||||||
|
{:id session-id
|
||||||
|
:profile-id profile-id
|
||||||
|
:total-chunks total-chunks})
|
||||||
|
{:session-id session-id}))
|
||||||
|
|
||||||
|
;; --- Chunked Upload: Upload a single chunk
|
||||||
|
|
||||||
|
(def ^:private schema:upload-chunk
|
||||||
|
[:map {:title "upload-chunk"}
|
||||||
|
[:session-id ::sm/uuid]
|
||||||
|
[:index ::sm/int]
|
||||||
|
[:content media/schema:upload]])
|
||||||
|
|
||||||
|
(def ^:private schema:upload-chunk-result
|
||||||
|
[:map {:title "upload-chunk-result"}
|
||||||
|
[:session-id ::sm/uuid]
|
||||||
|
[:index ::sm/int]])
|
||||||
|
|
||||||
|
(sv/defmethod ::upload-chunk
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:upload-chunk
|
||||||
|
::sm/result schema:upload-chunk-result}
|
||||||
|
[{:keys [::db/pool] :as cfg}
|
||||||
|
{:keys [::rpc/profile-id session-id index content] :as _params}]
|
||||||
|
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
|
||||||
|
(when (or (neg? index) (>= index (:total-chunks session)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-chunk-index
|
||||||
|
:hint "chunk index is out of range for this session"
|
||||||
|
:session-id session-id
|
||||||
|
:total-chunks (:total-chunks session)
|
||||||
|
:index index)))
|
||||||
|
|
||||||
|
(let [storage (sto/resolve cfg)
|
||||||
|
data (sto/content (:path content))]
|
||||||
|
(sto/put-object! storage
|
||||||
|
{::sto/content data
|
||||||
|
::sto/deduplicate? false
|
||||||
|
::sto/touch true
|
||||||
|
:content-type (:mtype content)
|
||||||
|
:bucket "tempfile"
|
||||||
|
:upload-id (str session-id)
|
||||||
|
:chunk-index index}))
|
||||||
|
|
||||||
|
{:session-id session-id
|
||||||
|
:index index})
|
||||||
|
|
||||||
|
;; --- Chunked Upload: shared helpers
|
||||||
|
|
||||||
|
(def ^:private sql:get-upload-chunks
|
||||||
|
"SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index
|
||||||
|
FROM storage_object
|
||||||
|
WHERE (metadata->>'~:upload-id') = ?::text
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY (metadata->>'~:chunk-index')::integer ASC")
|
||||||
|
|
||||||
|
(defn- get-upload-chunks
|
||||||
|
[conn session-id]
|
||||||
|
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
|
||||||
|
|
||||||
|
(defn- concat-chunks
|
||||||
|
"Reads all chunk storage objects in order and writes them to a single
|
||||||
|
temporary file on the local filesystem. Returns a path to that file."
|
||||||
|
[storage chunks]
|
||||||
|
(let [tmp (tmp/tempfile :prefix "penpot.chunked-upload.")]
|
||||||
|
(with-open [^OutputStream out (io/output-stream tmp)]
|
||||||
|
(doseq [{:keys [id]} chunks]
|
||||||
|
(let [sobj (sto/get-object storage id)
|
||||||
|
bytes (sto/get-object-bytes storage sobj)]
|
||||||
|
(.write out ^bytes bytes))))
|
||||||
|
tmp))
|
||||||
|
|
||||||
|
(defn assemble-chunks
|
||||||
|
"Validates that all expected chunks are present for `session-id` and
|
||||||
|
concatenates them into a single temporary file. Returns a map
|
||||||
|
conforming to `media/schema:upload` with `:filename`, `:path` and
|
||||||
|
`:size`.
|
||||||
|
|
||||||
|
Raises a :validation/:missing-chunks error when the number of stored
|
||||||
|
chunks does not match `:total-chunks` recorded in the session row.
|
||||||
|
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})
|
||||||
|
chunks (get-upload-chunks conn session-id)]
|
||||||
|
|
||||||
|
(when (not= (count chunks) (:total-chunks session))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :missing-chunks
|
||||||
|
:hint "number of stored chunks does not match expected total"
|
||||||
|
:session-id session-id
|
||||||
|
:expected (:total-chunks session)
|
||||||
|
:found (count chunks)))
|
||||||
|
|
||||||
|
(let [storage (sto/resolve cfg ::db/reuse-conn true)
|
||||||
|
path (concat-chunks storage chunks)
|
||||||
|
size (reduce #(+ %1 (:size %2)) 0 chunks)]
|
||||||
|
|
||||||
|
(db/delete! conn :upload-session {:id session-id})
|
||||||
|
|
||||||
|
{:filename "upload"
|
||||||
|
:path path
|
||||||
|
:size size})))
|
||||||
|
|
||||||
|
;; --- Chunked Upload: Assemble all chunks into a final media object
|
||||||
|
|
||||||
|
(def ^:private schema:assemble-file-media-object
|
||||||
|
[:map {:title "assemble-file-media-object"}
|
||||||
|
[:session-id ::sm/uuid]
|
||||||
|
[:file-id ::sm/uuid]
|
||||||
|
[:is-local ::sm/boolean]
|
||||||
|
[:name [:string {:max 250}]]
|
||||||
|
[:mtype :string]
|
||||||
|
[:id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::assemble-file-media-object
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:assemble-file-media-object
|
||||||
|
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||||
|
[:process-image/global]]}
|
||||||
|
[{:keys [::db/pool] :as cfg}
|
||||||
|
{:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}]
|
||||||
|
(files/check-edition-permissions! pool profile-id file-id)
|
||||||
|
|
||||||
|
(db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
|
(let [{:keys [path size]} (assemble-chunks cfg session-id)
|
||||||
|
content {:filename "upload"
|
||||||
|
:size size
|
||||||
|
:path path
|
||||||
|
:mtype mtype}
|
||||||
|
_ (media/validate-media-type! content)
|
||||||
|
mobj (create-file-media-object cfg (assoc params
|
||||||
|
:id (or id (uuid/next))
|
||||||
|
:content content))]
|
||||||
|
|
||||||
|
(db/update! conn :file
|
||||||
|
{:modified-at (ct/now)
|
||||||
|
:has-media-trimmed false}
|
||||||
|
{:id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
|
(with-meta mobj
|
||||||
|
{::audit/replace-props
|
||||||
|
{:name name
|
||||||
|
:file-id file-id
|
||||||
|
:is-local is-local
|
||||||
|
:mtype mtype}})))))
|
||||||
|
|
||||||
|
|||||||
283
backend/src/app/rpc/commands/nitrate.clj
Normal file
283
backend/src/app/rpc/commands/nitrate.clj
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.rpc.commands.nitrate
|
||||||
|
"Nitrate API for Penpot. Provides nitrate-related endpoints to be called
|
||||||
|
from Penpot frontend."
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
|
[app.rpc.doc :as-alias doc]
|
||||||
|
[app.rpc.notifications :as notifications]
|
||||||
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
|
|
||||||
|
(defn assert-is-owner [cfg profile-id team-id]
|
||||||
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
|
(when-not (:is-owner perms)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :insufficient-permissions))))
|
||||||
|
|
||||||
|
(defn assert-not-default-team [cfg team-id]
|
||||||
|
(let [team (teams/get-team-info cfg {:id team-id})]
|
||||||
|
(when (:is-default team)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :cant-move-default-team))))
|
||||||
|
|
||||||
|
(defn assert-membership [cfg profile-id organization-id]
|
||||||
|
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id})]
|
||||||
|
(when-not (:organization-id membership)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :organization-doesnt-exists))
|
||||||
|
|
||||||
|
(when-not (:is-member membership)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :user-doesnt-belong-organization))))
|
||||||
|
|
||||||
|
|
||||||
|
(def schema:connectivity
|
||||||
|
[:map {:title "nitrate-connectivity"}
|
||||||
|
[:licenses ::sm/boolean]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-nitrate-connectivity
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.14"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result schema:connectivity}
|
||||||
|
[cfg _params]
|
||||||
|
(nitrate/call cfg :connectivity {}))
|
||||||
|
|
||||||
|
(def ^:private sql:prefix-team-name-and-unset-default
|
||||||
|
"UPDATE team
|
||||||
|
SET name = ? || name,
|
||||||
|
is_default = FALSE
|
||||||
|
WHERE id = ?;")
|
||||||
|
|
||||||
|
(def ^:private sql:get-member-teams-info
|
||||||
|
"SELECT t.id,
|
||||||
|
t.is_default,
|
||||||
|
tpr.is_owner,
|
||||||
|
(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members,
|
||||||
|
(SELECT array_agg(profile_id) FROM team_profile_rel WHERE team_id = t.id) AS member_ids
|
||||||
|
FROM team AS t
|
||||||
|
JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id)
|
||||||
|
WHERE tpr.profile_id = ?
|
||||||
|
AND t.id = ANY(?)
|
||||||
|
AND t.deleted_at IS NULL")
|
||||||
|
|
||||||
|
(def ^:private sql:get-team-files-count
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM file AS f
|
||||||
|
JOIN project AS p ON (p.id = f.project_id)
|
||||||
|
WHERE p.team_id = ?
|
||||||
|
AND f.deleted_at IS NULL")
|
||||||
|
|
||||||
|
(def ^:private schema:leave-org
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:default-team-id ::sm/uuid]
|
||||||
|
[:teams-to-delete
|
||||||
|
[:vector ::sm/uuid]]
|
||||||
|
[:teams-to-leave
|
||||||
|
[:vector
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
||||||
|
|
||||||
|
|
||||||
|
(defn- get-organization-teams-for-user
|
||||||
|
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
||||||
|
(let [org-team-ids (->> (:teams org-summary)
|
||||||
|
(map :id))
|
||||||
|
ids-array (db/create-array conn "uuid" org-team-ids)]
|
||||||
|
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
||||||
|
|
||||||
|
(defn- calculate-valid-teams
|
||||||
|
([org-teams default-team-id]
|
||||||
|
(let [;; valid default team is the one which id is default-team-id
|
||||||
|
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
|
||||||
|
|
||||||
|
;; Remove your-penpot for the rest of validations
|
||||||
|
org-teams (remove #(= default-team-id (:id %)) org-teams)
|
||||||
|
|
||||||
|
;; valid teams to delete are those that the user is owner, and only have one member
|
||||||
|
valid-teams-to-delete-ids (->> org-teams
|
||||||
|
(filter #(and (:is-owner %)
|
||||||
|
(= (:num-members %) 1)))
|
||||||
|
(map :id)
|
||||||
|
(into #{}))
|
||||||
|
;; valid teams to transfer are those that the user is owner, and have more than one member
|
||||||
|
valid-teams-to-transfer (->> org-teams
|
||||||
|
(filter #(and (:is-owner %)
|
||||||
|
(> (:num-members %) 1))))
|
||||||
|
|
||||||
|
;; valid teams to exit are those that the user isn't owner, and have more than one member
|
||||||
|
valid-teams-to-exit (->> org-teams
|
||||||
|
(filter #(and (not (:is-owner %))
|
||||||
|
(> (:num-members %) 1))))]
|
||||||
|
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
||||||
|
:valid-teams-to-transfer valid-teams-to-transfer
|
||||||
|
:valid-teams-to-exit valid-teams-to-exit
|
||||||
|
:valid-default-team valid-default-team})))
|
||||||
|
|
||||||
|
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
||||||
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
|
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
|
||||||
|
(calculate-valid-teams org-teams default-team-id)))
|
||||||
|
|
||||||
|
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
||||||
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
|
org-teams (get-organization-teams-for-user cfg org-summary profile-id)
|
||||||
|
{:keys [valid-teams-to-delete-ids
|
||||||
|
valid-teams-to-transfer
|
||||||
|
valid-teams-to-exit
|
||||||
|
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
valid-teams-to-exit-ids (->> valid-teams-to-exit (map :id) (into #{}))
|
||||||
|
valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{}))
|
||||||
|
valid-teams-to-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids)
|
||||||
|
|
||||||
|
valid-default-team-id? (some? valid-default-team)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete))
|
||||||
|
|
||||||
|
;; for every team in teams-to-leave, check that:
|
||||||
|
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
|
||||||
|
;; the reassign-to is a member of the team and not the current user;
|
||||||
|
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
|
||||||
|
teams-by-id (d/index-by :id org-teams)
|
||||||
|
valid-teams-to-leave? (and
|
||||||
|
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
||||||
|
(every? (fn [{:keys [id reassign-to]}]
|
||||||
|
(if reassign-to
|
||||||
|
(let [members (db/pgarray->set (:member-ids (get teams-by-id id)))]
|
||||||
|
(and (contains? valid-teams-to-transfer-ids id)
|
||||||
|
(not= reassign-to profile-id)
|
||||||
|
(contains? members reassign-to)))
|
||||||
|
(contains? valid-teams-to-exit-ids id)))
|
||||||
|
teams-to-leave))]
|
||||||
|
;; the org owner cannot leave
|
||||||
|
(when (= (:owner-id org-summary) profile-id)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :org-owner-cannot-leave))
|
||||||
|
|
||||||
|
(when (or
|
||||||
|
(not valid-teams-to-delete?)
|
||||||
|
(not valid-teams-to-leave?)
|
||||||
|
(not valid-default-team-id?))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-valid-teams))))
|
||||||
|
|
||||||
|
|
||||||
|
(defn leave-org
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}]
|
||||||
|
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
||||||
|
|
||||||
|
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
|
||||||
|
:total)
|
||||||
|
delete-default-team? (= default-team-files-count 0)]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; assert that the received teams are valid, checking the different constraints
|
||||||
|
(when-not skip-validation
|
||||||
|
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
||||||
|
|
||||||
|
(assert-membership cfg profile-id id)
|
||||||
|
|
||||||
|
;; delete the teams-to-delete
|
||||||
|
(doseq [id teams-to-delete]
|
||||||
|
(teams/delete-team cfg {:profile-id profile-id :team-id id}))
|
||||||
|
|
||||||
|
;; leave the teams-to-leave
|
||||||
|
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||||
|
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||||
|
|
||||||
|
;; Delete default-team-id if empty; otherwise keep it and prefix the name.
|
||||||
|
(if delete-default-team?
|
||||||
|
(do
|
||||||
|
(db/update! conn :team {:is-default false} {:id default-team-id})
|
||||||
|
(teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))
|
||||||
|
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]))
|
||||||
|
|
||||||
|
;; Api call to nitrate
|
||||||
|
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
||||||
|
|
||||||
|
nil))
|
||||||
|
|
||||||
|
|
||||||
|
(sv/defmethod ::leave-org
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.15"
|
||||||
|
::sm/params schema:leave-org
|
||||||
|
::db/transaction true}
|
||||||
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
|
(leave-org cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
|
||||||
|
(def ^:private schema:remove-team-from-org
|
||||||
|
[:map
|
||||||
|
[:team-id ::sm/uuid]
|
||||||
|
[:organization-id ::sm/uuid]
|
||||||
|
[:organization-name ::sm/text]])
|
||||||
|
|
||||||
|
(sv/defmethod ::remove-team-from-org
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:remove-team-from-org}
|
||||||
|
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||||
|
|
||||||
|
(assert-is-owner cfg profile-id team-id)
|
||||||
|
(assert-not-default-team cfg team-id)
|
||||||
|
(assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
|
;; Api call to nitrate
|
||||||
|
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
|
||||||
|
|
||||||
|
;; Notify connected users
|
||||||
|
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
||||||
|
nil)
|
||||||
|
|
||||||
|
|
||||||
|
(def ^:private schema:add-team-to-organization
|
||||||
|
[:map
|
||||||
|
[:team-id ::sm/uuid]
|
||||||
|
[:organization-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::add-team-to-organization
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.17"
|
||||||
|
::sm/params schema:add-team-to-organization
|
||||||
|
::db/transaction true}
|
||||||
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||||
|
|
||||||
|
(assert-is-owner cfg profile-id team-id)
|
||||||
|
(assert-not-default-team cfg team-id)
|
||||||
|
(assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
|
||||||
|
;; Add teammates to the org if needed
|
||||||
|
(doseq [{member-id :profile-id} team-members
|
||||||
|
:when (not= member-id profile-id)]
|
||||||
|
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
||||||
|
|
||||||
|
;; Api call to nitrate
|
||||||
|
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
||||||
|
|
||||||
|
;; Notify connected users
|
||||||
|
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||||
|
nil)
|
||||||
@ -48,6 +48,7 @@
|
|||||||
(def schema:props
|
(def schema:props
|
||||||
[:map {:title "ProfileProps"}
|
[:map {:title "ProfileProps"}
|
||||||
[:plugins {:optional true} schema:plugin-registry]
|
[:plugins {:optional true} schema:plugin-registry]
|
||||||
|
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||||
[:newsletter-news {:optional true} ::sm/boolean]
|
[:newsletter-news {:optional true} ::sm/boolean]
|
||||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||||
@ -313,6 +314,25 @@
|
|||||||
(climit/invoke! generate-thumbnail file))]
|
(climit/invoke! generate-thumbnail file))]
|
||||||
(sto/put-object! storage params)))
|
(sto/put-object! storage params)))
|
||||||
|
|
||||||
|
;; --- MUTATION: Delete Photo
|
||||||
|
|
||||||
|
(sv/defmethod ::delete-profile-photo
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result :nil
|
||||||
|
::db/transaction true}
|
||||||
|
[{:keys [::db/conn ::sto/storage]} {:keys [::rpc/profile-id]}]
|
||||||
|
(let [profile (get-profile conn profile-id ::db/for-update true)]
|
||||||
|
(when-let [id (:photo-id profile)]
|
||||||
|
(sto/touch-object! storage id))
|
||||||
|
|
||||||
|
(db/update! conn :profile
|
||||||
|
{:photo-id nil}
|
||||||
|
{:id profile-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
|
nil))
|
||||||
|
|
||||||
;; --- MUTATION: Request Email Change
|
;; --- MUTATION: Request Email Change
|
||||||
|
|
||||||
(declare ^:private request-email-change!)
|
(declare ^:private request-email-change!)
|
||||||
@ -461,6 +481,9 @@
|
|||||||
{:deleted-at deleted-at}
|
{:deleted-at deleted-at}
|
||||||
{:id profile-id})
|
{:id profile-id})
|
||||||
|
|
||||||
|
;; Api call to nitrate
|
||||||
|
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
|
||||||
|
|
||||||
;; Schedule cascade deletion to a worker
|
;; Schedule cascade deletion to a worker
|
||||||
(wrk/submit! {::db/conn conn
|
(wrk/submit! {::db/conn conn
|
||||||
::wrk/task :delete-object
|
::wrk/task :delete-object
|
||||||
|
|||||||
@ -193,7 +193,7 @@
|
|||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
(cond->> (get-teams conn profile-id)
|
(cond->> (get-teams conn profile-id)
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :nitrate)
|
||||||
(map #(nitrate/add-org-to-team cfg % params)))))
|
(map #(nitrate/add-org-info-to-team cfg % params)))))
|
||||||
|
|
||||||
(def ^:private sql:get-owned-teams
|
(def ^:private sql:get-owned-teams
|
||||||
"SELECT t.id, t.name,
|
"SELECT t.id, t.name,
|
||||||
@ -471,8 +471,8 @@
|
|||||||
;; --- COMMAND QUERY: get-team-info
|
;; --- COMMAND QUERY: get-team-info
|
||||||
|
|
||||||
(defn get-team-info
|
(defn get-team-info
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
|
[cfg {:keys [id] :as params}]
|
||||||
(-> (db/get* conn :team
|
(-> (db/get* cfg :team
|
||||||
{:id id}
|
{:id id}
|
||||||
{::sql/columns [:id :is-default :features]})
|
{::sql/columns [:id :is-default :features]})
|
||||||
(decode-row)))
|
(decode-row)))
|
||||||
@ -499,7 +499,9 @@
|
|||||||
[:map {:title "create-team"}
|
[:map {:title "create-team"}
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:features {:optional true} ::cfeat/features]
|
[:features {:optional true} ::cfeat/features]
|
||||||
[:id {:optional true} ::sm/uuid]])
|
[:id {:optional true} ::sm/uuid]
|
||||||
|
[:organization-id {:optional true} ::sm/uuid]
|
||||||
|
[:is-default {:optional true} :boolean]])
|
||||||
|
|
||||||
(sv/defmethod ::create-team
|
(sv/defmethod ::create-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
@ -520,17 +522,89 @@
|
|||||||
(with-meta team
|
(with-meta team
|
||||||
{::audit/props {:id (:id team)}})))
|
{::audit/props {:id (:id team)}})))
|
||||||
|
|
||||||
|
|
||||||
|
(defn create-default-org-team
|
||||||
|
[cfg profile-id organization-id]
|
||||||
|
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||||
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
|
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
||||||
|
(set/difference cfeat/frontend-only-features)
|
||||||
|
(set/difference cfeat/no-team-inheritable-features))
|
||||||
|
params {:profile-id profile-id
|
||||||
|
:name "Your Penpot"
|
||||||
|
:features features
|
||||||
|
:organization-id organization-id
|
||||||
|
:is-default true}
|
||||||
|
team (create-team cfg params)]
|
||||||
|
(select-keys team [:id])))
|
||||||
|
|
||||||
|
(defn initialize-user-in-nitrate-org
|
||||||
|
"If needed, create a default team for the user on the organization,
|
||||||
|
and notify Nitrate that an user has been added to an org."
|
||||||
|
([cfg profile-id organization-id]
|
||||||
|
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
|
||||||
|
([cfg profile-id organization-id email]
|
||||||
|
(assert (db/connection-map? cfg)
|
||||||
|
"expected cfg with valid connection")
|
||||||
|
(when (contains? cf/flags :nitrate)
|
||||||
|
(db/tx-run!
|
||||||
|
cfg
|
||||||
|
(fn [{:keys [::db/conn] :as tx-cfg}]
|
||||||
|
|
||||||
|
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id})]
|
||||||
|
;; Only when the user doesn't belong to the organization yet
|
||||||
|
(when (and
|
||||||
|
(some? (:organization-id membership)) ;; the organization exists
|
||||||
|
(not (:is-member membership))) ;; the user is not a member of the org yet
|
||||||
|
|
||||||
|
|
||||||
|
(let [organization-id organization-id
|
||||||
|
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||||
|
default-team-id (:id default-team)
|
||||||
|
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id
|
||||||
|
:team-id default-team-id
|
||||||
|
:organization-id organization-id}
|
||||||
|
(some? email) (assoc :email email)))]
|
||||||
|
(when (not (:is-member result))
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :failed-add-profile-org-nitrate
|
||||||
|
:context {:profile-id profile-id
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id default-team-id}))
|
||||||
|
default-team-id))))))))
|
||||||
|
|
||||||
|
(defn add-profile-to-team!
|
||||||
|
([cfg params]
|
||||||
|
(add-profile-to-team! cfg params nil))
|
||||||
|
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
|
||||||
|
(assert (db/connection-map? cfg)
|
||||||
|
"expected cfg with valid connection")
|
||||||
|
(when (contains? cf/flags :nitrate)
|
||||||
|
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||||
|
;; Only when the team belong to an organization and the user is not a member
|
||||||
|
(when (and
|
||||||
|
(some? (:organization-id membership)) ;; the team do belong to an organization
|
||||||
|
(not (:is-member membership))) ;; the user is not a member of the org yet
|
||||||
|
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
|
||||||
|
(db/insert! conn :team-profile-rel params options)))
|
||||||
|
|
||||||
(defn create-team
|
(defn create-team
|
||||||
"This is a complete team creation process, it creates the team
|
"This is a complete team creation process, it creates the team
|
||||||
object and all related objects (default role and default project)."
|
object and all related objects (default role and default project)."
|
||||||
[cfg-or-conn params]
|
[{:keys [::db/conn] :as cfg} params]
|
||||||
(let [conn (db/get-connection cfg-or-conn)
|
(assert (db/connection-map? cfg)
|
||||||
team (create-team* conn params)
|
"expected cfg with valid connection")
|
||||||
|
(let [team (create-team* conn params)
|
||||||
params (assoc params
|
params (assoc params
|
||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
:role :owner)
|
:role :owner)
|
||||||
project (create-team-default-project conn params)]
|
project (create-team-default-project conn params)]
|
||||||
(create-team-role conn params)
|
(create-team-role cfg params)
|
||||||
|
;; Set team organization in Nitrate if organization-id is provided
|
||||||
|
(when (and (contains? cf/flags :nitrate) (:organization-id params))
|
||||||
|
(nitrate/set-team-organization cfg team params))
|
||||||
(assoc team :default-project-id (:id project))))
|
(assoc team :default-project-id (:id project))))
|
||||||
|
|
||||||
(defn- create-team*
|
(defn- create-team*
|
||||||
@ -546,11 +620,13 @@
|
|||||||
(decode-row team)))
|
(decode-row team)))
|
||||||
|
|
||||||
(defn- create-team-role
|
(defn- create-team-role
|
||||||
[conn {:keys [profile-id team-id role] :as params}]
|
[cfg {:keys [profile-id team-id role] :as params}]
|
||||||
|
(assert (db/connection-map? cfg)
|
||||||
|
"expected cfg with valid connection")
|
||||||
(let [params {:team-id team-id
|
(let [params {:team-id team-id
|
||||||
:profile-id profile-id}]
|
:profile-id profile-id}]
|
||||||
(->> (perms/assign-role-flags params role)
|
(->> (perms/assign-role-flags params role)
|
||||||
(db/insert! conn :team-profile-rel))))
|
(add-profile-to-team! cfg))))
|
||||||
|
|
||||||
(defn- create-team-default-project
|
(defn- create-team-default-project
|
||||||
[conn {:keys [profile-id team-id] :as params}]
|
[conn {:keys [profile-id team-id] :as params}]
|
||||||
@ -609,7 +685,7 @@
|
|||||||
;; --- Mutation: Leave Team
|
;; --- Mutation: Leave Team
|
||||||
|
|
||||||
(defn leave-team
|
(defn leave-team
|
||||||
[conn {:keys [profile-id id reassign-to]}]
|
[{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}]
|
||||||
(let [perms (get-permissions conn profile-id id)
|
(let [perms (get-permissions conn profile-id id)
|
||||||
members (get-team-members conn id)]
|
members (get-team-members conn id)]
|
||||||
|
|
||||||
@ -624,7 +700,9 @@
|
|||||||
;; if the `reassign-to` is filled and has a different value
|
;; if the `reassign-to` is filled and has a different value
|
||||||
;; than the current profile-id, we proceed to reassing the
|
;; than the current profile-id, we proceed to reassing the
|
||||||
;; owner role to profile identified by the `reassign-to`.
|
;; owner role to profile identified by the `reassign-to`.
|
||||||
(and reassign-to (not= reassign-to profile-id))
|
;; Ignore the reasignation if the current profile is not
|
||||||
|
;; the owner
|
||||||
|
(and reassign-to (not= reassign-to profile-id) (:is-owner perms))
|
||||||
(let [member (d/seek #(= reassign-to (:id %)) members)]
|
(let [member (d/seek #(= reassign-to (:id %)) members)]
|
||||||
(when-not member
|
(when-not member
|
||||||
(ex/raise :type :not-found :code :member-does-not-exist))
|
(ex/raise :type :not-found :code :member-does-not-exist))
|
||||||
@ -638,7 +716,15 @@
|
|||||||
;; assign owner role to new profile
|
;; assign owner role to new profile
|
||||||
(db/update! conn :team-profile-rel
|
(db/update! conn :team-profile-rel
|
||||||
(get types.team/permissions-for-role :owner)
|
(get types.team/permissions-for-role :owner)
|
||||||
{:team-id id :profile-id reassign-to}))
|
{:team-id id :profile-id reassign-to})
|
||||||
|
|
||||||
|
;; notify new owner
|
||||||
|
(mbus/pub! msgbus
|
||||||
|
:topic reassign-to
|
||||||
|
:message {:type :team-role-change
|
||||||
|
:topic reassign-to
|
||||||
|
:team-id id
|
||||||
|
:role :owner}))
|
||||||
|
|
||||||
;; and finally, if all other conditions does not match and the
|
;; and finally, if all other conditions does not match and the
|
||||||
;; current profile is owner, we dont allow it because there
|
;; current profile is owner, we dont allow it because there
|
||||||
@ -663,32 +749,44 @@
|
|||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:leave-team
|
::sm/params schema:leave-team
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(leave-team conn (assoc params :profile-id profile-id)))
|
(leave-team cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
|
||||||
;; --- Mutation: Delete Team
|
;; --- Mutation: Delete Team
|
||||||
|
|
||||||
(defn- delete-team
|
(defn delete-team
|
||||||
"Mark a team for deletion"
|
"Mark a team for deletion"
|
||||||
[conn {:keys [id] :as team}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}]
|
||||||
|
|
||||||
(let [delay (ldel/get-deletion-delay team)
|
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||||
team (db/update! conn :team
|
perms (get team :permissions)]
|
||||||
{:deleted-at (ct/in-future delay)}
|
|
||||||
{:id id}
|
(when-not (:is-owner perms)
|
||||||
{::db/return-keys true})]
|
(ex/raise :type :validation
|
||||||
|
:code :only-owner-can-delete-team))
|
||||||
|
|
||||||
(when (:is-default team)
|
(when (:is-default team)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :non-deletable-team
|
:code :non-deletable-team
|
||||||
:hint "impossible to delete default team"))
|
:hint "impossible to delete default team"))
|
||||||
|
|
||||||
(wrk/submit! {::db/conn conn
|
(let [delay (ldel/get-deletion-delay team)
|
||||||
::wrk/task :delete-object
|
team (db/update! conn :team
|
||||||
::wrk/params {:object :team
|
{:deleted-at (ct/in-future delay)}
|
||||||
:deleted-at (:deleted-at team)
|
{:id team-id}
|
||||||
:id id}})
|
{::db/return-keys true})]
|
||||||
team))
|
|
||||||
|
;; Api call to nitrate
|
||||||
|
(when (contains? cf/flags :nitrate)
|
||||||
|
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
||||||
|
|
||||||
|
(wrk/submit! {::db/conn conn
|
||||||
|
::wrk/task :delete-object
|
||||||
|
::wrk/params {:object :team
|
||||||
|
:deleted-at (:deleted-at team)
|
||||||
|
:id team-id}})
|
||||||
|
team)))
|
||||||
|
|
||||||
(def ^:private schema:delete-team
|
(def ^:private schema:delete-team
|
||||||
[:map {:title "delete-team"}
|
[:map {:title "delete-team"}
|
||||||
@ -698,16 +796,9 @@
|
|||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:delete-team
|
::sm/params schema:delete-team
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||||
(let [team (get-team conn :profile-id profile-id :team-id id)
|
(delete-team cfg {:team-id id :profile-id profile-id})
|
||||||
perms (get team :permissions)]
|
nil)
|
||||||
|
|
||||||
(when-not (:is-owner perms)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :only-owner-can-delete-team))
|
|
||||||
|
|
||||||
(delete-team conn team)
|
|
||||||
nil))
|
|
||||||
|
|
||||||
;; --- Mutation: Team Update Role
|
;; --- Mutation: Team Update Role
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
@ -35,20 +36,29 @@
|
|||||||
;; --- Mutation: Create Team Invitation
|
;; --- Mutation: Create Team Invitation
|
||||||
|
|
||||||
(def sql:upsert-team-invitation
|
(def sql:upsert-team-invitation
|
||||||
"insert into team_invitation(id, team_id, email_to, created_by, role, valid_until)
|
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||||
values (?, ?, ?, ?, ?, ?)
|
values (?, ?, null, ?, ?, ?, ?)
|
||||||
on conflict(team_id, email_to) do
|
on conflict(team_id, email_to) do
|
||||||
update set role = ?, valid_until = ?, updated_at = now()
|
update set role = ?, valid_until = ?, updated_at = now()
|
||||||
returning *")
|
returning *")
|
||||||
|
|
||||||
|
(def sql:upsert-org-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 *")
|
||||||
|
|
||||||
(defn- create-invitation-token
|
(defn- create-invitation-token
|
||||||
[cfg {:keys [profile-id valid-until team-id member-id member-email role]}]
|
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||||
(tokens/generate cfg
|
(tokens/generate cfg
|
||||||
{:iss :team-invitation
|
{:iss :team-invitation
|
||||||
:exp valid-until
|
:exp valid-until
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:role role
|
:role role
|
||||||
:team-id team-id
|
:team-id team-id
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name organization-name
|
||||||
:member-email member-email
|
:member-email member-email
|
||||||
:member-id member-id}))
|
:member-id member-id}))
|
||||||
|
|
||||||
@ -74,19 +84,40 @@
|
|||||||
[:role types.team/schema:role]
|
[:role types.team/schema:role]
|
||||||
[:email ::sm/email]])
|
[:email ::sm/email]])
|
||||||
|
|
||||||
|
(def ^:private schema:create-org-invitation
|
||||||
|
[:map {:title "params:create-org-invitation"}
|
||||||
|
[::rpc/profile-id ::sm/uuid]
|
||||||
|
[:organization
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name :string]
|
||||||
|
[:logo ::sm/uri]]]
|
||||||
|
[:profile
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:fullname :string]]]
|
||||||
|
[:role types.team/schema:role]
|
||||||
|
[:email ::sm/email]])
|
||||||
|
|
||||||
(def ^:private check-create-invitation-params
|
(def ^:private check-create-invitation-params
|
||||||
(sm/check-fn schema:create-invitation))
|
(sm/check-fn schema:create-invitation))
|
||||||
|
|
||||||
|
(def ^:private check-create-org-invitation-params
|
||||||
|
(sm/check-fn schema:create-org-invitation))
|
||||||
|
|
||||||
(defn- allow-invitation-emails?
|
(defn- allow-invitation-emails?
|
||||||
[member]
|
[member]
|
||||||
(let [notifications (dm/get-in member [:props :notifications])]
|
(let [notifications (dm/get-in member [:props :notifications])]
|
||||||
(not= :none (:email-invites notifications))))
|
(not= :none (:email-invites notifications))))
|
||||||
|
|
||||||
(defn- create-invitation
|
(defn- create-invitation
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
|
||||||
|
|
||||||
(assert (db/connection? conn) "expected valid connection on cfg parameter")
|
(assert (db/connection-map? cfg)
|
||||||
(assert (check-create-invitation-params params))
|
"expected cfg with valid connection")
|
||||||
|
(if organization
|
||||||
|
(assert (check-create-org-invitation-params params))
|
||||||
|
(assert (check-create-invitation-params params)))
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
member (profile/get-profile-by-email conn email)]
|
member (profile/get-profile-by-email conn email)]
|
||||||
@ -103,9 +134,12 @@
|
|||||||
:profile-id (:id member)}
|
:profile-id (:id member)}
|
||||||
(get types.team/permissions-for-role role))]
|
(get types.team/permissions-for-role role))]
|
||||||
|
|
||||||
;; Insert the invited member to the team
|
(if organization
|
||||||
(db/insert! conn :team-profile-rel params
|
;; Insert the invited member to the org
|
||||||
{::db/on-conflict-do-nothing? true})
|
(when (contains? cf/flags :nitrate)
|
||||||
|
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email))
|
||||||
|
;; Insert the invited member to the team
|
||||||
|
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
||||||
|
|
||||||
;; If profile is not yet verified, mark it as verified because
|
;; If profile is not yet verified, mark it as verified because
|
||||||
;; accepting an invitation link serves as verification.
|
;; accepting an invitation link serves as verification.
|
||||||
@ -122,18 +156,30 @@
|
|||||||
(teams/check-email-spam conn email true)
|
(teams/check-email-spam conn email true)
|
||||||
|
|
||||||
(let [id (uuid/next)
|
(let [id (uuid/next)
|
||||||
expire (ct/in-future "168h") ;; 7 days
|
expire (if organization
|
||||||
invitation (db/exec-one! conn [sql:upsert-team-invitation id
|
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||||
(:id team) (str/lower email)
|
(ct/in-future "168h")) ;; 7 days
|
||||||
(:id profile)
|
invitation (db/exec-one! conn (if organization
|
||||||
(name role) expire
|
[sql:upsert-org-invitation id
|
||||||
(name role) expire])
|
(: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))
|
updated? (not= id (:id invitation))
|
||||||
profile-id (:id profile)
|
profile-id (:id profile)
|
||||||
tprops {:profile-id profile-id
|
tprops {:profile-id profile-id
|
||||||
:invitation-id (:id invitation)
|
:invitation-id (:id invitation)
|
||||||
:valid-until expire
|
:valid-until expire
|
||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
|
:organization-id (:id organization)
|
||||||
|
:organization-name (:name organization)
|
||||||
:member-email (:email-to invitation)
|
:member-email (:email-to invitation)
|
||||||
:member-id (:id member)
|
:member-id (:id member)
|
||||||
:role role}
|
:role role}
|
||||||
@ -145,28 +191,58 @@
|
|||||||
|
|
||||||
(let [props (-> (dissoc tprops :profile-id)
|
(let [props (-> (dissoc tprops :profile-id)
|
||||||
(audit/clean-props))
|
(audit/clean-props))
|
||||||
evname (if updated?
|
evname (cond
|
||||||
"update-team-invitation"
|
(and updated? organization) "update-org-invitation"
|
||||||
"create-team-invitation")
|
updated? "update-team-invitation"
|
||||||
|
organization "create-org-invitation"
|
||||||
|
:else "create-team-invitation")
|
||||||
event (-> (audit/event-from-rpc-params params)
|
event (-> (audit/event-from-rpc-params params)
|
||||||
(assoc ::audit/name evname)
|
(assoc ::audit/name evname)
|
||||||
(assoc ::audit/props props))]
|
(assoc ::audit/props props))]
|
||||||
(audit/submit! cfg event))
|
(audit/submit! cfg event))
|
||||||
|
|
||||||
(when (allow-invitation-emails? member)
|
(when (allow-invitation-emails? member)
|
||||||
(eml/send! {::eml/conn conn
|
(if organization
|
||||||
::eml/factory eml/invite-to-team
|
(when (contains? cf/flags :nitrate)
|
||||||
:public-uri (cf/get :public-uri)
|
(eml/send! {::eml/conn conn
|
||||||
:to email
|
::eml/factory eml/invite-to-org
|
||||||
:invited-by (:fullname profile)
|
:public-uri (cf/get :public-uri)
|
||||||
:team (:name team)
|
:to email
|
||||||
:token itoken
|
:invited-by (:fullname profile)
|
||||||
:extra-data ptoken}))
|
:user-name (:fullname member)
|
||||||
|
:organization-name (:name organization)
|
||||||
|
:org-logo (:logo organization)
|
||||||
|
:org-initials (d/get-initials (:name organization))
|
||||||
|
:token itoken
|
||||||
|
:extra-data ptoken}))
|
||||||
|
(let [team (if (contains? cf/flags :nitrate)
|
||||||
|
(nitrate/add-org-info-to-team cfg team {})
|
||||||
|
team)]
|
||||||
|
(eml/send! {::eml/conn conn
|
||||||
|
::eml/factory eml/invite-to-team
|
||||||
|
:public-uri (cf/get :public-uri)
|
||||||
|
:to email
|
||||||
|
:invited-by (:fullname profile)
|
||||||
|
:team (:name team)
|
||||||
|
:organization (:organization-name team)
|
||||||
|
:token itoken
|
||||||
|
:extra-data ptoken}))))
|
||||||
|
|
||||||
itoken)))))
|
itoken)))))
|
||||||
|
|
||||||
|
(defn create-org-invitation
|
||||||
|
[cfg {:keys [::rpc/profile-id id name logo] :as params}]
|
||||||
|
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||||
|
(create-invitation cfg
|
||||||
|
(assoc params
|
||||||
|
:organization {:id id :name name :logo logo}
|
||||||
|
:profile profile
|
||||||
|
:role :editor))))
|
||||||
|
|
||||||
(defn- add-member-to-team
|
(defn- add-member-to-team
|
||||||
[conn profile team role member]
|
[{:keys [::db/conn] :as cfg} profile team role member]
|
||||||
|
(assert (db/connection-map? cfg)
|
||||||
|
"expected cfg with valid connection")
|
||||||
|
|
||||||
(let [team-id (:id team)
|
(let [team-id (:id team)
|
||||||
params (merge
|
params (merge
|
||||||
@ -186,7 +262,7 @@
|
|||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
|
|
||||||
;; Insert the member to the team
|
;; Insert the member to the team
|
||||||
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
|
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||||
|
|
||||||
;; Delete any request
|
;; Delete any request
|
||||||
(db/delete! conn :team-access-request
|
(db/delete! conn :team-access-request
|
||||||
@ -268,7 +344,7 @@
|
|||||||
(filter #(contains? invitation-emails (key %)))
|
(filter #(contains? invitation-emails (key %)))
|
||||||
(map (fn [[email member]]
|
(map (fn [[email member]]
|
||||||
(let [role (:role (first (filter #(= (:email %) email) invitation-data)))]
|
(let [role (:role (first (filter #(= (:email %) email) invitation-data)))]
|
||||||
(add-member-to-team conn profile team role member))))
|
(add-member-to-team cfg profile team role member))))
|
||||||
(doall))
|
(doall))
|
||||||
|
|
||||||
invitations))
|
invitations))
|
||||||
|
|||||||
@ -16,8 +16,10 @@
|
|||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
[app.rpc.quotes :as quotes]
|
[app.rpc.quotes :as quotes]
|
||||||
@ -86,52 +88,74 @@
|
|||||||
;; --- Team Invitation
|
;; --- Team Invitation
|
||||||
|
|
||||||
(defn- accept-invitation
|
(defn- accept-invitation
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
|
[{:keys [::db/conn] :as cfg}
|
||||||
|
{:keys [team-id organization-id role member-email] :as claims} invitation member]
|
||||||
(let [;; Update the role if there is an invitation
|
(let [;; Update the role if there is an invitation
|
||||||
role (or (some-> invitation :role keyword) role)
|
role (or (some-> invitation :role keyword) role)
|
||||||
params (merge
|
id-member (:id member)]
|
||||||
{:team-id team-id
|
|
||||||
:profile-id (:id member)}
|
|
||||||
(get types.team/permissions-for-role role))]
|
|
||||||
|
|
||||||
;; Do not allow blocked users accept invitations.
|
;; Do not allow blocked users accept invitations.
|
||||||
(when (:is-blocked member)
|
(when (:is-blocked member)
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :profile-blocked))
|
:code :profile-blocked))
|
||||||
|
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
|
(when team-id
|
||||||
::quotes/profile-id (:id member)
|
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
|
||||||
::quotes/team-id team-id})
|
::quotes/profile-id id-member
|
||||||
|
::quotes/team-id team-id}))
|
||||||
|
|
||||||
;; Insert the invited member to the team
|
(let [params (merge
|
||||||
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
|
{:team-id team-id
|
||||||
|
:profile-id id-member}
|
||||||
|
(get types.team/permissions-for-role role))
|
||||||
|
|
||||||
;; If profile is not yet verified, mark it as verified because
|
accepted-team-id (if organization-id
|
||||||
;; accepting an invitation link serves as verification.
|
;; Insert the invited member to the org
|
||||||
(when-not (:is-active member)
|
(when (contains? cf/flags :nitrate)
|
||||||
(db/update! conn :profile
|
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
|
||||||
{:is-active true}
|
;; Insert the invited member to the team
|
||||||
{:id (:id member)}))
|
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||||
|
team-id))]
|
||||||
|
|
||||||
;; Delete the invitation
|
(when-not accepted-team-id
|
||||||
(db/delete! conn :team-invitation
|
(ex/raise :type :internal
|
||||||
{:team-id team-id :email-to member-email})
|
:code :accept-invitation-failed
|
||||||
|
:hint "the accept invitation has failed"))
|
||||||
|
|
||||||
;; Delete any request
|
|
||||||
(db/delete! conn :team-access-request
|
|
||||||
{:team-id team-id :requester-id (:id member)})
|
|
||||||
|
|
||||||
(assoc member :is-active true)))
|
;; If profile is not yet verified, mark it as verified because
|
||||||
|
;; accepting an invitation link serves as verification.
|
||||||
|
(when-not (:is-active member)
|
||||||
|
(db/update! conn :profile
|
||||||
|
{:is-active true}
|
||||||
|
{:id id-member}))
|
||||||
|
|
||||||
|
;; Delete the invitation
|
||||||
|
(db/delete! conn :team-invitation
|
||||||
|
(cond-> {:email-to member-email}
|
||||||
|
team-id (assoc :team-id team-id)
|
||||||
|
organization-id (assoc :org-id organization-id)))
|
||||||
|
|
||||||
|
;; Delete any request (only applicable for team invitations)
|
||||||
|
(when team-id
|
||||||
|
(db/delete! conn :team-access-request
|
||||||
|
{:team-id team-id :requester-id id-member}))
|
||||||
|
|
||||||
|
accepted-team-id)))
|
||||||
|
|
||||||
(def schema:team-invitation-claims
|
(def schema:team-invitation-claims
|
||||||
[:map {:title "TeamInvitationClaims"}
|
[:and
|
||||||
[:iss :keyword]
|
[:map {:title "TeamInvitationClaims"}
|
||||||
[:exp ::ct/inst]
|
[:iss :keyword]
|
||||||
[:profile-id ::sm/uuid]
|
[:exp ::ct/inst]
|
||||||
[:role types.team/schema:role]
|
[:profile-id ::sm/uuid]
|
||||||
[:team-id ::sm/uuid]
|
[:role types.team/schema:role]
|
||||||
[:member-email ::sm/email]
|
[:team-id {:optional true} ::sm/uuid]
|
||||||
[:member-id {:optional true} ::sm/uuid]])
|
[:organization-id {:optional true} ::sm/uuid]
|
||||||
|
[:member-email ::sm/email]
|
||||||
|
[:member-id {:optional true} ::sm/uuid]]
|
||||||
|
[:fn {:error/message "team-id or organization-id must be present"}
|
||||||
|
(fn [m] (or (:team-id m) (:organization-id m)))]])
|
||||||
|
|
||||||
(def valid-team-invitation-claims?
|
(def valid-team-invitation-claims?
|
||||||
(sm/lazy-validator schema:team-invitation-claims))
|
(sm/lazy-validator schema:team-invitation-claims))
|
||||||
@ -139,7 +163,7 @@
|
|||||||
(defmethod process-token :team-invitation
|
(defmethod process-token :team-invitation
|
||||||
[{:keys [::db/conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [::rpc/profile-id token] :as params}
|
{:keys [::rpc/profile-id token] :as params}
|
||||||
{:keys [member-id team-id member-email] :as claims}]
|
{:keys [member-id team-id organization-id member-email] :as claims}]
|
||||||
|
|
||||||
(when-not (valid-team-invitation-claims? claims)
|
(when-not (valid-team-invitation-claims? claims)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -147,19 +171,44 @@
|
|||||||
:hint "invitation token contains unexpected data"))
|
:hint "invitation token contains unexpected data"))
|
||||||
|
|
||||||
(let [invitation (db/get* conn :team-invitation
|
(let [invitation (db/get* conn :team-invitation
|
||||||
{:team-id team-id :email-to member-email})
|
(cond-> {:email-to member-email}
|
||||||
|
team-id (assoc :team-id team-id)
|
||||||
|
organization-id (assoc :org-id organization-id)))
|
||||||
profile (db/get* conn :profile
|
profile (db/get* conn :profile
|
||||||
{:id profile-id}
|
{:id profile-id}
|
||||||
{:columns [:id :email]})
|
{:columns [:id :email :default-team-id]})
|
||||||
registration-disabled? (not (contains? cf/flags :registration))]
|
registration-disabled? (not (contains? cf/flags :registration))
|
||||||
(when (nil? invitation)
|
|
||||||
(ex/raise :type :validation
|
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
|
||||||
:code :invalid-token
|
membership (when org-invitation?
|
||||||
:hint "no invitation associated with the token"))
|
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id}))]
|
||||||
|
|
||||||
|
(if profile
|
||||||
|
(do
|
||||||
|
(when-not (or (= member-id profile-id)
|
||||||
|
(= member-email (:email profile)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-token
|
||||||
|
:hint "logged-in user does not matches the invitation"))
|
||||||
|
|
||||||
|
(when (:is-member membership)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :already-an-org-member
|
||||||
|
:team-id (:default-team-id membership)
|
||||||
|
:hint "the user is already a member of the organization"))
|
||||||
|
|
||||||
|
(when (and org-invitation? (not (:organization-id membership)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :org-not-found
|
||||||
|
:team-id (:default-team-id profile)
|
||||||
|
:hint "the organization doesn't exist"))
|
||||||
|
|
||||||
|
(when (nil? invitation)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-token
|
||||||
|
:hint "no invitation associated with the token"))
|
||||||
|
|
||||||
(if (some? profile)
|
|
||||||
(if (or (= member-id profile-id)
|
|
||||||
(= member-email (:email profile)))
|
|
||||||
|
|
||||||
;; if we have logged-in user and it matches the invitation we proceed
|
;; if we have logged-in user and it matches the invitation we proceed
|
||||||
;; with accepting the invitation and joining the current profile to the
|
;; with accepting the invitation and joining the current profile to the
|
||||||
@ -187,17 +236,16 @@
|
|||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:email (:email profile))))))
|
:email (:email profile))))))
|
||||||
|
|
||||||
(accept-invitation cfg claims invitation profile)
|
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||||
(assoc claims :state :created))
|
(cond-> (assoc claims :state :created)
|
||||||
|
;; when the invitation is to an org, instead of a team, add the
|
||||||
(ex/raise :type :validation
|
;; accepted-team-id as :org-team-id
|
||||||
:code :invalid-token
|
(:organization-id claims)
|
||||||
:hint "logged-in user does not matches the invitation"))
|
(assoc :org-team-id accepted-team-id)))))
|
||||||
|
|
||||||
;; If we have not logged-in user, and invitation comes with member-id we
|
;; If we have not logged-in user, and invitation comes with member-id we
|
||||||
;; redirect user to login, if no memeber-id is present and in the invitation
|
;; redirect user to login, if no memeber-id is present and in the invitation
|
||||||
;; token and registration is enabled, we redirect user the the register page.
|
;; token and registration is enabled, we redirect user the the register page.
|
||||||
|
|
||||||
{:invitation-token token
|
{:invitation-token token
|
||||||
:iss :team-invitation
|
:iss :team-invitation
|
||||||
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
||||||
|
|||||||
@ -28,19 +28,25 @@
|
|||||||
(update :pages-index select-keys allowed)))
|
(update :pages-index select-keys allowed)))
|
||||||
|
|
||||||
(defn obfuscate-email
|
(defn obfuscate-email
|
||||||
|
"Obfuscate the `email` for share-link members so the viewer only sees a
|
||||||
|
partially redacted address. Accepts any string shape (including nil,
|
||||||
|
missing `@`, or a domain with no `.`) and falls back to a fully-masked
|
||||||
|
result rather than throwing — the function is called while building the
|
||||||
|
view-only bundle for anonymous viewers, so an NPE here would abort the
|
||||||
|
entire share-link response."
|
||||||
[email]
|
[email]
|
||||||
(let [[name domain]
|
(let [[name domain]
|
||||||
(str/split email "@" 2)
|
(str/split (or email "") "@" 2)
|
||||||
|
|
||||||
[_ rest]
|
[_ rest]
|
||||||
(str/split domain "." 2)
|
(str/split (or domain "") "." 2)
|
||||||
|
|
||||||
name
|
name
|
||||||
(if (> (count name) 3)
|
(if (> (count name) 3)
|
||||||
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
|
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
|
||||||
"****")]
|
"****")]
|
||||||
|
|
||||||
(str name "@****." rest)))
|
(str name "@****" (when rest (str "." rest)))))
|
||||||
|
|
||||||
(defn anonymize-member
|
(defn anonymize-member
|
||||||
[member]
|
[member]
|
||||||
|
|||||||
@ -8,18 +8,34 @@
|
|||||||
"Internal Nitrate HTTP RPC API. Provides authenticated access to
|
"Internal Nitrate HTTP RPC API. Provides authenticated access to
|
||||||
organization management and token validation endpoints."
|
organization management and token validation endpoints."
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.common.types.organization :refer [schema:team-with-organization]]
|
||||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
[app.common.uuid :as uuid]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.msgbus :as mbus]
|
[app.media :as media]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
|
[app.rpc.commands.nitrate :as cnit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
|
[app.rpc.commands.teams-invitations :as ti]
|
||||||
[app.rpc.doc :as doc]
|
[app.rpc.doc :as doc]
|
||||||
|
[app.rpc.notifications :as notifications]
|
||||||
|
[app.storage :as sto]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
|
|
||||||
|
(defn- profile-to-map [profile]
|
||||||
|
{:id (:id profile)
|
||||||
|
:name (:fullname profile)
|
||||||
|
:email (:email profile)
|
||||||
|
:photo-url (files/resolve-public-uri (get profile :photo-id))})
|
||||||
|
|
||||||
;; ---- API: authenticate
|
;; ---- API: authenticate
|
||||||
|
|
||||||
(sv/defmethod ::authenticate
|
(sv/defmethod ::authenticate
|
||||||
@ -28,11 +44,9 @@
|
|||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:profile}
|
::sm/result schema:profile}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(let [profile (profile/get-profile cfg profile-id)]
|
(let [profile (profile/get-profile cfg profile-id)]
|
||||||
{:id (get profile :id)
|
(-> (profile-to-map profile)
|
||||||
:name (get profile :fullname)
|
(assoc :theme (:theme profile)))))
|
||||||
:email (get profile :email)
|
|
||||||
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
|
|
||||||
|
|
||||||
;; ---- API: get-teams
|
;; ---- API: get-teams
|
||||||
|
|
||||||
@ -45,6 +59,19 @@
|
|||||||
AND t.is_default IS FALSE
|
AND t.is_default IS FALSE
|
||||||
AND t.deleted_at IS NULL;")
|
AND t.deleted_at IS NULL;")
|
||||||
|
|
||||||
|
;; ---- API: get-penpot-version
|
||||||
|
|
||||||
|
(def ^:private schema:get-penpot-version-result
|
||||||
|
[:map [:version ::sm/text]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-penpot-version
|
||||||
|
"Get the current Penpot version"
|
||||||
|
{::doc/added "2.14"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result schema:get-penpot-version-result}
|
||||||
|
[_cfg _params]
|
||||||
|
{:version cf/version})
|
||||||
|
|
||||||
(def ^:private schema:get-teams-result
|
(def ^:private schema:get-teams-result
|
||||||
[:vector schema:team])
|
[:vector schema:team])
|
||||||
|
|
||||||
@ -58,28 +85,63 @@
|
|||||||
(->> (db/exec! cfg [sql:get-teams current-user-id])
|
(->> (db/exec! cfg [sql:get-teams current-user-id])
|
||||||
(map #(select-keys % [:id :name])))))
|
(map #(select-keys % [:id :name])))))
|
||||||
|
|
||||||
;; ---- API: notify-team-change
|
;; ---- API: upload-org-logo
|
||||||
|
|
||||||
(def ^:private schema:notify-team-change
|
(def ^:private schema:upload-org-logo
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:content media/schema:upload]
|
||||||
[:organization-id ::sm/text]])
|
[:organization-id ::sm/uuid]
|
||||||
|
[:previous-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private schema:upload-org-logo-result
|
||||||
|
[:map [:id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::upload-org-logo
|
||||||
|
"Store an organization logo in penpot storage and return its ID.
|
||||||
|
Accepts an optional previous-id to mark the old logo for garbage
|
||||||
|
collection when replacing an existing one."
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params schema:upload-org-logo
|
||||||
|
::sm/result schema:upload-org-logo-result}
|
||||||
|
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||||
|
(when previous-id
|
||||||
|
(sto/touch-object! storage previous-id))
|
||||||
|
(let [hash (sto/calculate-hash (:path content))
|
||||||
|
data (-> (sto/content (:path content))
|
||||||
|
(sto/wrap-with-hash hash))
|
||||||
|
obj (sto/put-object! storage {::sto/content data
|
||||||
|
::sto/deduplicate? true
|
||||||
|
:bucket "organization"
|
||||||
|
:content-type (:mtype content)
|
||||||
|
:organization-id organization-id})]
|
||||||
|
{:id (:id obj)}))
|
||||||
|
|
||||||
|
;; ---- API: notify-team-change
|
||||||
|
|
||||||
(sv/defmethod ::notify-team-change
|
(sv/defmethod ::notify-team-change
|
||||||
"Notify to Penpot a team change from nitrate"
|
"Notify to Penpot a team change from nitrate"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.14"
|
||||||
::sm/params schema:notify-team-change
|
::sm/params schema:team-with-organization
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [id organization-id organization-name]}]
|
[cfg team]
|
||||||
(let [msgbus (::mbus/msgbus cfg)]
|
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
||||||
(mbus/pub! msgbus
|
nil)
|
||||||
;;TODO There is a bug on dashboard with teams notifications.
|
|
||||||
;;For now we send it to uuid/zero instead of team-id
|
;; ---- API: notify-user-added-to-organization
|
||||||
:topic uuid/zero
|
|
||||||
:message {:type :team-org-change
|
(def ^:private schema:notify-user-added-to-organization
|
||||||
:team-id id
|
[:map
|
||||||
:organization-id organization-id
|
[:profile-id ::sm/uuid]
|
||||||
:organization-name organization-name})))
|
[:organization-id ::sm/uuid]
|
||||||
|
[:role ::sm/text]])
|
||||||
|
|
||||||
|
(sv/defmethod ::notify-user-added-to-organization
|
||||||
|
"Notify to Penpot that an user has joined an org from nitrate"
|
||||||
|
{::doc/added "2.14"
|
||||||
|
::sm/params schema:notify-user-added-to-organization
|
||||||
|
::rpc/auth false}
|
||||||
|
[cfg {:keys [profile-id organization-id]}]
|
||||||
|
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
|
||||||
|
|
||||||
|
|
||||||
;; ---- API: get-managed-profiles
|
;; ---- API: get-managed-profiles
|
||||||
@ -112,3 +174,359 @@
|
|||||||
[cfg {:keys [::rpc/profile-id]}]
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
||||||
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
|
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
|
||||||
|
|
||||||
|
;; ---- API: get-teams-summary
|
||||||
|
|
||||||
|
(def ^:private sql:get-teams-summary
|
||||||
|
"SELECT t.id, t.name, t.is_default
|
||||||
|
FROM team AS t
|
||||||
|
WHERE t.id = ANY(?)
|
||||||
|
AND t.deleted_at IS NULL;")
|
||||||
|
|
||||||
|
(def ^:private sql:get-files-count
|
||||||
|
"SELECT COUNT(f.*) AS count
|
||||||
|
FROM file AS f
|
||||||
|
JOIN project AS p ON f.project_id = p.id
|
||||||
|
JOIN team AS t ON t.id = p.team_id
|
||||||
|
WHERE p.team_id = ANY(?)
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND f.deleted_at IS NULL;")
|
||||||
|
|
||||||
|
(def ^:private schema:get-teams-summary-params
|
||||||
|
[:map
|
||||||
|
[:ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-teams-summary-result
|
||||||
|
[:map
|
||||||
|
[:teams [:vector [:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:is-default ::sm/boolean]]]]
|
||||||
|
[:num-files ::sm/int]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-teams-summary
|
||||||
|
"Get summary information for a list of teams"
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:get-teams-summary-params
|
||||||
|
::sm/result schema:get-teams-summary-result}
|
||||||
|
[cfg {:keys [ids]}]
|
||||||
|
(let [;; Handle one or multiple params
|
||||||
|
ids (cond
|
||||||
|
(uuid? ids)
|
||||||
|
[ids]
|
||||||
|
|
||||||
|
(and (vector? ids) (every? uuid? ids))
|
||||||
|
ids
|
||||||
|
|
||||||
|
:else
|
||||||
|
[])]
|
||||||
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" ids)
|
||||||
|
teams (db/exec! conn [sql:get-teams-summary ids-array])
|
||||||
|
files-count (-> (db/exec-one! conn [sql:get-files-count ids-array]) :count)]
|
||||||
|
{:teams teams
|
||||||
|
:num-files files-count})))))
|
||||||
|
|
||||||
|
|
||||||
|
;; ---- API: delete-teams-keeping-your-penpot-projects
|
||||||
|
|
||||||
|
(def ^:private sql:prefix-teams-name-and-unset-default
|
||||||
|
"UPDATE team
|
||||||
|
SET name = ? || name,
|
||||||
|
is_default = FALSE
|
||||||
|
WHERE id = ANY(?)
|
||||||
|
RETURNING id, name;")
|
||||||
|
|
||||||
|
|
||||||
|
(def ^:private schema:notify-org-deletion
|
||||||
|
[:map
|
||||||
|
[:organization-name ::sm/text]
|
||||||
|
[:teams [:vector ::sm/uuid]]])
|
||||||
|
|
||||||
|
(sv/defmethod ::notify-org-deletion
|
||||||
|
"For a list of teams, rename them with the name of the deleted org, and notify
|
||||||
|
of the deletion to the connected users"
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:notify-org-deletion}
|
||||||
|
[cfg {:keys [teams organization-name]}]
|
||||||
|
(when (seq teams)
|
||||||
|
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||||
|
(db/tx-run!
|
||||||
|
cfg
|
||||||
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" teams)
|
||||||
|
;; Rename projects
|
||||||
|
updated-teams (db/exec! conn [sql:prefix-teams-name-and-unset-default org-prefix ids-array])]
|
||||||
|
|
||||||
|
;; Notify users
|
||||||
|
(doseq [team updated-teams]
|
||||||
|
(notifications/notify-team-change cfg {:id (:id team) :name (:name team) :organization {:name organization-name}} "dashboard.org-deleted"))))))))
|
||||||
|
|
||||||
|
;; ---- API: get-profile-by-email
|
||||||
|
|
||||||
|
(def ^:private sql:get-profile-by-email
|
||||||
|
"SELECT DISTINCT id, fullname, email, photo_id
|
||||||
|
FROM profile
|
||||||
|
WHERE email = ?
|
||||||
|
AND deleted_at IS NULL;")
|
||||||
|
|
||||||
|
(sv/defmethod ::get-profile-by-email
|
||||||
|
"Get profile by email"
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params [:map [:email ::sm/email]]
|
||||||
|
::sm/result schema:profile}
|
||||||
|
[cfg {:keys [email]}]
|
||||||
|
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
|
||||||
|
(when-not profile
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :profile-not-found
|
||||||
|
:hint "profile does not exist"
|
||||||
|
:email email))
|
||||||
|
(profile-to-map profile)))
|
||||||
|
|
||||||
|
|
||||||
|
;; ---- API: get-profile-by-id
|
||||||
|
|
||||||
|
(def ^:private sql:get-profile-by-id
|
||||||
|
"SELECT DISTINCT id, fullname, email, photo_id
|
||||||
|
FROM profile
|
||||||
|
WHERE id = ?
|
||||||
|
AND deleted_at IS NULL;")
|
||||||
|
|
||||||
|
(sv/defmethod ::get-profile-by-id
|
||||||
|
"Get profile by email"
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params [:map [:id ::sm/uuid]]
|
||||||
|
::sm/result schema:profile}
|
||||||
|
[cfg {:keys [id]}]
|
||||||
|
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
|
||||||
|
(when-not profile
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :profile-not-found
|
||||||
|
:hint "profile does not exist"
|
||||||
|
:id id))
|
||||||
|
(profile-to-map profile)))
|
||||||
|
|
||||||
|
|
||||||
|
;; ---- API: get-org-member-team-counts
|
||||||
|
|
||||||
|
(def ^:private sql:get-org-member-team-counts
|
||||||
|
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
|
||||||
|
FROM team_profile_rel AS tpr
|
||||||
|
JOIN team AS t ON t.id = tpr.team_id
|
||||||
|
WHERE t.id = ANY(?)
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND t.is_default IS FALSE
|
||||||
|
GROUP BY tpr.profile_id;")
|
||||||
|
|
||||||
|
(def ^:private schema:get-org-member-team-counts-params
|
||||||
|
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-org-member-team-counts-result
|
||||||
|
[:vector [:map
|
||||||
|
[:profile-id ::sm/uuid]
|
||||||
|
[:team-count ::sm/int]]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-org-member-team-counts
|
||||||
|
"Get the number of non-default teams each profile belongs to within a set of teams."
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params schema:get-org-member-team-counts-params
|
||||||
|
::sm/result schema:get-org-member-team-counts-result
|
||||||
|
::rpc/auth false}
|
||||||
|
[cfg {:keys [team-ids]}]
|
||||||
|
(let [team-ids (cond
|
||||||
|
(uuid? team-ids)
|
||||||
|
[team-ids]
|
||||||
|
|
||||||
|
(and (vector? team-ids) (every? uuid? team-ids))
|
||||||
|
team-ids
|
||||||
|
|
||||||
|
:else
|
||||||
|
[])]
|
||||||
|
(if (empty? team-ids)
|
||||||
|
[]
|
||||||
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
|
(db/exec! conn [sql:get-org-member-team-counts ids-array])))))))
|
||||||
|
|
||||||
|
|
||||||
|
;; API: invite-to-org
|
||||||
|
|
||||||
|
(sv/defmethod ::invite-to-org
|
||||||
|
"Invite to organization"
|
||||||
|
{::doc/added "2.15"
|
||||||
|
::sm/params [:map
|
||||||
|
[:email ::sm/email]
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:logo ::sm/uri]]}
|
||||||
|
[cfg params]
|
||||||
|
(db/tx-run! cfg ti/create-org-invitation params)
|
||||||
|
nil)
|
||||||
|
|
||||||
|
|
||||||
|
;; API: get-org-invitations
|
||||||
|
|
||||||
|
(def ^:private sql:get-org-invitations
|
||||||
|
"SELECT DISTINCT ON (email_to)
|
||||||
|
ti.id,
|
||||||
|
ti.org_id AS organization_id,
|
||||||
|
ti.email_to AS email,
|
||||||
|
ti.created_at AS sent_at,
|
||||||
|
p.fullname AS name,
|
||||||
|
p.photo_id
|
||||||
|
FROM team_invitation AS ti
|
||||||
|
LEFT JOIN profile AS p
|
||||||
|
ON p.email = ti.email_to
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
WHERE ti.valid_until >= now()
|
||||||
|
AND (ti.org_id = ? OR ti.team_id = ANY(?))
|
||||||
|
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
|
||||||
|
|
||||||
|
(def ^:private schema:get-org-invitations-params
|
||||||
|
[:map
|
||||||
|
[:organization-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-org-invitations-result
|
||||||
|
[:vector
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:email ::sm/email]
|
||||||
|
[:sent-at ::sm/inst]
|
||||||
|
[:name {:optional true} [:maybe ::sm/text]]
|
||||||
|
[:photo-url {:optional true} ::sm/uri]]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-org-invitations
|
||||||
|
"Get valid invitations for an organization, returning at most one invitation per email."
|
||||||
|
{::doc/added "2.16"
|
||||||
|
::sm/params schema:get-org-invitations-params
|
||||||
|
::sm/result schema:get-org-invitations-result}
|
||||||
|
[cfg {:keys [organization-id]}]
|
||||||
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
|
team-ids (->> (:teams org-summary)
|
||||||
|
(map :id)
|
||||||
|
(filter uuid?)
|
||||||
|
(into []))]
|
||||||
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
|
(->> (db/exec! conn [sql:get-org-invitations organization-id ids-array])
|
||||||
|
(mapv (fn [{:keys [photo-id] :as invitation}]
|
||||||
|
(cond-> (dissoc invitation :photo-id)
|
||||||
|
photo-id
|
||||||
|
(assoc :photo-url (files/resolve-public-uri photo-id)))))))))))
|
||||||
|
|
||||||
|
|
||||||
|
;; API: delete-org-invitations
|
||||||
|
|
||||||
|
(def ^:private sql:delete-org-invitations
|
||||||
|
"DELETE FROM team_invitation AS ti
|
||||||
|
WHERE ti.email_to = ?
|
||||||
|
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
|
||||||
|
|
||||||
|
(def ^:private schema:delete-org-invitations-params
|
||||||
|
[:map
|
||||||
|
[:organization-id ::sm/uuid]
|
||||||
|
[:email ::sm/email]])
|
||||||
|
|
||||||
|
(sv/defmethod ::delete-org-invitations
|
||||||
|
"Delete all invitations for one email in an organization scope (org + org teams)."
|
||||||
|
{::doc/added "2.16"
|
||||||
|
::sm/params schema:delete-org-invitations-params}
|
||||||
|
[cfg {:keys [organization-id email]}]
|
||||||
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
|
clean-email (profile/clean-email email)
|
||||||
|
team-ids (->> (:teams org-summary)
|
||||||
|
(map :id)
|
||||||
|
(filter uuid?)
|
||||||
|
(into []))]
|
||||||
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
|
(db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array]))))
|
||||||
|
nil))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; API: remove-from-org
|
||||||
|
|
||||||
|
(def ^:private sql:get-reassign-to
|
||||||
|
"SELECT tpr.profile_id
|
||||||
|
FROM team_profile_rel AS tpr
|
||||||
|
WHERE tpr.team_id = ?
|
||||||
|
AND tpr.profile_id <> ?
|
||||||
|
AND tpr.is_owner IS NOT TRUE
|
||||||
|
ORDER BY CASE
|
||||||
|
WHEN tpr.is_admin IS TRUE THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
tpr.created_at,
|
||||||
|
tpr.profile_id
|
||||||
|
LIMIT 1;")
|
||||||
|
|
||||||
|
(defn add-reassign-to [cfg profile-id team-to-transfer]
|
||||||
|
(let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id])
|
||||||
|
:profile-id)]
|
||||||
|
(when-not reassign-to
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :nobody-to-reassign-team))
|
||||||
|
|
||||||
|
(assoc team-to-transfer :reassign-to reassign-to)))
|
||||||
|
|
||||||
|
(sv/defmethod ::remove-from-org
|
||||||
|
"Remove an user from an organization"
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params [:map
|
||||||
|
[:profile-id ::sm/uuid]
|
||||||
|
[:organization-id ::sm/uuid]
|
||||||
|
[:organization-name ::sm/text]
|
||||||
|
[:default-team-id ::sm/uuid]]
|
||||||
|
::db/transaction true}
|
||||||
|
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
||||||
|
(let [{:keys [valid-teams-to-delete-ids
|
||||||
|
valid-teams-to-transfer
|
||||||
|
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
||||||
|
add-reassign-to (partial add-reassign-to cfg profile-id)
|
||||||
|
|
||||||
|
valid-teams-to-leave (into valid-teams-to-exit
|
||||||
|
(map add-reassign-to valid-teams-to-transfer))]
|
||||||
|
|
||||||
|
(cnit/leave-org cfg (assoc params
|
||||||
|
:id organization-id
|
||||||
|
:name organization-name
|
||||||
|
:teams-to-delete valid-teams-to-delete-ids
|
||||||
|
:teams-to-leave valid-teams-to-leave
|
||||||
|
:skip-validation true))
|
||||||
|
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
|
||||||
|
nil))
|
||||||
|
|
||||||
|
;; API: get-remove-from-org-summary
|
||||||
|
|
||||||
|
(def ^:private schema:get-remove-from-org-summary-result
|
||||||
|
[:map
|
||||||
|
[:teams-to-delete ::sm/int]
|
||||||
|
[:teams-to-transfer ::sm/int]
|
||||||
|
[:teams-to-exit ::sm/int]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-remove-from-org-summary
|
||||||
|
"Get a summary of the teams that would be deleted, transferred, or exited
|
||||||
|
if the user were removed from the organization"
|
||||||
|
{::doc/added "2.17"
|
||||||
|
::sm/params [:map
|
||||||
|
[:profile-id ::sm/uuid]
|
||||||
|
[:organization-id ::sm/uuid]
|
||||||
|
[:default-team-id ::sm/uuid]]
|
||||||
|
::sm/result schema:get-remove-from-org-summary-result
|
||||||
|
::db/transaction true}
|
||||||
|
[cfg {:keys [profile-id organization-id default-team-id]}]
|
||||||
|
(let [{:keys [valid-teams-to-delete-ids
|
||||||
|
valid-teams-to-transfer
|
||||||
|
valid-teams-to-exit
|
||||||
|
valid-default-team]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)]
|
||||||
|
(when-not valid-default-team
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-valid-teams))
|
||||||
|
{:teams-to-delete (count valid-teams-to-delete-ids)
|
||||||
|
:teams-to-transfer (count valid-teams-to-transfer)
|
||||||
|
:teams-to-exit (count valid-teams-to-exit)}))
|
||||||
|
|
||||||
|
|||||||
33
backend/src/app/rpc/notifications.clj
Normal file
33
backend/src/app/rpc/notifications.clj
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.rpc.notifications
|
||||||
|
(:require
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.msgbus :as mbus]))
|
||||||
|
|
||||||
|
(defn notify-team-change
|
||||||
|
[cfg team notification]
|
||||||
|
(let [msgbus (::mbus/msgbus cfg)]
|
||||||
|
(mbus/pub! msgbus
|
||||||
|
;;TODO There is a bug on dashboard with teams notifications.
|
||||||
|
;;For now we send it to uuid/zero instead of team-id
|
||||||
|
:topic uuid/zero
|
||||||
|
:message {:type :team-org-change
|
||||||
|
:team team
|
||||||
|
:notification notification})))
|
||||||
|
|
||||||
|
|
||||||
|
(defn notify-user-org-change
|
||||||
|
[cfg profile-id organization-id organization-name notification]
|
||||||
|
(let [msgbus (::mbus/msgbus cfg)]
|
||||||
|
(mbus/pub! msgbus
|
||||||
|
:topic profile-id
|
||||||
|
:message {:type :user-org-change
|
||||||
|
:topic profile-id
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name organization-name
|
||||||
|
:notification notification})))
|
||||||
@ -522,6 +522,30 @@
|
|||||||
(assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id])
|
(assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id])
|
||||||
(generic-check!)))
|
(generic-check!)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private schema:upload-sessions-per-profile
|
||||||
|
[:map [::profile-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private valid-upload-sessions-per-profile-quote?
|
||||||
|
(sm/lazy-validator schema:upload-sessions-per-profile))
|
||||||
|
|
||||||
|
(def ^:private sql:get-upload-sessions-per-profile
|
||||||
|
"SELECT count(*) AS total
|
||||||
|
FROM upload_session
|
||||||
|
WHERE profile_id = ?")
|
||||||
|
|
||||||
|
(defmethod check-quote ::upload-sessions-per-profile
|
||||||
|
[{:keys [::profile-id ::target] :as quote}]
|
||||||
|
(assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters")
|
||||||
|
(-> quote
|
||||||
|
(assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE))
|
||||||
|
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
||||||
|
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
|
||||||
|
(generic-check!)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; QUOTE: DEFAULT
|
;; QUOTE: DEFAULT
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@ -82,45 +82,37 @@
|
|||||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(db/xact-lock! conn 0)
|
(db/xact-lock! conn 0)
|
||||||
(when-not key
|
(when-not key
|
||||||
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
|
(l/wrn :hint (str "using autogenerated secret-key, it will change "
|
||||||
"all sessions on each restart, it is highly recommended setting up the "
|
"on each restart and will invalidate "
|
||||||
"PENPOT_SECRET_KEY environment variable")))
|
"all sessions on each restart, it is highly "
|
||||||
|
"recommended setting up the "
|
||||||
|
"PENPOT_SECRET_KEY environment variable")))
|
||||||
(let [secret (or key (generate-random-key))]
|
(let [secret (or key (generate-random-key))]
|
||||||
(-> (get-all-props conn)
|
(-> (get-all-props conn)
|
||||||
(assoc :secret-key secret)
|
(assoc :secret-key secret)
|
||||||
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
||||||
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
|
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
|
||||||
|
|
||||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
|
||||||
|
|
||||||
|
|
||||||
(defmethod ig/init-key ::shared-keys
|
(defmethod ig/init-key ::shared-keys
|
||||||
[_ {:keys [::props] :as cfg}]
|
[_ {:keys [::props] :as cfg}]
|
||||||
(let [secret (get props :secret-key)]
|
(let [secret (get props :secret-key)]
|
||||||
(d/without-nils
|
(reduce (fn [keys id]
|
||||||
{:exporter
|
(let [key (or (get cfg id)
|
||||||
(let [key (or (get cfg :exporter)
|
(-> (keys/derive secret :salt (name id))
|
||||||
(-> (keys/derive secret :salt "exporter")
|
(bc/bytes->b64-str true)))]
|
||||||
(bc/bytes->b64-str true)))]
|
(if (or (str/empty? key)
|
||||||
(if (or (str/empty? key)
|
(str/blank? key))
|
||||||
(str/blank? key))
|
(do
|
||||||
(do
|
(l/wrn :id (name id) :hint "key is disabled because empty string found")
|
||||||
(l/wrn :hint "exporter key is disabled because empty string found")
|
keys)
|
||||||
nil)
|
(do
|
||||||
(do
|
(l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
|
||||||
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
|
(assoc keys id key)))))
|
||||||
key)))
|
{}
|
||||||
|
[:exporter
|
||||||
|
:nitrate
|
||||||
|
:nexus])))
|
||||||
|
|
||||||
:nitrate
|
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||||
(let [key (or (get cfg :nitrate)
|
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
||||||
(-> (keys/derive secret :salt "nitrate")
|
|
||||||
(bc/bytes->b64-str true)))]
|
|
||||||
(if (or (str/empty? key)
|
|
||||||
(str/blank? key))
|
|
||||||
(do
|
|
||||||
(l/wrn :hint "nitrate key is disabled because empty string found")
|
|
||||||
nil)
|
|
||||||
(do
|
|
||||||
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
|
|
||||||
key)))})))
|
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
:or {is-active true}}]
|
:or {is-active true}}]
|
||||||
(some-> (get-current-system)
|
(some-> (get-current-system)
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
(fn [{:keys [::db/conn] :as system}]
|
(fn [system]
|
||||||
(let [password (derive-password password)
|
(let [password (derive-password password)
|
||||||
params {:id (uuid/next)
|
params {:id (uuid/next)
|
||||||
:email email
|
:email email
|
||||||
@ -62,7 +62,7 @@
|
|||||||
:password password
|
:password password
|
||||||
:props {}}]
|
:props {}}]
|
||||||
(->> (cmd.auth/create-profile system params)
|
(->> (cmd.auth/create-profile system params)
|
||||||
(cmd.auth/create-profile-rels conn)))))))
|
(cmd.auth/create-profile-rels system)))))))
|
||||||
|
|
||||||
(defmethod exec-command "update-profile"
|
(defmethod exec-command "update-profile"
|
||||||
[{:keys [fullname email password is-active]}]
|
[{:keys [fullname email password is-active]}]
|
||||||
|
|||||||
@ -905,5 +905,4 @@
|
|||||||
(let [params (-> rel
|
(let [params (-> rel
|
||||||
(assoc :id (uuid/next))
|
(assoc :id (uuid/next))
|
||||||
(assoc :team-id (:id team)))]
|
(assoc :team-id (:id team)))]
|
||||||
(db/insert! conn :team-profile-rel params
|
(teams/add-profile-to-team! cfg params {::db/return-keys false}))))))))
|
||||||
{::db/return-keys false}))))))))
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"file-object-thumbnail"
|
"file-object-thumbnail"
|
||||||
"file-thumbnail"
|
"file-thumbnail"
|
||||||
"profile"
|
"profile"
|
||||||
|
"organization"
|
||||||
"tempfile"
|
"tempfile"
|
||||||
"file-data"
|
"file-data"
|
||||||
"file-data-fragment"
|
"file-data-fragment"
|
||||||
|
|||||||
@ -149,7 +149,7 @@
|
|||||||
:status "delete"
|
:status "delete"
|
||||||
:bucket bucket)
|
:bucket bucket)
|
||||||
(recur to-freeze (conj to-delete id) (rest objects))))
|
(recur to-freeze (conj to-delete id) (rest objects))))
|
||||||
(let [deletion-delay (if (= bucket "tempfile")
|
(let [deletion-delay (if (= "tempfile" bucket)
|
||||||
(ct/duration {:hours 2})
|
(ct/duration {:hours 2})
|
||||||
(cf/get-deletion-delay))]
|
(cf/get-deletion-delay))]
|
||||||
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
||||||
@ -166,6 +166,7 @@
|
|||||||
"profile" (process-objects! conn has-profile-refs? bucket objects)
|
"profile" (process-objects! conn has-profile-refs? bucket objects)
|
||||||
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
|
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
|
||||||
"tempfile" (process-objects! conn (constantly false) bucket objects)
|
"tempfile" (process-objects! conn (constantly false) bucket objects)
|
||||||
|
"organization" (process-objects! conn (constantly false) bucket objects)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :unexpected-unknown-reference
|
:code :unexpected-unknown-reference
|
||||||
:hint (dm/fmt "unknown reference '%'" bucket))))
|
:hint (dm/fmt "unknown reference '%'" bucket))))
|
||||||
@ -213,8 +214,13 @@
|
|||||||
[_ params]
|
[_ params]
|
||||||
(assert (db/pool? (::db/pool params)) "expect valid storage"))
|
(assert (db/pool? (::db/pool params)) "expect valid storage"))
|
||||||
|
|
||||||
(defmethod ig/init-key ::handler
|
(defmethod ig/expand-key ::handler
|
||||||
[_ cfg]
|
[k v]
|
||||||
(fn [_]
|
{k (merge {::min-age (ct/duration {:hours 2})} v)})
|
||||||
(process-touched! (assoc cfg ::timestamp (ct/now)))))
|
|
||||||
|
(defmethod ig/init-key ::handler
|
||||||
|
[_ {:keys [::min-age] :as cfg}]
|
||||||
|
(fn [_]
|
||||||
|
(let [threshold (ct/minus (ct/now) min-age)]
|
||||||
|
(process-touched! (assoc cfg ::timestamp threshold)))))
|
||||||
|
|
||||||
|
|||||||
@ -30,21 +30,18 @@
|
|||||||
java.nio.file.Path
|
java.nio.file.Path
|
||||||
java.time.Duration
|
java.time.Duration
|
||||||
java.util.Collection
|
java.util.Collection
|
||||||
java.util.Optional
|
|
||||||
java.util.concurrent.atomic.AtomicLong
|
java.util.concurrent.atomic.AtomicLong
|
||||||
|
java.util.Optional
|
||||||
org.reactivestreams.Subscriber
|
org.reactivestreams.Subscriber
|
||||||
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
|
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
|
||||||
software.amazon.awssdk.core.ResponseBytes
|
|
||||||
software.amazon.awssdk.core.async.AsyncRequestBody
|
software.amazon.awssdk.core.async.AsyncRequestBody
|
||||||
software.amazon.awssdk.core.async.AsyncResponseTransformer
|
software.amazon.awssdk.core.async.AsyncResponseTransformer
|
||||||
software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody
|
software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody
|
||||||
software.amazon.awssdk.core.client.config.ClientAsyncConfiguration
|
software.amazon.awssdk.core.client.config.ClientAsyncConfiguration
|
||||||
|
software.amazon.awssdk.core.ResponseBytes
|
||||||
software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
|
software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
|
||||||
software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup
|
software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup
|
||||||
software.amazon.awssdk.regions.Region
|
software.amazon.awssdk.regions.Region
|
||||||
software.amazon.awssdk.services.s3.S3AsyncClient
|
|
||||||
software.amazon.awssdk.services.s3.S3AsyncClientBuilder
|
|
||||||
software.amazon.awssdk.services.s3.S3Configuration
|
|
||||||
software.amazon.awssdk.services.s3.model.Delete
|
software.amazon.awssdk.services.s3.model.Delete
|
||||||
software.amazon.awssdk.services.s3.model.DeleteObjectRequest
|
software.amazon.awssdk.services.s3.model.DeleteObjectRequest
|
||||||
software.amazon.awssdk.services.s3.model.DeleteObjectsRequest
|
software.amazon.awssdk.services.s3.model.DeleteObjectsRequest
|
||||||
@ -54,9 +51,12 @@
|
|||||||
software.amazon.awssdk.services.s3.model.ObjectIdentifier
|
software.amazon.awssdk.services.s3.model.ObjectIdentifier
|
||||||
software.amazon.awssdk.services.s3.model.PutObjectRequest
|
software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||||
software.amazon.awssdk.services.s3.model.S3Error
|
software.amazon.awssdk.services.s3.model.S3Error
|
||||||
software.amazon.awssdk.services.s3.presigner.S3Presigner
|
|
||||||
software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
|
software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
|
||||||
software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest))
|
software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest
|
||||||
|
software.amazon.awssdk.services.s3.presigner.S3Presigner
|
||||||
|
software.amazon.awssdk.services.s3.S3AsyncClient
|
||||||
|
software.amazon.awssdk.services.s3.S3AsyncClientBuilder
|
||||||
|
software.amazon.awssdk.services.s3.S3Configuration))
|
||||||
|
|
||||||
(def ^:private max-retries
|
(def ^:private max-retries
|
||||||
"A maximum number of retries on internal operations"
|
"A maximum number of retries on internal operations"
|
||||||
|
|||||||
@ -129,6 +129,12 @@
|
|||||||
(->> [sql:team-averages]
|
(->> [sql:team-averages]
|
||||||
(db/exec-one! conn)))
|
(db/exec-one! conn)))
|
||||||
|
|
||||||
|
(defn- get-email-domains
|
||||||
|
[conn]
|
||||||
|
(let [sql "SELECT DISTINCT split_part(email, '@', 2) AS domain FROM profile ORDER BY 1"]
|
||||||
|
(->> (db/exec! conn [sql])
|
||||||
|
(mapv :domain))))
|
||||||
|
|
||||||
(defn- get-enabled-auth-providers
|
(defn- get-enabled-auth-providers
|
||||||
[conn]
|
[conn]
|
||||||
(let [sql (str "SELECT auth_backend AS backend, count(*) AS total "
|
(let [sql (str "SELECT auth_backend AS backend, count(*) AS total "
|
||||||
@ -192,7 +198,8 @@
|
|||||||
:total-fonts (get-num-fonts conn)
|
:total-fonts (get-num-fonts conn)
|
||||||
:total-comments (get-num-comments conn)
|
:total-comments (get-num-comments conn)
|
||||||
:total-file-changes (get-num-file-changes conn)
|
:total-file-changes (get-num-file-changes conn)
|
||||||
:total-touched-files (get-num-touched-files conn)}
|
:total-touched-files (get-num-touched-files conn)
|
||||||
|
:email-domains (get-email-domains conn)}
|
||||||
(merge
|
(merge
|
||||||
(get-team-averages conn)
|
(get-team-averages conn)
|
||||||
(get-jvm-stats)
|
(get-jvm-stats)
|
||||||
|
|||||||
41
backend/src/app/tasks/upload_session_gc.clj
Normal file
41
backend/src/app/tasks/upload_session_gc.clj
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.tasks.upload-session-gc
|
||||||
|
"A maintenance task that deletes stalled (incomplete) upload sessions.
|
||||||
|
|
||||||
|
An upload session is considered stalled when it was created more than
|
||||||
|
`max-age` ago without being completed (i.e. the session row still
|
||||||
|
exists because `assemble-chunks` was never called to clean it up).
|
||||||
|
The default max-age is 1 hour."
|
||||||
|
(:require
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.db :as db]
|
||||||
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
|
(def ^:private sql:delete-stalled-sessions
|
||||||
|
"DELETE FROM upload_session
|
||||||
|
WHERE created_at < ?::timestamptz")
|
||||||
|
|
||||||
|
(defmethod ig/assert-key ::handler
|
||||||
|
[_ params]
|
||||||
|
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||||
|
|
||||||
|
(defmethod ig/expand-key ::handler
|
||||||
|
[k v]
|
||||||
|
{k (merge {::max-age (ct/duration {:hours 1})} v)})
|
||||||
|
|
||||||
|
(defmethod ig/init-key ::handler
|
||||||
|
[_ {:keys [::max-age] :as cfg}]
|
||||||
|
(fn [_]
|
||||||
|
(db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn]}]
|
||||||
|
(let [threshold (ct/minus (ct/now) max-age)
|
||||||
|
result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold])
|
||||||
|
(db/get-update-count))]
|
||||||
|
(l/debug :hint "task finished" :deleted result)
|
||||||
|
{:deleted result})))))
|
||||||
55
backend/test/backend_tests/auth_oidc_test.clj
Normal file
55
backend/test/backend_tests/auth_oidc_test.clj
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.auth-oidc-test
|
||||||
|
(:require
|
||||||
|
[app.auth.oidc :as oidc]
|
||||||
|
[clojure.test :as t]))
|
||||||
|
|
||||||
|
(def ^:private oidc-provider
|
||||||
|
{:id "oidc"
|
||||||
|
:type "oidc"})
|
||||||
|
|
||||||
|
(t/deftest parse-attr-path-supports-dot-and-double-underscore
|
||||||
|
(t/is
|
||||||
|
(= [:oidc/resource-access :penpot_roles :roles]
|
||||||
|
(#'oidc/parse-attr-path oidc-provider "resource_access__penpot_roles__roles")))
|
||||||
|
(t/is
|
||||||
|
(= [:oidc/ocs :data :email]
|
||||||
|
(#'oidc/parse-attr-path oidc-provider "ocs.data.email"))))
|
||||||
|
|
||||||
|
(t/deftest process-user-info-supports-dot-notation-nested-attrs
|
||||||
|
(let [provider (assoc oidc-provider
|
||||||
|
:email-attr "ocs.data.email"
|
||||||
|
:name-attr "ocs.data.display-name")
|
||||||
|
info (#'oidc/process-user-info provider
|
||||||
|
{}
|
||||||
|
{:email_verified true
|
||||||
|
:ocs {:data {:email "nextcloud@example.com"
|
||||||
|
:display-name "Nextcloud User"}}})]
|
||||||
|
(t/is (= "nextcloud@example.com" (:email info)))
|
||||||
|
(t/is (= "Nextcloud User" (:fullname info)))
|
||||||
|
(t/is (true? (:email-verified info)))))
|
||||||
|
|
||||||
|
;; The provider's `:user-info-source` value arrives as a string (enforced by
|
||||||
|
;; the malli schema in `app.config` and used as-is by the hard-coded Google /
|
||||||
|
;; GitHub provider maps), so the dispatch must interpret strings — not
|
||||||
|
;; keywords — to actually honour `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`.
|
||||||
|
(t/deftest select-user-info-source-interprets-config-strings
|
||||||
|
(t/testing "explicit string values map to keyword dispatch tokens"
|
||||||
|
(t/is (= :token (#'oidc/select-user-info-source "token")))
|
||||||
|
(t/is (= :userinfo (#'oidc/select-user-info-source "userinfo"))))
|
||||||
|
|
||||||
|
(t/testing "missing or explicit \"auto\" falls back to auto dispatch"
|
||||||
|
(t/is (= :auto (#'oidc/select-user-info-source "auto")))
|
||||||
|
(t/is (= :auto (#'oidc/select-user-info-source nil))))
|
||||||
|
|
||||||
|
(t/testing "unknown values fall back to auto dispatch safely"
|
||||||
|
(t/is (= :auto (#'oidc/select-user-info-source "unknown")))
|
||||||
|
;; Guards against the reverse regression — a stray keyword value must
|
||||||
|
;; not silently slip through as if it were the matching string.
|
||||||
|
(t/is (= :auto (#'oidc/select-user-info-source :token)))
|
||||||
|
(t/is (= :auto (#'oidc/select-user-info-source :userinfo)))))
|
||||||
34
backend/test/backend_tests/email_blacklist_test.clj
Normal file
34
backend/test/backend_tests/email_blacklist_test.clj
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.email-blacklist-test
|
||||||
|
(:require
|
||||||
|
[app.email :as-alias email]
|
||||||
|
[app.email.blacklist :as blacklist]
|
||||||
|
[clojure.test :as t]))
|
||||||
|
|
||||||
|
(def ^:private cfg
|
||||||
|
{::email/blacklist #{"somedomain.com" "spam.net"}})
|
||||||
|
|
||||||
|
(t/deftest test-exact-domain-match
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@somedomain.com")))
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@spam.net")))
|
||||||
|
(t/is (false? (blacklist/contains? cfg "user@legit.com"))))
|
||||||
|
|
||||||
|
(t/deftest test-subdomain-match
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@sub.somedomain.com")))
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@a.b.somedomain.com")))
|
||||||
|
;; A domain that merely contains the blacklisted string but is not a
|
||||||
|
;; subdomain must NOT be rejected.
|
||||||
|
(t/is (false? (blacklist/contains? cfg "user@notsomedomain.com"))))
|
||||||
|
|
||||||
|
(t/deftest test-case-insensitive
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@SOMEDOMAIN.COM")))
|
||||||
|
(t/is (true? (blacklist/contains? cfg "user@Sub.SomeDomain.Com"))))
|
||||||
|
|
||||||
|
(t/deftest test-non-blacklisted-domain
|
||||||
|
(t/is (false? (blacklist/contains? cfg "user@example.com")))
|
||||||
|
(t/is (false? (blacklist/contains? cfg "user@sub.legit.com"))))
|
||||||
@ -186,10 +186,10 @@
|
|||||||
:is-demo false}
|
:is-demo false}
|
||||||
params)]
|
params)]
|
||||||
(db/run! system
|
(db/run! system
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [cfg]
|
||||||
(->> params
|
(->> params
|
||||||
(cmd.auth/create-profile cfg)
|
(cmd.auth/create-profile cfg)
|
||||||
(cmd.auth/create-profile-rels conn)))))))
|
(cmd.auth/create-profile-rels cfg)))))))
|
||||||
|
|
||||||
(defn create-project*
|
(defn create-project*
|
||||||
([i params] (create-project* *system* i params))
|
([i params] (create-project* *system* i params))
|
||||||
@ -234,10 +234,10 @@
|
|||||||
(dm/with-open [conn (db/open system)]
|
(dm/with-open [conn (db/open system)]
|
||||||
(let [id (mk-uuid "team" i)
|
(let [id (mk-uuid "team" i)
|
||||||
features (cfeat/get-enabled-features cf/flags)]
|
features (cfeat/get-enabled-features cf/flags)]
|
||||||
(teams/create-team conn {:id id
|
(teams/create-team {::db/conn conn} {:id id
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:features features
|
:features features
|
||||||
:name (str "team" i)})))))
|
:name (str "team" i)})))))
|
||||||
|
|
||||||
(defn create-file-media-object*
|
(defn create-file-media-object*
|
||||||
([params] (create-file-media-object* *system* params))
|
([params] (create-file-media-object* *system* params))
|
||||||
@ -283,9 +283,10 @@
|
|||||||
([params] (create-team-role* *system* params))
|
([params] (create-team-role* *system* params))
|
||||||
([system {:keys [team-id profile-id role] :or {role :owner}}]
|
([system {:keys [team-id profile-id role] :or {role :owner}}]
|
||||||
(dm/with-open [conn (db/open system)]
|
(dm/with-open [conn (db/open system)]
|
||||||
(#'teams/create-team-role conn {:team-id team-id
|
(#'teams/create-team-role {::db/conn conn}
|
||||||
:profile-id profile-id
|
{:team-id team-id
|
||||||
:role role}))))
|
:profile-id profile-id
|
||||||
|
:role role}))))
|
||||||
|
|
||||||
(defn create-project-role*
|
(defn create-project-role*
|
||||||
([params] (create-project-role* *system* params))
|
([params] (create-project-role* *system* params))
|
||||||
@ -384,6 +385,31 @@
|
|||||||
(dissoc ::type)
|
(dissoc ::type)
|
||||||
(assoc :app.rpc/request-at (ct/now)))))))
|
(assoc :app.rpc/request-at (ct/now)))))))
|
||||||
|
|
||||||
|
(defn management-command!
|
||||||
|
([data]
|
||||||
|
(management-command! data nil))
|
||||||
|
([{:keys [::type] :as data} flags-to-add]
|
||||||
|
(let [flags (reduce conj cf/flags (or flags-to-add []))
|
||||||
|
|
||||||
|
resolve-management-methods
|
||||||
|
(requiring-resolve 'app.rpc/resolve-management-methods)
|
||||||
|
|
||||||
|
methods
|
||||||
|
(with-redefs [cf/flags flags]
|
||||||
|
(resolve-management-methods *system*))
|
||||||
|
|
||||||
|
[_ method-fn]
|
||||||
|
(get methods type)]
|
||||||
|
|
||||||
|
(when-not method-fn
|
||||||
|
(ex/raise :type :assertion
|
||||||
|
:code :rpc-method-not-found
|
||||||
|
:hint (str/ffmt "management rpc method '%' not found" (name type))))
|
||||||
|
|
||||||
|
(try-on! (method-fn (-> data
|
||||||
|
(dissoc ::type)
|
||||||
|
(assoc :app.rpc/request-at (ct/now))))))))
|
||||||
|
|
||||||
(defn run-task!
|
(defn run-task!
|
||||||
([name]
|
([name]
|
||||||
(run-task! name {}))
|
(run-task! name {}))
|
||||||
|
|||||||
@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
(t/deftest access-token-authz
|
(t/deftest access-token-authz
|
||||||
(let [profile (th/create-profile* 1)
|
(let [profile (th/create-profile* 1)
|
||||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
|
||||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
||||||
|
|
||||||
(let [response (handler nil)]
|
(let [response (handler nil)]
|
||||||
|
|||||||
@ -107,4 +107,18 @@
|
|||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(let [results (:result out)]
|
(let [results (:result out)]
|
||||||
(t/is (= 2 (count results))))))))
|
(t/is (= 2 (count results))))))
|
||||||
|
|
||||||
|
(t/testing "get mcp token"
|
||||||
|
(let [_ (th/command! {::th/type :create-access-token
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:type "mcp"
|
||||||
|
:name "token 1"
|
||||||
|
:perms ["get-profile"]})
|
||||||
|
{:keys [error result]}
|
||||||
|
(th/command! {::th/type :get-current-mcp-token
|
||||||
|
::rpc/profile-id (:id prof)})]
|
||||||
|
;; (th/print-result! result)
|
||||||
|
(t/is (nil? error))
|
||||||
|
(t/is (string? (:token result)))))))
|
||||||
|
|
||||||
|
|||||||
@ -312,7 +312,8 @@
|
|||||||
;; freeze because of the deduplication (we have uploaded 2 times
|
;; freeze because of the deduplication (we have uploaded 2 times
|
||||||
;; the same files).
|
;; the same files).
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 2 (:freeze res)))
|
(t/is (= 2 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
@ -386,7 +387,8 @@
|
|||||||
;; Now that file-gc have deleted the file-media-object usage,
|
;; Now that file-gc have deleted the file-media-object usage,
|
||||||
;; lets execute the touched-gc task, we should see that two of
|
;; lets execute the touched-gc task, we should see that two of
|
||||||
;; them are marked to be deleted
|
;; them are marked to be deleted
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 2 (:delete res))))
|
(t/is (= 2 (:delete res))))
|
||||||
|
|
||||||
@ -571,7 +573,8 @@
|
|||||||
;; Now that file-gc have deleted the file-media-object usage,
|
;; Now that file-gc have deleted the file-media-object usage,
|
||||||
;; lets execute the touched-gc task, we should see that two of
|
;; lets execute the touched-gc task, we should see that two of
|
||||||
;; them are marked to be deleted.
|
;; them are marked to be deleted.
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 2 (:delete res))))
|
(t/is (= 2 (:delete res))))
|
||||||
|
|
||||||
@ -664,7 +667,8 @@
|
|||||||
;; because of the deduplication (we have uploaded 2 times the
|
;; because of the deduplication (we have uploaded 2 times the
|
||||||
;; same files).
|
;; same files).
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 1 (:freeze res)))
|
(t/is (= 1 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
@ -714,7 +718,8 @@
|
|||||||
|
|
||||||
;; Now that objects-gc have deleted the object thumbnail lets
|
;; Now that objects-gc have deleted the object thumbnail lets
|
||||||
;; execute the touched-gc task
|
;; execute the touched-gc task
|
||||||
(let [res (th/run-task! "storage-gc-touched" {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! "storage-gc-touched" {}))]
|
||||||
(t/is (= 1 (:freeze res))))
|
(t/is (= 1 (:freeze res))))
|
||||||
|
|
||||||
;; check file media objects
|
;; check file media objects
|
||||||
@ -749,7 +754,8 @@
|
|||||||
|
|
||||||
;; Now that file-gc have deleted the object thumbnail lets
|
;; Now that file-gc have deleted the object thumbnail lets
|
||||||
;; execute the touched-gc task
|
;; execute the touched-gc task
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 1 (:delete res))))
|
(t/is (= 1 (:delete res))))
|
||||||
|
|
||||||
;; check file media objects
|
;; check file media objects
|
||||||
@ -1319,7 +1325,8 @@
|
|||||||
;; The FileGC task will schedule an inner taskq
|
;; The FileGC task will schedule an inner taskq
|
||||||
(th/run-pending-tasks!)
|
(th/run-pending-tasks!)
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 2 (:freeze res)))
|
(t/is (= 2 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
@ -1413,7 +1420,8 @@
|
|||||||
|
|
||||||
;; we ensure that once object-gc is passed and marked two storage
|
;; we ensure that once object-gc is passed and marked two storage
|
||||||
;; objects to delete
|
;; objects to delete
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 2 (:delete res))))
|
(t/is (= 2 (:delete res))))
|
||||||
|
|
||||||
@ -2113,3 +2121,92 @@
|
|||||||
(t/is (= 1 (count rows)))
|
(t/is (= 1 (count rows)))
|
||||||
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
|
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
|
||||||
(t/is (nil? (:deleted-at row1))))))))
|
(t/is (nil? (:deleted-at row1))))))))
|
||||||
|
|
||||||
|
(t/deftest get-file-stats-empty-file
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
file (th/create-file* 1 {:profile-id (:id profile)
|
||||||
|
:project-id (:default-project-id profile)
|
||||||
|
:is-shared false})
|
||||||
|
out (th/command! {::th/type :get-file-stats
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:id (:id file)})]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
|
||||||
|
(let [result (:result out)]
|
||||||
|
(t/is (= (:id file) (:file-id result)))
|
||||||
|
(t/is (pos? (:page-count result)))
|
||||||
|
(t/is (zero? (:component-count result)))
|
||||||
|
(t/is (zero? (:deleted-component-count result)))
|
||||||
|
(t/is (zero? (:color-count result)))
|
||||||
|
(t/is (zero? (:typography-count result)))
|
||||||
|
(t/is (zero? (:library-count result)))
|
||||||
|
(t/is (zero? (:referenced-by-count result)))
|
||||||
|
(t/is (contains? result :shape-counts))
|
||||||
|
(t/is (zero? (get-in result [:shape-counts :total])))
|
||||||
|
(t/is (= {} (get-in result [:shape-counts :by-type]))))))
|
||||||
|
|
||||||
|
(t/deftest get-file-stats-with-shapes
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
file (th/create-file* 1 {:profile-id (:id profile)
|
||||||
|
:project-id (:default-project-id profile)
|
||||||
|
:is-shared false})
|
||||||
|
page-id (-> file :data :pages first)
|
||||||
|
rect-id (uuid/random)
|
||||||
|
frame-id (uuid/random)]
|
||||||
|
|
||||||
|
(update-file!
|
||||||
|
:file-id (:id file)
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:revn 0
|
||||||
|
:vern 0
|
||||||
|
:changes
|
||||||
|
[{:type :add-obj
|
||||||
|
:page-id page-id
|
||||||
|
:id frame-id
|
||||||
|
:parent-id uuid/zero
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:components-v2 true
|
||||||
|
:obj (cts/setup-shape
|
||||||
|
{:id frame-id
|
||||||
|
:name "frame"
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:parent-id uuid/zero
|
||||||
|
:type :frame})}
|
||||||
|
{:type :add-obj
|
||||||
|
:page-id page-id
|
||||||
|
:id rect-id
|
||||||
|
:parent-id frame-id
|
||||||
|
:frame-id frame-id
|
||||||
|
:components-v2 true
|
||||||
|
:obj (cts/setup-shape
|
||||||
|
{:id rect-id
|
||||||
|
:name "rect"
|
||||||
|
:frame-id frame-id
|
||||||
|
:parent-id frame-id
|
||||||
|
:type :rect})}])
|
||||||
|
|
||||||
|
(let [out (th/command! {::th/type :get-file-stats
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:id (:id file)})
|
||||||
|
result (:result out)]
|
||||||
|
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(t/is (= 2 (get-in result [:shape-counts :total])))
|
||||||
|
(t/is (= 1 (get-in result [:shape-counts :by-type :rect])))
|
||||||
|
(t/is (= 1 (get-in result [:shape-counts :by-type :frame]))))))
|
||||||
|
|
||||||
|
(t/deftest get-file-stats-forbidden
|
||||||
|
(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)
|
||||||
|
:is-shared false})
|
||||||
|
out (th/command! {::th/type :get-file-stats
|
||||||
|
::rpc/profile-id (:id other)
|
||||||
|
:id (:id file)})]
|
||||||
|
|
||||||
|
(t/is (not (nil? (:error out))))
|
||||||
|
(let [edata (-> out :error ex-data)]
|
||||||
|
(t/is (= :not-found (:type edata))))))
|
||||||
|
|||||||
@ -85,7 +85,7 @@
|
|||||||
(t/is (map? (:result out))))
|
(t/is (map? (:result out))))
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
(th/run-task! "storage-gc-touched" {}))]
|
(th/run-task! "storage-gc-touched" {}))]
|
||||||
(t/is (= 2 (:freeze res))))
|
(t/is (= 2 (:freeze res))))
|
||||||
|
|
||||||
@ -136,7 +136,7 @@
|
|||||||
(t/is (some? (sto/get-object storage (:media-id row2))))
|
(t/is (some? (sto/get-object storage (:media-id row2))))
|
||||||
|
|
||||||
;; run the task again
|
;; run the task again
|
||||||
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
(th/run-task! :storage-gc-touched {}))]
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 1 (:delete res)))
|
(t/is (= 1 (:delete res)))
|
||||||
(t/is (= 0 (:freeze res))))
|
(t/is (= 0 (:freeze res))))
|
||||||
@ -235,7 +235,8 @@
|
|||||||
(t/is (= (:object-id data1) (:object-id row)))
|
(t/is (= (:object-id data1) (:object-id row)))
|
||||||
(t/is (uuid? (:media-id row1))))
|
(t/is (uuid? (:media-id row1))))
|
||||||
|
|
||||||
(let [result (th/run-task! :storage-gc-touched {})]
|
(let [result (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 1 (:delete result))))
|
(t/is (= 1 (:delete result))))
|
||||||
|
|
||||||
;; Check if storage objects still exists after file-gc
|
;; Check if storage objects still exists after file-gc
|
||||||
|
|||||||
@ -93,6 +93,41 @@
|
|||||||
:font-weight
|
:font-weight
|
||||||
:font-style))))
|
:font-style))))
|
||||||
|
|
||||||
|
(t/deftest woff2-font-upload-1
|
||||||
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
|
team-id (:default-team-id prof)
|
||||||
|
proj-id (:default-project-id prof)
|
||||||
|
font-id (uuid/custom 10 1)
|
||||||
|
|
||||||
|
data (-> (io/resource "backend_tests/test_files/font-1.woff2")
|
||||||
|
(io/read*))
|
||||||
|
|
||||||
|
params {::th/type :create-font-variant
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:team-id team-id
|
||||||
|
:font-id font-id
|
||||||
|
:font-family "somefont"
|
||||||
|
:font-weight 400
|
||||||
|
:font-style "normal"
|
||||||
|
:data {"font/woff2" data}}
|
||||||
|
out (th/command! params)]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(let [result (:result out)]
|
||||||
|
(t/is (uuid? (:id result)))
|
||||||
|
(t/is (uuid? (:ttf-file-id result)))
|
||||||
|
(t/is (uuid? (:otf-file-id result)))
|
||||||
|
(t/is (uuid? (:woff1-file-id result)))
|
||||||
|
(t/is (uuid? (:woff2-file-id result)))
|
||||||
|
(t/are [k] (= (get params k)
|
||||||
|
(get result k))
|
||||||
|
:team-id
|
||||||
|
:font-id
|
||||||
|
:font-family
|
||||||
|
:font-weight
|
||||||
|
:font-style))))
|
||||||
|
|
||||||
(t/deftest font-deletion-1
|
(t/deftest font-deletion-1
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
team-id (:default-team-id prof)
|
team-id (:default-team-id prof)
|
||||||
@ -130,7 +165,8 @@
|
|||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out))))
|
(t/is (nil? (:error out))))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 6 (:freeze res))))
|
(t/is (= 6 (:freeze res))))
|
||||||
|
|
||||||
(let [params {::th/type :delete-font
|
(let [params {::th/type :delete-font
|
||||||
@ -142,14 +178,16 @@
|
|||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(t/is (nil? (:result out))))
|
(t/is (nil? (:result out))))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||||
(let [res (th/run-task! :objects-gc {})]
|
(let [res (th/run-task! :objects-gc {})]
|
||||||
(t/is (= 2 (:processed res))))
|
(t/is (= 2 (:processed res)))))
|
||||||
|
|
||||||
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (th/run-task! :storage-gc-touched {})]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 6 (:delete res)))))))
|
(t/is (= 6 (:delete res)))))))
|
||||||
@ -191,7 +229,8 @@
|
|||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out))))
|
(t/is (nil? (:error out))))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 6 (:freeze res))))
|
(t/is (= 6 (:freeze res))))
|
||||||
|
|
||||||
(let [params {::th/type :delete-font
|
(let [params {::th/type :delete-font
|
||||||
@ -203,14 +242,16 @@
|
|||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(t/is (nil? (:result out))))
|
(t/is (nil? (:result out))))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||||
(let [res (th/run-task! :objects-gc {})]
|
(let [res (th/run-task! :objects-gc {})]
|
||||||
(t/is (= 1 (:processed res))))
|
(t/is (= 1 (:processed res)))))
|
||||||
|
|
||||||
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (th/run-task! :storage-gc-touched {})]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 3 (:delete res)))))))
|
(t/is (= 3 (:delete res)))))))
|
||||||
@ -220,57 +261,42 @@
|
|||||||
team-id (:default-team-id prof)
|
team-id (:default-team-id prof)
|
||||||
proj-id (:default-project-id prof)
|
proj-id (:default-project-id prof)
|
||||||
font-id (uuid/custom 10 1)
|
font-id (uuid/custom 10 1)
|
||||||
|
data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
|
||||||
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
|
data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))
|
||||||
(io/read*))
|
params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
|
||||||
|
:team-id team-id :font-id font-id :font-family "somefont"
|
||||||
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
|
:font-weight 400 :font-style "normal" :data {"font/woff" data1}}
|
||||||
(io/read*))
|
params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
|
||||||
params1 {::th/type :create-font-variant
|
:team-id team-id :font-id font-id :font-family "somefont"
|
||||||
::rpc/profile-id (:id prof)
|
:font-weight 500 :font-style "normal" :data {"font/woff" data2}}
|
||||||
:team-id team-id
|
|
||||||
:font-id font-id
|
|
||||||
:font-family "somefont"
|
|
||||||
:font-weight 400
|
|
||||||
:font-style "normal"
|
|
||||||
:data {"font/woff" data1}}
|
|
||||||
|
|
||||||
params2 {::th/type :create-font-variant
|
|
||||||
::rpc/profile-id (:id prof)
|
|
||||||
:team-id team-id
|
|
||||||
:font-id font-id
|
|
||||||
:font-family "somefont"
|
|
||||||
:font-weight 500
|
|
||||||
:font-style "normal"
|
|
||||||
:data {"font/woff" data2}}
|
|
||||||
|
|
||||||
out1 (th/command! params1)
|
out1 (th/command! params1)
|
||||||
out2 (th/command! params2)]
|
out2 (th/command! params2)]
|
||||||
|
|
||||||
;; (th/print-result! out1)
|
|
||||||
(t/is (nil? (:error out1)))
|
(t/is (nil? (:error out1)))
|
||||||
(t/is (nil? (:error out2)))
|
(t/is (nil? (:error out2)))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
;; freeze with hours 3 clock
|
||||||
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 6 (:freeze res))))
|
(t/is (= 6 (:freeze res))))
|
||||||
|
|
||||||
(let [params {::th/type :delete-font-variant
|
(let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof)
|
||||||
::rpc/profile-id (:id prof)
|
:team-id team-id :id (-> out1 :result :id)}
|
||||||
:team-id team-id
|
|
||||||
:id (-> out1 :result :id)}
|
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(t/is (nil? (:result out))))
|
(t/is (nil? (:result out))))
|
||||||
|
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
;; no-op with hours 3 clock (nothing touched yet)
|
||||||
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
|
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
|
||||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||||
(let [res (th/run-task! :objects-gc {})]
|
(let [res (th/run-task! :objects-gc {})]
|
||||||
(t/is (= 1 (:processed res))))
|
(t/is (= 1 (:processed res)))))
|
||||||
|
|
||||||
|
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (th/run-task! :storage-gc-touched {})]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 3 (:delete res)))))))
|
(t/is (= 3 (:delete res)))))))
|
||||||
|
|||||||
800
backend/test/backend_tests/rpc_management_nitrate_test.clj
Normal file
800
backend/test/backend_tests/rpc_management_nitrate_test.clj
Normal file
@ -0,0 +1,800 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.rpc-management-nitrate-test
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.db :as-alias db]
|
||||||
|
[app.email :as email]
|
||||||
|
[app.msgbus :as mbus]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[backend-tests.helpers :as th]
|
||||||
|
[clojure.set :as set]
|
||||||
|
[clojure.test :as t]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
(t/use-fixtures :once th/state-init)
|
||||||
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
|
(defn- management-command-with-nitrate!
|
||||||
|
[data]
|
||||||
|
(th/management-command! data [:nitrate]))
|
||||||
|
|
||||||
|
(t/deftest authenticate-success
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true
|
||||||
|
:fullname "Nitrate User"})
|
||||||
|
out (management-command-with-nitrate! {::th/type :authenticate
|
||||||
|
::rpc/profile-id (:id profile)})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= (:id profile) (-> out :result :id)))
|
||||||
|
(t/is (= "Nitrate User" (-> out :result :name)))
|
||||||
|
(t/is (= (:email profile) (-> out :result :email)))
|
||||||
|
(t/is (nil? (-> out :result :photo-url)))))
|
||||||
|
|
||||||
|
(t/deftest authenticate-requires-authentication
|
||||||
|
(let [out (management-command-with-nitrate! {::th/type :authenticate})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authentication (th/ex-type (:error out))))
|
||||||
|
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
(t/deftest get-penpot-version
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
out (management-command-with-nitrate! {::th/type :get-penpot-version
|
||||||
|
::rpc/profile-id (:id profile)})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= cf/version (-> out :result :version)))))
|
||||||
|
|
||||||
|
(t/deftest get-teams-returns-only-owned-non-default-non-deleted
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
other (th/create-profile* 2 {:is-active true})
|
||||||
|
owned-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
deleted-team (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
_ (th/db-update! :team
|
||||||
|
{:deleted-at (ct/now)}
|
||||||
|
{:id (:id deleted-team)})
|
||||||
|
other-team (th/create-team* 3 {:profile-id (:id other)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id other-team)
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:role :editor})
|
||||||
|
out (management-command-with-nitrate! {::th/type :get-teams
|
||||||
|
::rpc/profile-id (:id profile)})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= #{(:id owned-team)}
|
||||||
|
(->> out :result (map :id) set)))
|
||||||
|
(t/is (= #{(:name owned-team)}
|
||||||
|
(->> out :result (map :name) set)))))
|
||||||
|
|
||||||
|
(t/deftest notify-team-change-publishes-event
|
||||||
|
(let [team-id (uuid/random)
|
||||||
|
organization-id (uuid/random)
|
||||||
|
organization {:id organization-id
|
||||||
|
:name "Acme Inc"
|
||||||
|
:slug "acme-inc"
|
||||||
|
:owner-id (uuid/random)
|
||||||
|
:avatar-bg-url "http://example.com/avatar.svg"}
|
||||||
|
calls (atom [])
|
||||||
|
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||||
|
(swap! calls conj {:topic topic
|
||||||
|
:message message}))]
|
||||||
|
(management-command-with-nitrate! {::th/type :notify-team-change
|
||||||
|
:id team-id
|
||||||
|
:is-your-penpot false
|
||||||
|
:organization organization}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count @calls)))
|
||||||
|
(t/is (= uuid/zero (-> @calls first :topic)))
|
||||||
|
(let [msg (-> @calls first :message)]
|
||||||
|
(t/is (= :team-org-change (:type msg)))
|
||||||
|
(t/is (= nil (:notification msg)))
|
||||||
|
(t/is (= team-id (-> msg :team :id)))
|
||||||
|
(t/is (= false (-> msg :team :is-your-penpot)))
|
||||||
|
(t/is (= (:id organization) (-> msg :team :organization :id)))
|
||||||
|
(t/is (= (:name organization) (-> msg :team :organization :name)))
|
||||||
|
(t/is (= (:slug organization) (-> msg :team :organization :slug)))
|
||||||
|
(t/is (= (:owner-id organization) (-> msg :team :organization :owner-id)))
|
||||||
|
(t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url)))))))
|
||||||
|
|
||||||
|
(t/deftest notify-user-added-to-organization-creates-default-org-team
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
|
||||||
|
:is-owner true})
|
||||||
|
(map :team-id)
|
||||||
|
set)
|
||||||
|
out (management-command-with-nitrate! {::th/type :notify-user-added-to-organization
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:organization-id (uuid/random)
|
||||||
|
:role "owner"})
|
||||||
|
after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
|
||||||
|
:is-owner true})
|
||||||
|
(map :team-id)
|
||||||
|
set)
|
||||||
|
new-team-id (first (set/difference after-teams before-teams))
|
||||||
|
new-team (th/db-get :team {:id new-team-id})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (set/difference after-teams before-teams))))
|
||||||
|
(t/is (= "Your Penpot" (:name new-team)))
|
||||||
|
(t/is (true? (:is-default new-team)))))
|
||||||
|
|
||||||
|
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
|
||||||
|
(let [owner (th/create-profile* 1 {:is-active true})
|
||||||
|
member1 (th/create-profile* 2 {:is-active true})
|
||||||
|
member2 (th/create-profile* 3 {:is-active true})
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id owner)})
|
||||||
|
team2 (th/create-team* 2 {:profile-id (:id owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id member1)
|
||||||
|
:role :editor})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id member2)
|
||||||
|
:role :editor})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team2)
|
||||||
|
:profile-id (:id member1)
|
||||||
|
:role :editor})
|
||||||
|
out (management-command-with-nitrate! {::th/type :get-managed-profiles
|
||||||
|
::rpc/profile-id (:id owner)})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= #{(:id member1) (:id member2)}
|
||||||
|
(->> out :result (map :id) set)))
|
||||||
|
(t/is (= #{(:email member1) (:email member2)}
|
||||||
|
(->> out :result (map :email) set)))))
|
||||||
|
|
||||||
|
(t/deftest get-teams-summary-returns-teams-and-files-count
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
proj1 (th/create-project* 1 {:profile-id (:id profile)
|
||||||
|
:team-id (:id team1)})
|
||||||
|
proj2 (th/create-project* 2 {:profile-id (:id profile)
|
||||||
|
:team-id (:id team2)})
|
||||||
|
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||||
|
:project-id (:id proj1)})
|
||||||
|
_ (th/create-file* 2 {:profile-id (:id profile)
|
||||||
|
:project-id (:id proj2)})
|
||||||
|
out (management-command-with-nitrate! {::th/type :get-teams-summary
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:ids [(:id team1) (:id team2)]})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 2 (-> out :result :num-files)))
|
||||||
|
(t/is (= #{(:id team1) (:id team2)}
|
||||||
|
(->> out :result :teams (map :id) set)))))
|
||||||
|
|
||||||
|
(t/deftest notify-org-deletion-prefixes-teams-and-notifies
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
extra-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
default-team (th/db-get :team {:id (:default-team-id profile)})
|
||||||
|
teams [(:id default-team) (:id extra-team)]
|
||||||
|
organization-name "Acme / Design"
|
||||||
|
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||||
|
calls (atom [])
|
||||||
|
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||||
|
(swap! calls conj {:topic topic
|
||||||
|
:message message}))]
|
||||||
|
(management-command-with-nitrate! {::th/type :notify-org-deletion
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:teams teams
|
||||||
|
:organization-name organization-name}))
|
||||||
|
updated (map #(th/db-get :team {:id %} {::db/remove-deleted false}) teams)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 2 (count @calls)))
|
||||||
|
(doseq [team updated]
|
||||||
|
(t/is (false? (:is-default team)))
|
||||||
|
(t/is (str/starts-with? (:name team) expected-start)))
|
||||||
|
(doseq [call @calls]
|
||||||
|
(t/is (= uuid/zero (:topic call)))
|
||||||
|
(t/is (= :team-org-change (-> call :message :type)))
|
||||||
|
(t/is (= organization-name (-> call :message :team :organization :name)))
|
||||||
|
(t/is (= "dashboard.org-deleted" (-> call :message :notification))))))
|
||||||
|
|
||||||
|
(t/deftest get-profile-by-email-success-and-not-found
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true
|
||||||
|
:fullname "Lookup by Email"})
|
||||||
|
ok-out (management-command-with-nitrate! {::th/type :get-profile-by-email
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:email (:email profile)})
|
||||||
|
ko-out (management-command-with-nitrate! {::th/type :get-profile-by-email
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:email "not-found@example.com"})]
|
||||||
|
(t/is (th/success? ok-out))
|
||||||
|
(t/is (= (:id profile) (-> ok-out :result :id)))
|
||||||
|
(t/is (= "Lookup by Email" (-> ok-out :result :name)))
|
||||||
|
(t/is (nil? (-> ok-out :result :photo-url)))
|
||||||
|
|
||||||
|
(t/is (not (th/success? ko-out)))
|
||||||
|
(t/is (= :not-found (th/ex-type (:error ko-out))))
|
||||||
|
(t/is (= :profile-not-found (th/ex-code (:error ko-out))))))
|
||||||
|
|
||||||
|
(t/deftest get-profile-by-id-success-and-not-found
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true
|
||||||
|
:fullname "Lookup by Id"})
|
||||||
|
ok-out (management-command-with-nitrate! {::th/type :get-profile-by-id
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:id (:id profile)})
|
||||||
|
ko-out (management-command-with-nitrate! {::th/type :get-profile-by-id
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:id (uuid/random)})]
|
||||||
|
(t/is (th/success? ok-out))
|
||||||
|
(t/is (= (:id profile) (-> ok-out :result :id)))
|
||||||
|
(t/is (= "Lookup by Id" (-> ok-out :result :name)))
|
||||||
|
(t/is (nil? (-> ok-out :result :photo-url)))
|
||||||
|
|
||||||
|
(t/is (not (th/success? ko-out)))
|
||||||
|
(t/is (= :not-found (th/ex-type (:error ko-out))))
|
||||||
|
(t/is (= :profile-not-found (th/ex-code (:error ko-out))))))
|
||||||
|
|
||||||
|
(t/deftest get-org-invitations-returns-valid-deduped-by-email
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org-summary {:id org-id
|
||||||
|
:teams [{:id (:id team-1)}
|
||||||
|
{:id (:id team-2)}]}
|
||||||
|
params {::th/type :get-org-invitations
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id}]
|
||||||
|
|
||||||
|
;; Same email appears in org and team invitations; only one should be returned.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "dup@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "dup@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "admin"
|
||||||
|
:valid-until (ct/in-future "72h")})
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "valid@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
;; Expired invitation should be ignored.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "expired@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-past "1h")})
|
||||||
|
|
||||||
|
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
result (:result out)
|
||||||
|
emails (->> result (map :email) set)
|
||||||
|
dedup (->> result
|
||||||
|
(filter #(= "dup@example.com" (:email %)))
|
||||||
|
first)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= #{"dup@example.com" "valid@example.com"} emails))
|
||||||
|
(t/is (= 2 (count result)))
|
||||||
|
(t/is (some? (:id dedup)))
|
||||||
|
(t/is (some? (:sent-at dedup)))
|
||||||
|
(t/is (nil? (:organization-id dedup)))
|
||||||
|
(t/is (nil? (:team-id dedup)))
|
||||||
|
(t/is (nil? (:role dedup)))
|
||||||
|
(t/is (nil? (:valid-until dedup))))))
|
||||||
|
|
||||||
|
(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org-summary {:id org-id
|
||||||
|
:teams []}
|
||||||
|
params {::th/type :get-org-invitations
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id}]
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "org-only@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
result (:result out)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count result)))
|
||||||
|
(t/is (= "org-only@example.com" (-> result first :email)))
|
||||||
|
(t/is (some? (-> result first :sent-at))))))
|
||||||
|
|
||||||
|
(t/deftest get-org-invitations-returns-existing-profile-data
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
invited (th/create-profile* 2 {:is-active true
|
||||||
|
:fullname "Invited User"})
|
||||||
|
photo-id (uuid/random)
|
||||||
|
_ (th/db-insert! :storage-object {:id photo-id
|
||||||
|
:backend "assets-fs"})
|
||||||
|
_ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org-summary {:id org-id
|
||||||
|
:teams []}
|
||||||
|
params {::th/type :get-org-invitations
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id}]
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to (:email invited)
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
invitation (-> out :result first)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= "Invited User" (:name invitation)))
|
||||||
|
(t/is (some? (:sent-at invitation)))
|
||||||
|
(t/is (str/ends-with? (:photo-url invitation)
|
||||||
|
(str "/assets/by-id/" photo-id))))))
|
||||||
|
|
||||||
|
(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org-summary {:id org-id
|
||||||
|
:teams [{:id (:id team-1)}
|
||||||
|
{:id (:id team-2)}]}
|
||||||
|
target-email "target@example.com"
|
||||||
|
params {::th/type :delete-org-invitations
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id
|
||||||
|
:email "TARGET@example.com"}]
|
||||||
|
|
||||||
|
;; Should be deleted: org-level invitation for same org+email.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to target-email
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should be deleted: team-level invitation for teams belonging to org summary.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to target-email
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-past "1h")})
|
||||||
|
|
||||||
|
;; Should remain: different email.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "other@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should remain: same email but outside org scope.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id outside-team)
|
||||||
|
:org-id nil
|
||||||
|
:email-to target-email
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
remaining-target (th/db-query :team-invitation {:email-to target-email})
|
||||||
|
remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
(t/is (= 1 (count remaining-target)))
|
||||||
|
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
|
||||||
|
(t/is (= 1 (count remaining-other))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Tests: remove-from-org
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- make-org-summary
|
||||||
|
[& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams]
|
||||||
|
:or {your-penpot-teams [] org-teams []}}]
|
||||||
|
{:id organization-id
|
||||||
|
:name organization-name
|
||||||
|
:owner-id owner-id
|
||||||
|
:teams (into
|
||||||
|
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
|
||||||
|
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
||||||
|
|
||||||
|
(defn- nitrate-call-mock
|
||||||
|
[org-summary]
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
:get-org-membership {:organization-id (:id org-summary)
|
||||||
|
:is-member true}
|
||||||
|
:remove-profile-from-org nil
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-happy-path-no-extra-teams
|
||||||
|
;; User is only in its default team (which has files); it should be
|
||||||
|
;; kept, renamed and unset as default. A notification must be sent.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-team (th/create-team* 1 {:profile-id (:id user)})
|
||||||
|
project (th/create-project* 1 {:profile-id (:id user)
|
||||||
|
:team-id (:id org-team)})
|
||||||
|
_ (th/create-file* 1 {:profile-id (:id user)
|
||||||
|
:project-id (:id project)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [])
|
||||||
|
calls (atom [])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [_bus & {:keys [topic message]}]
|
||||||
|
(swap! calls conj {:topic topic :message message}))]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
|
||||||
|
;; default team preserved, renamed and unset as default
|
||||||
|
(let [team (th/db-get :team {:id (:id org-team)})]
|
||||||
|
(t/is (false? (:is-default team)))
|
||||||
|
(t/is (str/starts-with? (:name team) "[Acme Org] ")))
|
||||||
|
|
||||||
|
;; exactly one notification sent to the user
|
||||||
|
(t/is (= 1 (count @calls)))
|
||||||
|
(let [msg (-> @calls first :message)]
|
||||||
|
(t/is (= :user-org-change (:type msg)))
|
||||||
|
(t/is (= (:id user) (:topic msg)))
|
||||||
|
(t/is (= organization-id (:organization-id msg)))
|
||||||
|
(t/is (= "Acme Org" (:organization-name msg)))
|
||||||
|
(t/is (= "dashboard.user-no-longer-belong-org" (:notification msg))))))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-deletes-empty-default-team
|
||||||
|
;; When the default team has no files it should be soft-deleted.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-team (th/create-team* 2 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [& _] nil)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
|
||||||
|
(t/is (some? (:deleted-at team))))))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-deletes-sole-owner-team
|
||||||
|
;; When the user is the sole member of an org team it should be deleted.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 3 {:profile-id (:id user)})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [& _] nil)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})]
|
||||||
|
(t/is (some? (:deleted-at team))))))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-transfers-ownership-of-multi-member-team
|
||||||
|
;; When the user owns a team that has another non-owner member, ownership
|
||||||
|
;; is transferred to that member by the endpoint automatically.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
candidate (th/create-profile* 3 {:is-active true})
|
||||||
|
extra-team (th/create-team* 4 {:profile-id (:id user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
||||||
|
:profile-id (:id candidate)
|
||||||
|
:role :editor})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [& _] nil)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
;; user no longer in extra-team
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
;; candidate promoted to owner
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})]
|
||||||
|
(t/is (true? (:is-owner rel))))))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-exits-non-owned-team
|
||||||
|
;; When the user is a non-owner member of an org team, they simply leave.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 5 {:profile-id (:id org-owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:role :editor})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [& _] nil)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
;; user no longer a member of extra-team
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
;; team still exists for the owner
|
||||||
|
(let [team (th/db-get :team {:id (:id extra-team)})]
|
||||||
|
(t/is (some? team)))))
|
||||||
|
|
||||||
|
(t/deftest remove-from-org-error-nobody-to-reassign
|
||||||
|
;; When the user owns a multi-member team but every other member is
|
||||||
|
;; also an owner, the auto-selection query finds nobody and raises.
|
||||||
|
(let [other-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 6 {:profile-id (:id user)})
|
||||||
|
;; add other-owner to the team and make them co-owner directly in DB
|
||||||
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
||||||
|
:profile-id (:id other-owner)
|
||||||
|
:role :editor})
|
||||||
|
_ (th/db-update! :team-profile-rel
|
||||||
|
{:is-owner true :is-admin false}
|
||||||
|
{:team-id (:id extra-team) :profile-id (:id other-owner)})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id other-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
|
||||||
|
mbus/pub! (fn [& _] nil)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :remove-from-org
|
||||||
|
::rpc/profile-id (:id other-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :nobody-to-reassign-team (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
;; Tests: get-remove-from-org-summary
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(t/deftest get-remove-from-org-summary-no-extra-teams
|
||||||
|
;; User only has a default team — nothing to delete/transfer/exit.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-team (th/create-team* 1 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :get-remove-from-org-summary
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {:teams-to-delete 0
|
||||||
|
:teams-to-transfer 0
|
||||||
|
:teams-to-exit 0}
|
||||||
|
(:result out)))))
|
||||||
|
|
||||||
|
(t/deftest get-remove-from-org-summary-with-teams-to-delete
|
||||||
|
;; User owns a sole-member extra org team → 1 to delete.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 3 {:profile-id (:id user)})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :get-remove-from-org-summary
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {:teams-to-delete 1
|
||||||
|
:teams-to-transfer 0
|
||||||
|
:teams-to-exit 0}
|
||||||
|
(:result out)))))
|
||||||
|
|
||||||
|
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
|
||||||
|
;; User owns a multi-member extra org team → 1 to transfer.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
candidate (th/create-profile* 3 {:is-active true})
|
||||||
|
extra-team (th/create-team* 4 {:profile-id (:id user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
||||||
|
:profile-id (:id candidate)
|
||||||
|
:role :editor})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :get-remove-from-org-summary
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {:teams-to-delete 0
|
||||||
|
:teams-to-transfer 1
|
||||||
|
:teams-to-exit 0}
|
||||||
|
(:result out)))))
|
||||||
|
|
||||||
|
(t/deftest get-remove-from-org-summary-with-teams-to-exit
|
||||||
|
;; User is a non-owner member of an org team → 1 to exit.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 5 {:profile-id (:id org-owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:role :editor})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :get-remove-from-org-summary
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {:teams-to-delete 0
|
||||||
|
:teams-to-transfer 0
|
||||||
|
:teams-to-exit 1}
|
||||||
|
(:result out)))))
|
||||||
|
|
||||||
|
(t/deftest get-remove-from-org-summary-does-not-mutate
|
||||||
|
;; Calling the summary endpoint must not modify any teams.
|
||||||
|
(let [org-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
user (th/create-profile* 2 {:is-active true})
|
||||||
|
extra-team (th/create-team* 6 {:profile-id (:id user)})
|
||||||
|
org-team (th/create-team* 99 {:profile-id (:id user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Acme Org"
|
||||||
|
:owner-id (:id org-owner)
|
||||||
|
:your-penpot-teams [(:id org-team)]
|
||||||
|
:org-teams [(:id extra-team)])
|
||||||
|
_ (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(management-command-with-nitrate!
|
||||||
|
{::th/type :get-remove-from-org-summary
|
||||||
|
::rpc/profile-id (:id org-owner)
|
||||||
|
:profile-id (:id user)
|
||||||
|
:organization-id organization-id
|
||||||
|
:default-team-id (:id org-team)}))]
|
||||||
|
;; Both teams must still exist and be undeleted
|
||||||
|
(let [t1 (th/db-get :team {:id (:id org-team)})]
|
||||||
|
(t/is (some? t1))
|
||||||
|
(t/is (nil? (:deleted-at t1))))
|
||||||
|
(let [t2 (th/db-get :team {:id (:id extra-team)})]
|
||||||
|
(t/is (some? t2))
|
||||||
|
(t/is (nil? (:deleted-at t2))))
|
||||||
|
;; User must still be a member of both teams
|
||||||
|
(let [rel1 (th/db-get :team-profile-rel {:team-id (:id org-team) :profile-id (:id user)})]
|
||||||
|
(t/is (some? rel1)))
|
||||||
|
(let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
||||||
|
(t/is (some? rel2)))))
|
||||||
@ -6,9 +6,7 @@
|
|||||||
|
|
||||||
(ns backend-tests.rpc-media-test
|
(ns backend-tests.rpc-media-test
|
||||||
(:require
|
(:require
|
||||||
[app.common.time :as ct]
|
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@ -16,7 +14,10 @@
|
|||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[mockery.core :refer [with-mocks]]))
|
[datoteka.io :as io]
|
||||||
|
[mockery.core :refer [with-mocks]])
|
||||||
|
(:import
|
||||||
|
java.io.RandomAccessFile))
|
||||||
|
|
||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
@ -260,7 +261,7 @@
|
|||||||
:is-shared false})
|
:is-shared false})
|
||||||
|
|
||||||
_ (th/db-update! :file
|
_ (th/db-update! :file
|
||||||
{:deleted-at (ct/now)}
|
{:deleted-at (app.common.time/now)}
|
||||||
{:id (:id file)})
|
{:id (:id file)})
|
||||||
|
|
||||||
mfile {:filename "sample.jpg"
|
mfile {:filename "sample.jpg"
|
||||||
@ -378,3 +379,325 @@
|
|||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
(t/is (= :validation (:type (ex-data err))))
|
(t/is (= :validation (:type (ex-data err))))
|
||||||
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------
|
||||||
|
;; Helpers for chunked-upload tests
|
||||||
|
;; --------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn- split-file-into-chunks
|
||||||
|
"Splits the file at `path` into byte-array chunks of at most
|
||||||
|
`chunk-size` bytes. Returns a vector of byte arrays."
|
||||||
|
[path chunk-size]
|
||||||
|
(let [file (RandomAccessFile. (str path) "r")
|
||||||
|
length (.length file)]
|
||||||
|
(try
|
||||||
|
(loop [offset 0 chunks []]
|
||||||
|
(if (>= offset length)
|
||||||
|
chunks
|
||||||
|
(let [remaining (- length offset)
|
||||||
|
size (min chunk-size remaining)
|
||||||
|
buf (byte-array size)]
|
||||||
|
(.seek file offset)
|
||||||
|
(.readFully file buf)
|
||||||
|
(recur (+ offset size) (conj chunks buf)))))
|
||||||
|
(finally
|
||||||
|
(.close file)))))
|
||||||
|
|
||||||
|
(defn- make-chunk-mfile
|
||||||
|
"Writes `data` (byte array) to a tempfile and returns a map
|
||||||
|
compatible with `media/schema:upload`."
|
||||||
|
[data mtype]
|
||||||
|
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")]
|
||||||
|
(io/write* tmp data)
|
||||||
|
{:filename "chunk"
|
||||||
|
:path tmp
|
||||||
|
:mtype mtype
|
||||||
|
:size (alength data)}))
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------
|
||||||
|
;; Chunked-upload tests
|
||||||
|
;; --------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn- create-session!
|
||||||
|
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
|
||||||
|
[prof total-chunks]
|
||||||
|
(let [out (th/command! {::th/type :create-upload-session
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:total-chunks total-chunks})]
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(:session-id (:result out))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-happy-path
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
_ (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 (:default-project-id prof)
|
||||||
|
:is-shared false})
|
||||||
|
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||||
|
chunks (split-file-into-chunks source-path 110000) ; ~107 KB each
|
||||||
|
mtype "image/jpeg"
|
||||||
|
total-size (reduce + (map alength chunks))
|
||||||
|
session-id (create-session! prof (count chunks))]
|
||||||
|
|
||||||
|
(t/is (= 3 (count chunks)))
|
||||||
|
|
||||||
|
;; --- 1. Upload chunks ---
|
||||||
|
(doseq [[idx chunk-data] (map-indexed vector chunks)]
|
||||||
|
(let [mfile (make-chunk-mfile chunk-data mtype)
|
||||||
|
out (th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index idx
|
||||||
|
:content mfile})]
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(t/is (= session-id (:session-id (:result out))))
|
||||||
|
(t/is (= idx (:index (:result out))))))
|
||||||
|
|
||||||
|
;; --- 2. Assemble ---
|
||||||
|
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "assembled-image"
|
||||||
|
:mtype mtype})]
|
||||||
|
|
||||||
|
(t/is (nil? (:error assemble-out)))
|
||||||
|
(let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)]
|
||||||
|
(t/is (= (:id file) (:file-id result)))
|
||||||
|
(t/is (= 800 (:width result)))
|
||||||
|
(t/is (= 800 (:height result)))
|
||||||
|
(t/is (= mtype (:mtype result)))
|
||||||
|
(t/is (uuid? media-id))
|
||||||
|
(t/is (uuid? thumbnail-id))
|
||||||
|
|
||||||
|
(let [storage (:app.storage/storage th/*system*)
|
||||||
|
mobj1 (sto/get-object storage media-id)
|
||||||
|
mobj2 (sto/get-object storage thumbnail-id)]
|
||||||
|
(t/is (sto/object? mobj1))
|
||||||
|
(t/is (sto/object? mobj2))
|
||||||
|
(t/is (= total-size (:size mobj1))))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-idempotency
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
_ (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 (:default-project-id prof)
|
||||||
|
:is-shared false})
|
||||||
|
media-id (uuid/next)
|
||||||
|
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||||
|
chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file
|
||||||
|
mtype "image/jpeg"
|
||||||
|
mfile (make-chunk-mfile (first chunks) mtype)
|
||||||
|
session-id (create-session! prof 1)]
|
||||||
|
|
||||||
|
(th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index 0
|
||||||
|
:content mfile})
|
||||||
|
|
||||||
|
;; First assemble succeeds; session row is deleted afterwards
|
||||||
|
(let [out1 (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "sample"
|
||||||
|
:mtype mtype
|
||||||
|
:id media-id})]
|
||||||
|
(t/is (nil? (:error out1)))
|
||||||
|
(t/is (= media-id (:id (:result out1)))))
|
||||||
|
|
||||||
|
;; Second assemble with the same session-id must fail because the
|
||||||
|
;; session row has been deleted after the first assembly
|
||||||
|
(let [out2 (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "sample"
|
||||||
|
:mtype mtype
|
||||||
|
:id media-id})]
|
||||||
|
(t/is (some? (:error out2)))
|
||||||
|
(t/is (= :not-found (-> out2 :error ex-data :type)))
|
||||||
|
(t/is (= :object-not-found (-> out2 :error ex-data :code))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-no-permission
|
||||||
|
;; A second profile must not be able to upload chunks into a session
|
||||||
|
;; that belongs to another profile: the DB lookup includes profile-id,
|
||||||
|
;; so the session will not be found.
|
||||||
|
(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}
|
||||||
|
|
||||||
|
;; prof2 tries to upload a chunk into prof1's session
|
||||||
|
out (th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof2)
|
||||||
|
:session-id session-id
|
||||||
|
:index 0
|
||||||
|
:content mfile})]
|
||||||
|
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :not-found (-> out :error ex-data :type)))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-invalid-media-type
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
_ (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 (:default-project-id prof)
|
||||||
|
:is-shared false})
|
||||||
|
session-id (create-session! prof 1)
|
||||||
|
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||||
|
mfile {:filename "sample.jpg"
|
||||||
|
:path source-path
|
||||||
|
:mtype "image/jpeg"
|
||||||
|
:size 312043}]
|
||||||
|
|
||||||
|
(th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index 0
|
||||||
|
:content mfile})
|
||||||
|
|
||||||
|
;; Assemble with a wrong mtype should fail validation
|
||||||
|
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "bad-type"
|
||||||
|
:mtype "application/octet-stream"})]
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-missing-chunks
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
_ (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 (:default-project-id prof)
|
||||||
|
:is-shared false})
|
||||||
|
;; Session expects 3 chunks
|
||||||
|
session-id (create-session! prof 3)
|
||||||
|
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||||
|
mfile {:filename "sample.jpg"
|
||||||
|
:path source-path
|
||||||
|
:mtype "image/jpeg"
|
||||||
|
:size 312043}]
|
||||||
|
|
||||||
|
;; Upload only 1 chunk
|
||||||
|
(th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index 0
|
||||||
|
:content mfile})
|
||||||
|
|
||||||
|
;; Assemble: session says 3 expected, only 1 stored → :missing-chunks
|
||||||
|
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "incomplete"
|
||||||
|
:mtype "image/jpeg"})]
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-session-not-found
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
_ (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 (:default-project-id prof)
|
||||||
|
:is-shared false})
|
||||||
|
bogus-id (uuid/next)]
|
||||||
|
|
||||||
|
;; Assemble with a session-id that was never created
|
||||||
|
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id bogus-id
|
||||||
|
:file-id (:id file)
|
||||||
|
:is-local true
|
||||||
|
:name "ghost"
|
||||||
|
:mtype "image/jpeg"})]
|
||||||
|
(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-over-chunk-limit
|
||||||
|
;; Verify that requesting more chunks than the configured maximum
|
||||||
|
;; (quotes-upload-chunks-per-session) raises a :restriction error.
|
||||||
|
(with-mocks [mock {:target 'app.config/get
|
||||||
|
:return (th/config-get-mock
|
||||||
|
{:quotes-upload-chunks-per-session 3})}]
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
out (th/command! {::th/type :create-upload-session
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:total-chunks 4})]
|
||||||
|
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
|
||||||
|
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-invalid-chunk-index
|
||||||
|
;; Both a negative index and an index >= total-chunks must be
|
||||||
|
;; rejected with a :validation / :invalid-chunk-index error.
|
||||||
|
(let [prof (th/create-profile* 1)
|
||||||
|
session-id (create-session! prof 2)
|
||||||
|
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||||
|
mfile {:filename "sample.jpg"
|
||||||
|
:path source-path
|
||||||
|
:mtype "image/jpeg"
|
||||||
|
:size 312043}]
|
||||||
|
|
||||||
|
;; index == total-chunks (out of range)
|
||||||
|
(let [out (th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index 2
|
||||||
|
:content mfile})]
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))
|
||||||
|
|
||||||
|
;; negative index
|
||||||
|
(let [out (th/command! {::th/type :upload-chunk
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:session-id session-id
|
||||||
|
:index -1
|
||||||
|
:content mfile})]
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :validation (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
|
||||||
|
|
||||||
|
(t/deftest chunked-upload-sessions-per-profile-quota
|
||||||
|
;; With the session limit set to 2, creating a third session for the
|
||||||
|
;; same profile must fail with :restriction / :max-quote-reached.
|
||||||
|
;; The :quotes flag is already enabled by the test fixture.
|
||||||
|
(with-mocks [mock {:target 'app.config/get
|
||||||
|
:return (th/config-get-mock
|
||||||
|
{:quotes-upload-sessions-per-profile 2})}]
|
||||||
|
(let [prof (th/create-profile* 1)]
|
||||||
|
|
||||||
|
;; First two sessions succeed
|
||||||
|
(create-session! prof 1)
|
||||||
|
(create-session! prof 1)
|
||||||
|
|
||||||
|
;; Third session must be rejected
|
||||||
|
(let [out (th/command! {::th/type :create-upload-session
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:total-chunks 1})]
|
||||||
|
(t/is (some? (:error out)))
|
||||||
|
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||||
|
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
|
||||||
|
|||||||
686
backend/test/backend_tests/rpc_nitrate_test.clj
Normal file
686
backend/test/backend_tests/rpc_nitrate_test.clj
Normal file
@ -0,0 +1,686 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.rpc-nitrate-test
|
||||||
|
(:require
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.db :as-alias db]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.nitrate]
|
||||||
|
[backend-tests.helpers :as th]
|
||||||
|
[clojure.test :as t]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
(t/use-fixtures :once th/state-init)
|
||||||
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Helpers
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- make-org-summary
|
||||||
|
[& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams]
|
||||||
|
:or {your-penpot-teams [] org-teams []}}]
|
||||||
|
{:id organization-id
|
||||||
|
:name organization-name
|
||||||
|
:owner-id owner-id
|
||||||
|
:teams (into
|
||||||
|
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
|
||||||
|
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
||||||
|
|
||||||
|
(defn- nitrate-call-mock
|
||||||
|
"Creates a mock for nitrate/call that returns the given org-summary for
|
||||||
|
:get-org-summary, a valid membership for :get-org-membership, and nil for
|
||||||
|
any other method."
|
||||||
|
[org-summary]
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
:get-org-membership {:is-member true
|
||||||
|
:organization-id (:id org-summary)}
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Tests
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(t/deftest leave-org-happy-path-no-extra-teams
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
project (th/create-project* 99 {:profile-id (:id profile-user)
|
||||||
|
:team-id (:id org-default-team)})
|
||||||
|
_ (th/create-file* 99 {:profile-id (:id profile-user)
|
||||||
|
:project-id (:id project)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
;; The user's personal penpot team in the org context
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
|
||||||
|
;; The personal team must be renamed with the org prefix and
|
||||||
|
;; unset as a default team.
|
||||||
|
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||||
|
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
||||||
|
(t/is (false? (:is-default team))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-deletes-org-default-team-when-empty
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 98 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; Empty org default team should be soft-deleted.
|
||||||
|
(let [team (th/db-get :team {:id your-penpot-id} {::db/remove-deleted false})]
|
||||||
|
(t/is (some? (:deleted-at team))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-keeps-and-renames-org-default-team-when-has-files
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
|
||||||
|
project (th/create-project* 97 {:profile-id (:id profile-user)
|
||||||
|
:team-id (:id org-default-team)})
|
||||||
|
_ (th/create-file* 97 {:profile-id (:id profile-user)
|
||||||
|
:project-id (:id project)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; Non-empty org default team should remain and be renamed.
|
||||||
|
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||||
|
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
||||||
|
(t/is (false? (:is-default team)))
|
||||||
|
(t/is (nil? (:deleted-at team))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-with-teams-to-delete
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-user is the sole owner/member of team1
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete [(:id team1)]
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; team1 should be scheduled for deletion (deleted-at set)
|
||||||
|
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
|
||||||
|
(t/is (some? (:deleted-at team))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-with-ownership-transfer
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-user owns team1; profile-owner is also a member
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; profile-user should no longer be a member of team1
|
||||||
|
(let [rel (th/db-get :team-profile-rel
|
||||||
|
{:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
|
||||||
|
;; profile-owner should have been promoted to owner
|
||||||
|
(let [rel (th/db-get :team-profile-rel
|
||||||
|
{:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)})]
|
||||||
|
(t/is (true? (:is-owner rel))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-exit-as-non-owner
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-owner owns team1; profile-user is a non-owner member
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-user)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave [{:id (:id team1)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
;; (th/print-result! out)
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; profile-user should no longer be a member of team1
|
||||||
|
(let [rel (th/db-get :team-profile-rel
|
||||||
|
{:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
|
||||||
|
;; The team itself should still exist
|
||||||
|
(let [team (th/db-get :team {:id (:id team1)})]
|
||||||
|
(t/is (nil? (:deleted-at team))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-org-owner-cannot-leave
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
;; profile-owner IS the org owner in the org-summary
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-owner)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :org-owner-cannot-leave (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-invalid-default-team-id
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; Pass a random UUID that is not in the your-penpot-teams list
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id (uuid/random)
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Unit Tests for calculate-valid-teams
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private calculate-valid-teams
|
||||||
|
(or (ns-resolve 'app.rpc.commands.nitrate 'calculate-valid-teams)
|
||||||
|
(throw (ex-info "Unable to resolve calculate-valid-teams"
|
||||||
|
{:ns 'app.rpc.commands.nitrate
|
||||||
|
:symbol 'calculate-valid-teams}))))
|
||||||
|
|
||||||
|
(defn- make-team [id & {:keys [is-owner num-members member-ids]
|
||||||
|
:or {is-owner false num-members 1 member-ids []}}]
|
||||||
|
{:id id :is-owner is-owner :num-members num-members :member-ids member-ids})
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-no-org-teams
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
default-team (make-team default-id)
|
||||||
|
result (calculate-valid-teams [default-team] default-id)]
|
||||||
|
(t/is (= default-team (:valid-default-team result)))
|
||||||
|
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||||
|
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||||
|
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-default-not-found
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
other-id (uuid/random)
|
||||||
|
other-team (make-team other-id)
|
||||||
|
;; default-id is not in org-teams at all
|
||||||
|
result (calculate-valid-teams [other-team] default-id)]
|
||||||
|
(t/is (nil? (:valid-default-team result)))))
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-sole-owner-team
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
team-id (uuid/random)
|
||||||
|
default (make-team default-id)
|
||||||
|
solo-team (make-team team-id :is-owner true :num-members 1)
|
||||||
|
result (calculate-valid-teams [default solo-team] default-id)]
|
||||||
|
(t/is (contains? (:valid-teams-to-delete-ids result) team-id))
|
||||||
|
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||||
|
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-owned-multi-member-team
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
team-id (uuid/random)
|
||||||
|
default (make-team default-id)
|
||||||
|
;; owner of a team with 3 members — must be transferred
|
||||||
|
multi-team (make-team team-id :is-owner true :num-members 3)
|
||||||
|
result (calculate-valid-teams [default multi-team] default-id)]
|
||||||
|
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||||
|
(t/is (= [team-id] (map :id (:valid-teams-to-transfer result))))
|
||||||
|
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-non-owner-multi-member-team
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
team-id (uuid/random)
|
||||||
|
default (make-team default-id)
|
||||||
|
;; non-owner member of a team with 2 members — can just exit
|
||||||
|
exit-team (make-team team-id :is-owner false :num-members 2)
|
||||||
|
result (calculate-valid-teams [default exit-team] default-id)]
|
||||||
|
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||||
|
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||||
|
(t/is (= [team-id] (map :id (:valid-teams-to-exit result))))))
|
||||||
|
|
||||||
|
(t/deftest calculate-valid-teams-mixed
|
||||||
|
(let [default-id (uuid/random)
|
||||||
|
solo-id (uuid/random)
|
||||||
|
transfer-id (uuid/random)
|
||||||
|
exit-id (uuid/random)
|
||||||
|
default (make-team default-id)
|
||||||
|
solo-team (make-team solo-id :is-owner true :num-members 1)
|
||||||
|
transfer-team (make-team transfer-id :is-owner true :num-members 2)
|
||||||
|
exit-team (make-team exit-id :is-owner false :num-members 3)
|
||||||
|
result (calculate-valid-teams [default solo-team transfer-team exit-team] default-id)]
|
||||||
|
(t/is (= #{solo-id} (:valid-teams-to-delete-ids result)))
|
||||||
|
(t/is (= [transfer-id] (map :id (:valid-teams-to-transfer result))))
|
||||||
|
(t/is (= [exit-id] (map :id (:valid-teams-to-exit result))))
|
||||||
|
(t/is (= default-id (:id (:valid-default-team result))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Integration: combined delete + leave
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(t/deftest leave-org-combined-delete-and-leave
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; team1: profile-user is sole owner — must delete
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
;; team2: profile-user owns it, profile-owner is also member — must transfer
|
||||||
|
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team2)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
;; team3: profile-owner owns it, profile-user is non-owner member — can exit
|
||||||
|
team3 (th/create-team* 3 {:profile-id (:id profile-owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team3)
|
||||||
|
:profile-id (:id profile-user)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1) (:id team2) (:id team3)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete [(:id team1)]
|
||||||
|
:teams-to-leave [{:id (:id team2) :reassign-to (:id profile-owner)}
|
||||||
|
{:id (:id team3)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (th/success? out))
|
||||||
|
|
||||||
|
;; team1 should be soft-deleted
|
||||||
|
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
|
||||||
|
(t/is (some? (:deleted-at team))))
|
||||||
|
|
||||||
|
;; profile-user should no longer be a member of team2
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
|
||||||
|
;; profile-owner should now own team2
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-owner)})]
|
||||||
|
(t/is (true? (:is-owner rel))))
|
||||||
|
|
||||||
|
;; profile-user should no longer be a member of team3
|
||||||
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id team3) :profile-id (:id profile-user)})]
|
||||||
|
(t/is (nil? rel)))
|
||||||
|
|
||||||
|
;; team3 itself should still exist (profile-owner is still there)
|
||||||
|
(let [team (th/db-get :team {:id (:id team3)})]
|
||||||
|
(t/is (some? team)))))))
|
||||||
|
(t/deftest leave-org-error-teams-to-delete-incomplete
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-user is the sole owner/member of both team1 and team2
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1) (:id team2)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; Only team1 is listed; team2 is also a sole-owner team and must be included
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete [(:id team1)]
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-cannot-delete-multi-member-team
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; team1 has two members: profile-user (owner) and profile-owner (editor)
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; team1 has 2 members so it is not a valid deletion candidate
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete [(:id team1)]
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-teams-to-leave-incomplete
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-user owns team1, which also has profile-owner as editor
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; team1 must be transferred (owner + multiple members) but is absent
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave []}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-reassign-to-self
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; reassign-to points to the profile that is leaving — not allowed
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-reassign-to-non-member
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
profile-other (th/create-profile* 3 {:is-active true})
|
||||||
|
;; team1 has profile-user (owner) and profile-owner (editor) — NOT profile-other
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-owner)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; profile-other is not a member of team1
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest leave-org-error-reassign-on-non-owned-team
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
;; profile-owner owns team1; profile-user is just a non-owner member
|
||||||
|
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team1)
|
||||||
|
:profile-id (:id profile-user)
|
||||||
|
:role :editor})
|
||||||
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id team1)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||||
|
;; profile-user is not the owner so providing reassign-to is invalid
|
||||||
|
(let [data {::th/type :leave-org
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:name "Test Org"
|
||||||
|
:default-team-id your-penpot-id
|
||||||
|
:teams-to-delete []
|
||||||
|
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
@ -125,7 +125,20 @@
|
|||||||
out (th/command! data)]
|
out (th/command! data)]
|
||||||
|
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))))))
|
(t/is (nil? (:error out)))))
|
||||||
|
|
||||||
|
(t/testing "delete photo clears photo-id"
|
||||||
|
(let [data {::th/type :delete-profile-photo
|
||||||
|
::rpc/profile-id (:id profile)}
|
||||||
|
out (th/command! data)]
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(t/is (nil? (:result out))))
|
||||||
|
|
||||||
|
(let [data {::th/type :get-profile
|
||||||
|
::rpc/profile-id (:id profile)}
|
||||||
|
out (th/command! data)]
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(t/is (nil? (:photo-id (:result out))))))))
|
||||||
|
|
||||||
(t/deftest profile-deletion-1
|
(t/deftest profile-deletion-1
|
||||||
(let [prof (th/create-profile* 1)
|
(let [prof (th/create-profile* 1)
|
||||||
@ -380,7 +393,9 @@
|
|||||||
(let [data {::th/type :prepare-register-profile
|
(let [data {::th/type :prepare-register-profile
|
||||||
:email "user@example.com"
|
:email "user@example.com"
|
||||||
:fullname "foobar"
|
:fullname "foobar"
|
||||||
:password "foobar"}
|
:password "foobar"
|
||||||
|
:utm_campaign "utma"
|
||||||
|
:mtm_campaign "mtma"}
|
||||||
out (th/command! data)
|
out (th/command! data)
|
||||||
token (get-in out [:result :token])]
|
token (get-in out [:result :token])]
|
||||||
(t/is (string? token))
|
(t/is (string? token))
|
||||||
@ -396,11 +411,9 @@
|
|||||||
|
|
||||||
;; try correct register
|
;; try correct register
|
||||||
(let [data {::th/type :register-profile
|
(let [data {::th/type :register-profile
|
||||||
:token token
|
:token token}
|
||||||
:utm_campaign "utma"
|
out (th/command! data)]
|
||||||
:mtm_campaign "mtma"}]
|
(t/is (nil? (:error out))))
|
||||||
(let [{:keys [result error]} (th/command! data)]
|
|
||||||
(t/is (nil? error))))
|
|
||||||
|
|
||||||
(let [profile (some-> (th/db-get :profile {:email "user@example.com"})
|
(let [profile (some-> (th/db-get :profile {:email "user@example.com"})
|
||||||
(profile/decode-row))]
|
(profile/decode-row))]
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.viewer :as viewer]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[datoteka.fs :as fs]))
|
[datoteka.fs :as fs]))
|
||||||
@ -16,6 +17,28 @@
|
|||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
|
(t/deftest obfuscate-email-happy-path
|
||||||
|
(t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com")))
|
||||||
|
(t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com")))
|
||||||
|
(t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com"))))
|
||||||
|
|
||||||
|
(t/deftest obfuscate-email-handles-domain-without-dot
|
||||||
|
;; `localhost`-style domains have no `.`; the previous implementation produced
|
||||||
|
;; a dangling-dot output like "a****@****." — now the trailing `.` is only
|
||||||
|
;; emitted when there actually is a TLD segment to append.
|
||||||
|
(t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost")))
|
||||||
|
(t/is (= "****@****" (viewer/obfuscate-email "x@y"))))
|
||||||
|
|
||||||
|
(t/deftest obfuscate-email-handles-malformed-input
|
||||||
|
;; These shapes must not throw — `obfuscate-email` runs while building the
|
||||||
|
;; view-only bundle for share-link viewers and an NPE here aborts the whole
|
||||||
|
;; RPC response. The previous implementation called `clojure.string/split`
|
||||||
|
;; on `nil` for the `no-@` case, raising NullPointerException.
|
||||||
|
(t/is (= "****@****" (viewer/obfuscate-email nil)))
|
||||||
|
(t/is (= "****@****" (viewer/obfuscate-email "")))
|
||||||
|
(t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3
|
||||||
|
(t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3
|
||||||
|
|
||||||
(t/deftest retrieve-bundle
|
(t/deftest retrieve-bundle
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
prof2 (th/create-profile* 2 {:is-active true})
|
prof2 (th/create-profile* 2 {:is-active true})
|
||||||
|
|||||||
@ -169,7 +169,8 @@
|
|||||||
(t/is (= 2 (:count res))))
|
(t/is (= 2 (:count res))))
|
||||||
|
|
||||||
;; run the touched gc task
|
;; run the touched gc task
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 2 (:freeze res)))
|
(t/is (= 2 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
@ -229,7 +230,8 @@
|
|||||||
(t/is (nil? (:error out2)))
|
(t/is (nil? (:error out2)))
|
||||||
|
|
||||||
;; run the touched gc task
|
;; run the touched gc task
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 5 (:freeze res)))
|
(t/is (= 5 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res)))
|
(t/is (= 0 (:delete res)))
|
||||||
|
|
||||||
@ -249,7 +251,8 @@
|
|||||||
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])
|
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])
|
||||||
|
|
||||||
;; Run the task again
|
;; Run the task again
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 2 (:freeze res)))
|
(t/is (= 2 (:freeze res)))
|
||||||
(t/is (= 3 (:delete res))))
|
(t/is (= 3 (:delete res))))
|
||||||
|
|
||||||
@ -295,7 +298,8 @@
|
|||||||
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
|
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
|
||||||
|
|
||||||
;; run the touched gc task
|
;; run the touched gc task
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 2 (:freeze res)))
|
(t/is (= 2 (:freeze res)))
|
||||||
(t/is (= 0 (:delete res))))
|
(t/is (= 0 (:delete res))))
|
||||||
|
|
||||||
@ -310,7 +314,8 @@
|
|||||||
(t/is (= 2 (:processed res))))
|
(t/is (= 2 (:processed res))))
|
||||||
|
|
||||||
;; run the touched gc task
|
;; run the touched gc task
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||||
|
(th/run-task! :storage-gc-touched {}))]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 2 (:delete res))))
|
(t/is (= 2 (:delete res))))
|
||||||
|
|
||||||
@ -336,7 +341,7 @@
|
|||||||
(t/is (= 0 (:delete res)))))
|
(t/is (= 0 (:delete res)))))
|
||||||
|
|
||||||
|
|
||||||
(binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))]
|
(binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))]
|
||||||
(let [res (th/run-task! :storage-gc-touched {})]
|
(let [res (th/run-task! :storage-gc-touched {})]
|
||||||
(t/is (= 0 (:freeze res)))
|
(t/is (= 0 (:freeze res)))
|
||||||
(t/is (= 1 (:delete res)))))
|
(t/is (= 1 (:delete res)))))
|
||||||
|
|||||||
@ -42,4 +42,6 @@
|
|||||||
(t/is (contains? data :avg-files-on-project))
|
(t/is (contains? data :avg-files-on-project))
|
||||||
(t/is (contains? data :max-projects-on-team))
|
(t/is (contains? data :max-projects-on-team))
|
||||||
(t/is (contains? data :avg-files-on-project))
|
(t/is (contains? data :avg-files-on-project))
|
||||||
(t/is (contains? data :version))))))
|
(t/is (contains? data :version))
|
||||||
|
(t/is (contains? data :email-domains))
|
||||||
|
(t/is (= ["nodomain.com"] (:email-domains data)))))))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user