mirror of
https://github.com/penpot/penpot.git
synced 2026-04-28 04:38:14 +00:00
Compare commits
1022 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5bfe6dda | ||
|
|
bd1e0fb23f | ||
|
|
8a8ebb7943 | ||
|
|
ea265da1f3 | ||
|
|
f4cf667d2f | ||
|
|
f8e40a1ca5 | ||
|
|
c41537eb55 | ||
|
|
82f1606377 | ||
|
|
839754715a | ||
|
|
db8aa9bccc | ||
|
|
ef2fe78aac | ||
|
|
a3b9d7bed7 | ||
|
|
57f1b80013 | ||
|
|
cbd5f7795b | ||
|
|
99f006d728 | ||
|
|
edccda2038 | ||
|
|
4867358428 | ||
|
|
c6bea65a48 | ||
|
|
e5314f4a13 | ||
|
|
9c6cc5ec32 | ||
|
|
feec89679a | ||
|
|
77c507000b | ||
|
|
a5a8ab5de6 | ||
|
|
5ee65c5efb | ||
|
|
7504c3b53e | ||
|
|
c4e508a606 | ||
|
|
37cba3355d | ||
|
|
6d9019c383 | ||
|
|
700f3e9c10 | ||
|
|
debfe5490f | ||
|
|
7031052c4e | ||
|
|
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
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -24,6 +24,9 @@
|
|||||||
/.clj-kondo/.cache
|
/.clj-kondo/.cache
|
||||||
/_dump
|
/_dump
|
||||||
/notes
|
/notes
|
||||||
|
/.opencode/package-lock.json
|
||||||
|
/plans
|
||||||
|
/prompts
|
||||||
/playground/
|
/playground/
|
||||||
/backend/*.md
|
/backend/*.md
|
||||||
!/backend/AGENTS.md
|
!/backend/AGENTS.md
|
||||||
@ -50,6 +53,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 +67,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 +85,4 @@
|
|||||||
/**/node_modules
|
/**/node_modules
|
||||||
/**/.yarn/*
|
/**/.yarn/*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
|
/.vscode
|
||||||
|
|||||||
29
.opencode/agents/commiter.md
Normal file
29
.opencode/agents/commiter.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: commiter
|
||||||
|
description: Git commit assistant following CONTRIBUTING.md commit rules
|
||||||
|
mode: subagent
|
||||||
|
---
|
||||||
|
|
||||||
|
Role: You are responsible for creating git commits for Penpot and must
|
||||||
|
follow the repository commit-format rules exactly. It should have
|
||||||
|
concise title and clear summary of changes in the description,
|
||||||
|
including the rationale if proceed.
|
||||||
|
|
||||||
|
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: Penpot 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`.
|
||||||
61
.opencode/agents/planner.md
Normal file
61
.opencode/agents/planner.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: Penpot Planner
|
||||||
|
description: Software architect for planning and analysis only
|
||||||
|
mode: primary
|
||||||
|
permission:
|
||||||
|
edit: ask
|
||||||
|
---
|
||||||
|
|
||||||
|
# Penpot Planner
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a Senior Software Architect working on Penpot, an open-source design
|
||||||
|
tool. Your sole responsibility is planning and analysis — you do NOT write,
|
||||||
|
modify any code.
|
||||||
|
|
||||||
|
You help users understand the codebase, design solutions, and create detailed
|
||||||
|
implementation plans that other agents or developers can execute. Document
|
||||||
|
everything they need to know: which files to touch for each task, code, testing,
|
||||||
|
docs they might need to check, how to test it. Give them the whole plan as
|
||||||
|
bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
|
||||||
|
|
||||||
|
Assume they are a skilled developer, but know almost nothing about our toolset
|
||||||
|
or problem domain. Assume they don't know good test design very well.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* Analyze the codebase architecture and identify affected modules.
|
||||||
|
* Read `AGENTS.md` files (root and per-module) to understand structure and
|
||||||
|
conventions.
|
||||||
|
* Search code using `ripgrep` skill (`rg`) to trace dependencies, find patterns,
|
||||||
|
and understand existing implementations.
|
||||||
|
* Break down complex features or bugs into atomic, actionable steps.
|
||||||
|
* Propose solutions with clear rationale, trade-offs, and sequencing.
|
||||||
|
* Identify risks, edge cases, and testing considerations.
|
||||||
|
|
||||||
|
Save plans to: plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
* You are **read-only** — never create, edit, or delete files.
|
||||||
|
* You do **not** run builds, tests, linters, or any commands that modify state.
|
||||||
|
* You do **not** create git commits or interact with version control.
|
||||||
|
* You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||||
|
`find`, `cat`).
|
||||||
|
* Your output is a structured plan or analysis, ready for handoff to an
|
||||||
|
engineer agent or developer.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
When producing a plan, structure it as:
|
||||||
|
|
||||||
|
1. **Context** — What is the problem or feature request?
|
||||||
|
2. **Affected modules** — Which parts of the codebase are involved?
|
||||||
|
3. **Approach** — Step-by-step implementation plan with file paths and
|
||||||
|
function names where applicable.
|
||||||
|
4. **Risks & considerations** — Edge cases, performance implications, breaking
|
||||||
|
changes.
|
||||||
|
5. **Testing strategy** — How to verify the implementation works correctly.
|
||||||
|
|
||||||
|
|
||||||
59
.opencode/agents/prompt-assistant.md
Normal file
59
.opencode/agents/prompt-assistant.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: Prompt Assistant
|
||||||
|
description: Refines and improves prompts for maximum clarity and effectiveness
|
||||||
|
mode: all
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prompt Assistant
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are an expert Prompt Engineer with strong knowledge of
|
||||||
|
penpot. Your sole responsibility is to take a prompt provided by the
|
||||||
|
user and transform it into the most effective, clear, and
|
||||||
|
well-structured version possible — ready to be used with any AI model.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* You do NOT execute tasks. You do NOT write code. You only design and
|
||||||
|
refine prompts
|
||||||
|
* Read the root `AGENTS.md` to understand the repository and application
|
||||||
|
architecture. Then read the `AGENTS.md` **only** for each affected module.
|
||||||
|
* Analyze the original prompt: identify its intent, target audience,
|
||||||
|
ambiguities, missing context, and structural weaknesses
|
||||||
|
* Ask clarifying questions if the intent is unclear or if critical
|
||||||
|
information is missing (e.g. target model, expected output format,
|
||||||
|
tone, constraints). Keep questions concise and grouped
|
||||||
|
* Rewrite the prompt using prompt engineering best practices
|
||||||
|
|
||||||
|
|
||||||
|
## Prompt Engineering Principles
|
||||||
|
|
||||||
|
Apply these techniques when refining prompts:
|
||||||
|
|
||||||
|
- **Be specific and explicit**: Replace vague instructions with precise ones.
|
||||||
|
- **Set the context**: Include background information the model needs to
|
||||||
|
perform well.
|
||||||
|
- **Specify the output format**: State the desired structure, length, tone,
|
||||||
|
or format (e.g. bullet list, JSON, step-by-step).
|
||||||
|
- **Add constraints**: Include what the model should avoid or not do.
|
||||||
|
- **Use examples** (few-shot): When applicable, suggest adding examples to
|
||||||
|
anchor the model's behaviour.
|
||||||
|
- **Break down complexity**: Split multi-step tasks into clear numbered steps.
|
||||||
|
- **Avoid ambiguity**: Remove pronouns and references that could be
|
||||||
|
misinterpreted.
|
||||||
|
- **Chain of thought**: For reasoning tasks, include "Think step by step."
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do NOT execute the prompt yourself.
|
||||||
|
- Do NOT answer the question inside the prompt.
|
||||||
|
- Do NOT add unnecessary verbosity — prompts should be as short as they can
|
||||||
|
be while remaining complete.
|
||||||
|
- Always preserve the user's original intent.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Refined Prompt: The improved, ready-to-use prompt. Print it for
|
||||||
|
immediate use and save it to
|
||||||
|
prompts/YYYY-MM-DD-<prompt-one-line-title>.md for future use.
|
||||||
210
.opencode/skills/bat-cat/SKILL.md
Normal file
210
.opencode/skills/bat-cat/SKILL.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
name: bat-cat
|
||||||
|
description: A cat clone with syntax highlighting, line numbers, and Git integration - a modern replacement for cat.
|
||||||
|
homepage: https://github.com/sharkdp/bat
|
||||||
|
metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"id":"brew","kind":"brew","formula":"bat","bins":["bat"],"label":"Install bat (brew)"},{"id":"apt","kind":"apt","package":"bat","bins":["bat"],"label":"Install bat (apt)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# bat - Better cat
|
||||||
|
|
||||||
|
`cat` with syntax highlighting, line numbers, and Git integration.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic usage
|
||||||
|
```bash
|
||||||
|
# View file with syntax highlighting
|
||||||
|
bat README.md
|
||||||
|
|
||||||
|
# Multiple files
|
||||||
|
bat file1.js file2.py
|
||||||
|
|
||||||
|
# With line numbers (default)
|
||||||
|
bat script.sh
|
||||||
|
|
||||||
|
# Without line numbers
|
||||||
|
bat -p script.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing modes
|
||||||
|
```bash
|
||||||
|
# Plain mode (like cat)
|
||||||
|
bat -p file.txt
|
||||||
|
|
||||||
|
# Show non-printable characters
|
||||||
|
bat -A file.txt
|
||||||
|
|
||||||
|
# Squeeze blank lines
|
||||||
|
bat -s file.txt
|
||||||
|
|
||||||
|
# Paging (auto for large files)
|
||||||
|
bat --paging=always file.txt
|
||||||
|
bat --paging=never file.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Syntax Highlighting
|
||||||
|
|
||||||
|
### Language detection
|
||||||
|
```bash
|
||||||
|
# Auto-detect from extension
|
||||||
|
bat script.py
|
||||||
|
|
||||||
|
# Force specific language
|
||||||
|
bat -l javascript config.txt
|
||||||
|
|
||||||
|
# Show all languages
|
||||||
|
bat --list-languages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
```bash
|
||||||
|
# List available themes
|
||||||
|
bat --list-themes
|
||||||
|
|
||||||
|
# Use specific theme
|
||||||
|
bat --theme="Monokai Extended" file.py
|
||||||
|
|
||||||
|
# Set default theme in config
|
||||||
|
# ~/.config/bat/config: --theme="Dracula"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Line Ranges
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show specific lines
|
||||||
|
bat -r 10:20 file.txt
|
||||||
|
|
||||||
|
# From line to end
|
||||||
|
bat -r 100: file.txt
|
||||||
|
|
||||||
|
# Start to specific line
|
||||||
|
bat -r :50 file.txt
|
||||||
|
|
||||||
|
# Multiple ranges
|
||||||
|
bat -r 1:10 -r 50:60 file.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Integration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show Git modifications (added/removed/modified lines)
|
||||||
|
bat --diff file.txt
|
||||||
|
|
||||||
|
# Show decorations (Git + file header)
|
||||||
|
bat --decorations=always file.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Output raw (no styling)
|
||||||
|
bat --style=plain file.txt
|
||||||
|
|
||||||
|
# Customize style
|
||||||
|
bat --style=numbers,changes file.txt
|
||||||
|
|
||||||
|
# Available styles: auto, full, plain, changes, header, grid, numbers, snip
|
||||||
|
bat --style=header,grid,numbers file.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
**Quick file preview:**
|
||||||
|
```bash
|
||||||
|
bat file.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs with syntax highlighting:**
|
||||||
|
```bash
|
||||||
|
bat error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compare files visually:**
|
||||||
|
```bash
|
||||||
|
bat --diff file1.txt
|
||||||
|
bat file2.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preview before editing:**
|
||||||
|
```bash
|
||||||
|
bat config.yaml && vim config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cat replacement in pipes:**
|
||||||
|
```bash
|
||||||
|
bat -p file.txt | grep "pattern"
|
||||||
|
```
|
||||||
|
|
||||||
|
**View specific function:**
|
||||||
|
```bash
|
||||||
|
bat -r 45:67 script.py # If function is on lines 45-67
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with other tools
|
||||||
|
|
||||||
|
**As pager for man pages:**
|
||||||
|
```bash
|
||||||
|
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
|
||||||
|
man grep
|
||||||
|
```
|
||||||
|
|
||||||
|
**With ripgrep:**
|
||||||
|
```bash
|
||||||
|
rg "pattern" -l | xargs bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**With fzf:**
|
||||||
|
```bash
|
||||||
|
fzf --preview 'bat --color=always --style=numbers {}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**With diff:**
|
||||||
|
```bash
|
||||||
|
diff -u file1 file2 | bat -l diff
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `~/.config/bat/config` for defaults:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Set theme
|
||||||
|
--theme="Dracula"
|
||||||
|
|
||||||
|
# Show line numbers, Git modifications and file header, but no grid
|
||||||
|
--style="numbers,changes,header"
|
||||||
|
|
||||||
|
# Use italic text on terminal
|
||||||
|
--italic-text=always
|
||||||
|
|
||||||
|
# Add custom mapping
|
||||||
|
--map-syntax "*.conf:INI"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- Use `-p` for plain mode when piping
|
||||||
|
- Use `--paging=never` when output is used programmatically
|
||||||
|
- `bat` caches parsed files for faster subsequent access
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Alias:** `alias cat='bat -p'` for drop-in cat replacement
|
||||||
|
- **Pager:** Use as pager with `export PAGER="bat"`
|
||||||
|
- **On Debian/Ubuntu:** Command may be `batcat` instead of `bat`
|
||||||
|
- **Custom syntaxes:** Add to `~/.config/bat/syntaxes/`
|
||||||
|
- **Performance:** For huge files, use `bat --paging=never` or plain `cat`
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
- `-p` / `--plain`: Plain mode (no line numbers/decorations)
|
||||||
|
- `-n` / `--number`: Only show line numbers
|
||||||
|
- `-A` / `--show-all`: Show non-printable characters
|
||||||
|
- `-l` / `--language`: Set language for syntax highlighting
|
||||||
|
- `-r` / `--line-range`: Only show specific line range(s)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
GitHub: https://github.com/sharkdp/bat
|
||||||
|
Man page: `man bat`
|
||||||
|
Customization: https://github.com/sharkdp/bat#customization
|
||||||
194
.opencode/skills/fd-find/SKILL.md
Normal file
194
.opencode/skills/fd-find/SKILL.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
name: fd-find
|
||||||
|
description: A fast and user-friendly alternative to 'find' - simple syntax, smart defaults, respects gitignore.
|
||||||
|
homepage: https://github.com/sharkdp/fd
|
||||||
|
metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id":"brew","kind":"brew","formula":"fd","bins":["fd"],"label":"Install fd (brew)"},{"id":"apt","kind":"apt","package":"fd-find","bins":["fd"],"label":"Install fd (apt)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# fd - Fast File Finder
|
||||||
|
|
||||||
|
User-friendly alternative to `find` with smart defaults.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic search
|
||||||
|
```bash
|
||||||
|
# Find files by name
|
||||||
|
fd pattern
|
||||||
|
|
||||||
|
# Find in specific directory
|
||||||
|
fd pattern /path/to/dir
|
||||||
|
|
||||||
|
# Case-insensitive
|
||||||
|
fd -i pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common patterns
|
||||||
|
```bash
|
||||||
|
# Find all Python files
|
||||||
|
fd -e py
|
||||||
|
|
||||||
|
# Find multiple extensions
|
||||||
|
fd -e py -e js -e ts
|
||||||
|
|
||||||
|
# Find directories only
|
||||||
|
fd -t d pattern
|
||||||
|
|
||||||
|
# Find files only
|
||||||
|
fd -t f pattern
|
||||||
|
|
||||||
|
# Find symlinks
|
||||||
|
fd -t l
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
```bash
|
||||||
|
# Exclude patterns
|
||||||
|
fd pattern -E "node_modules" -E "*.min.js"
|
||||||
|
|
||||||
|
# Include hidden files
|
||||||
|
fd -H pattern
|
||||||
|
|
||||||
|
# Include ignored files (.gitignore)
|
||||||
|
fd -I pattern
|
||||||
|
|
||||||
|
# Search all (hidden + ignored)
|
||||||
|
fd -H -I pattern
|
||||||
|
|
||||||
|
# Maximum depth
|
||||||
|
fd pattern -d 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
```bash
|
||||||
|
# Execute command on results
|
||||||
|
fd -e jpg -x convert {} {.}.png
|
||||||
|
|
||||||
|
# Parallel execution
|
||||||
|
fd -e md -x wc -l
|
||||||
|
|
||||||
|
# Use with xargs
|
||||||
|
fd -e log -0 | xargs -0 rm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regex patterns
|
||||||
|
```bash
|
||||||
|
# Full regex search
|
||||||
|
fd '^test.*\.js$'
|
||||||
|
|
||||||
|
# Match full path
|
||||||
|
fd --full-path 'src/.*/test'
|
||||||
|
|
||||||
|
# Glob pattern
|
||||||
|
fd -g "*.{js,ts}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Time-based filtering
|
||||||
|
```bash
|
||||||
|
# Modified within last day
|
||||||
|
fd --changed-within 1d
|
||||||
|
|
||||||
|
# Modified before specific date
|
||||||
|
fd --changed-before 2024-01-01
|
||||||
|
|
||||||
|
# Created recently
|
||||||
|
fd --changed-within 1h
|
||||||
|
```
|
||||||
|
|
||||||
|
## Size filtering
|
||||||
|
```bash
|
||||||
|
# Files larger than 10MB
|
||||||
|
fd --size +10m
|
||||||
|
|
||||||
|
# Files smaller than 1KB
|
||||||
|
fd --size -1k
|
||||||
|
|
||||||
|
# Specific size range
|
||||||
|
fd --size +100k --size -10m
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output formatting
|
||||||
|
```bash
|
||||||
|
# Absolute paths
|
||||||
|
fd --absolute-path
|
||||||
|
|
||||||
|
# List format (like ls -l)
|
||||||
|
fd --list-details
|
||||||
|
|
||||||
|
# Null separator (for xargs)
|
||||||
|
fd -0 pattern
|
||||||
|
|
||||||
|
# Color always/never/auto
|
||||||
|
fd --color always pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
**Find and delete old files:**
|
||||||
|
```bash
|
||||||
|
fd --changed-before 30d -t f -x rm {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Find large files:**
|
||||||
|
```bash
|
||||||
|
fd --size +100m --list-details
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy all PDFs to directory:**
|
||||||
|
```bash
|
||||||
|
fd -e pdf -x cp {} /target/dir/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Count lines in all Python files:**
|
||||||
|
```bash
|
||||||
|
fd -e py -x wc -l | awk '{sum+=$1} END {print sum}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Find broken symlinks:**
|
||||||
|
```bash
|
||||||
|
fd -t l -x test -e {} \; -print
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search in specific time window:**
|
||||||
|
```bash
|
||||||
|
fd --changed-within 2d --changed-before 1d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with other tools
|
||||||
|
|
||||||
|
**With ripgrep:**
|
||||||
|
```bash
|
||||||
|
fd -e js | xargs rg "pattern"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With fzf (fuzzy finder):**
|
||||||
|
```bash
|
||||||
|
vim $(fd -t f | fzf)
|
||||||
|
```
|
||||||
|
|
||||||
|
**With bat (cat alternative):**
|
||||||
|
```bash
|
||||||
|
fd -e md | xargs bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- `fd` is typically much faster than `find`
|
||||||
|
- Respects `.gitignore` by default (disable with `-I`)
|
||||||
|
- Uses parallel traversal automatically
|
||||||
|
- Smart case: lowercase = case-insensitive, any uppercase = case-sensitive
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Use `-t` for type filtering (f=file, d=directory, l=symlink, x=executable)
|
||||||
|
- `-e` for extension is simpler than `-g "*.ext"`
|
||||||
|
- `{}` in `-x` commands represents the found path
|
||||||
|
- `{.}` strips the extension
|
||||||
|
- `{/}` gets basename, `{//}` gets directory
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
GitHub: https://github.com/sharkdp/fd
|
||||||
|
Man page: `man fd`
|
||||||
112
.opencode/skills/jq-json-processor/SKILL.md
Normal file
112
.opencode/skills/jq-json-processor/SKILL.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
name: jq-json-processor
|
||||||
|
description: Process, filter, and transform JSON data using jq - the lightweight and flexible command-line JSON processor.
|
||||||
|
homepage: https://jqlang.github.io/jq/
|
||||||
|
metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id":"brew","kind":"brew","formula":"jq","bins":["jq"],"label":"Install jq (brew)"},{"id":"apt","kind":"apt","package":"jq","bins":["jq"],"label":"Install jq (apt)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# jq JSON Processor
|
||||||
|
|
||||||
|
Process, filter, and transform JSON data with jq.
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### Basic filtering
|
||||||
|
```bash
|
||||||
|
# Extract a field
|
||||||
|
echo '{"name":"Alice","age":30}' | jq '.name'
|
||||||
|
# Output: "Alice"
|
||||||
|
|
||||||
|
# Multiple fields
|
||||||
|
echo '{"name":"Alice","age":30}' | jq '{name: .name, age: .age}'
|
||||||
|
|
||||||
|
# Array indexing
|
||||||
|
echo '[1,2,3,4,5]' | jq '.[2]'
|
||||||
|
# Output: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with arrays
|
||||||
|
```bash
|
||||||
|
# Map over array
|
||||||
|
echo '[{"name":"Alice"},{"name":"Bob"}]' | jq '.[].name'
|
||||||
|
# Output: "Alice" "Bob"
|
||||||
|
|
||||||
|
# Filter array
|
||||||
|
echo '[1,2,3,4,5]' | jq 'map(select(. > 2))'
|
||||||
|
# Output: [3,4,5]
|
||||||
|
|
||||||
|
# Length
|
||||||
|
echo '[1,2,3]' | jq 'length'
|
||||||
|
# Output: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common operations
|
||||||
|
```bash
|
||||||
|
# Pretty print JSON
|
||||||
|
cat file.json | jq '.'
|
||||||
|
|
||||||
|
# Compact output
|
||||||
|
cat file.json | jq -c '.'
|
||||||
|
|
||||||
|
# Raw output (no quotes)
|
||||||
|
echo '{"name":"Alice"}' | jq -r '.name'
|
||||||
|
# Output: Alice
|
||||||
|
|
||||||
|
# Sort keys
|
||||||
|
echo '{"z":1,"a":2}' | jq -S '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced filtering
|
||||||
|
```bash
|
||||||
|
# Select with conditions
|
||||||
|
jq '[.[] | select(.age > 25)]' people.json
|
||||||
|
|
||||||
|
# Group by
|
||||||
|
jq 'group_by(.category)' items.json
|
||||||
|
|
||||||
|
# Reduce
|
||||||
|
echo '[1,2,3,4,5]' | jq 'reduce .[] as $item (0; . + $item)'
|
||||||
|
# Output: 15
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with files
|
||||||
|
```bash
|
||||||
|
# Read from file
|
||||||
|
jq '.users[0].name' users.json
|
||||||
|
|
||||||
|
# Multiple files
|
||||||
|
jq -s '.[0] * .[1]' file1.json file2.json
|
||||||
|
|
||||||
|
# Modify and save
|
||||||
|
jq '.version = "2.0"' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
**Extract specific fields from API response:**
|
||||||
|
```bash
|
||||||
|
curl -s https://api.github.com/users/octocat | jq '{name: .name, repos: .public_repos, followers: .followers}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Convert CSV-like data:**
|
||||||
|
```bash
|
||||||
|
jq -r '.[] | [.name, .email, .age] | @csv' users.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debug API responses:**
|
||||||
|
```bash
|
||||||
|
curl -s https://api.example.com/data | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Use `-r` for raw string output (removes quotes)
|
||||||
|
- Use `-c` for compact output (single line)
|
||||||
|
- Use `-S` to sort object keys
|
||||||
|
- Use `--arg name value` to pass variables
|
||||||
|
- Pipe multiple jq operations: `jq '.a' | jq '.b'`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full manual: https://jqlang.github.io/jq/manual/
|
||||||
|
Interactive tutorial: https://jqplay.org/
|
||||||
150
.opencode/skills/ripgrep/SKILL.md
Normal file
150
.opencode/skills/ripgrep/SKILL.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
name: ripgrep
|
||||||
|
description: Blazingly fast text search tool - recursively searches directories for regex patterns with respect to gitignore rules.
|
||||||
|
homepage: https://github.com/BurntSushi/ripgrep
|
||||||
|
metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id":"brew","kind":"brew","formula":"ripgrep","bins":["rg"],"label":"Install ripgrep (brew)"},{"id":"apt","kind":"apt","package":"ripgrep","bins":["rg"],"label":"Install ripgrep (apt)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# ripgrep (rg)
|
||||||
|
|
||||||
|
Fast, smart recursive search. Respects `.gitignore` by default.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic search
|
||||||
|
```bash
|
||||||
|
# Search for "TODO" in current directory
|
||||||
|
rg "TODO"
|
||||||
|
|
||||||
|
# Case-insensitive search
|
||||||
|
rg -i "fixme"
|
||||||
|
|
||||||
|
# Search specific file types
|
||||||
|
rg "error" -t py # Python files only
|
||||||
|
rg "function" -t js # JavaScript files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common patterns
|
||||||
|
```bash
|
||||||
|
# Whole word match
|
||||||
|
rg -w "test"
|
||||||
|
|
||||||
|
# Show only filenames
|
||||||
|
rg -l "pattern"
|
||||||
|
|
||||||
|
# Show with context (3 lines before/after)
|
||||||
|
rg -C 3 "function"
|
||||||
|
|
||||||
|
# Count matches
|
||||||
|
rg -c "import"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### File type filtering
|
||||||
|
```bash
|
||||||
|
# Multiple file types
|
||||||
|
rg "error" -t py -t js
|
||||||
|
|
||||||
|
# Exclude file types
|
||||||
|
rg "TODO" -T md -T txt
|
||||||
|
|
||||||
|
# List available types
|
||||||
|
rg --type-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search modifiers
|
||||||
|
```bash
|
||||||
|
# Regex search
|
||||||
|
rg "user_\d+"
|
||||||
|
|
||||||
|
# Fixed string (no regex)
|
||||||
|
rg -F "function()"
|
||||||
|
|
||||||
|
# Multiline search
|
||||||
|
rg -U "start.*end"
|
||||||
|
|
||||||
|
# Only show matches, not lines
|
||||||
|
rg -o "https?://[^\s]+"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path filtering
|
||||||
|
```bash
|
||||||
|
# Search specific directory
|
||||||
|
rg "pattern" src/
|
||||||
|
|
||||||
|
# Glob patterns
|
||||||
|
rg "error" -g "*.log"
|
||||||
|
rg "test" -g "!*.min.js"
|
||||||
|
|
||||||
|
# Include hidden files
|
||||||
|
rg "secret" --hidden
|
||||||
|
|
||||||
|
# Search all files (ignore .gitignore)
|
||||||
|
rg "pattern" --no-ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replacement Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview replacements
|
||||||
|
rg "old_name" --replace "new_name"
|
||||||
|
|
||||||
|
# Actually replace (requires extra tool like sd)
|
||||||
|
rg "old_name" -l | xargs sed -i 's/old_name/new_name/g'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parallel search (auto by default)
|
||||||
|
rg "pattern" -j 8
|
||||||
|
|
||||||
|
# Skip large files
|
||||||
|
rg "pattern" --max-filesize 10M
|
||||||
|
|
||||||
|
# Memory map files
|
||||||
|
rg "pattern" --mmap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
**Find TODOs in code:**
|
||||||
|
```bash
|
||||||
|
rg "TODO|FIXME|HACK" --type-add 'code:*.{rs,go,py,js,ts}' -t code
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search in specific branches:**
|
||||||
|
```bash
|
||||||
|
git show branch:file | rg "pattern"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Find files containing multiple patterns:**
|
||||||
|
```bash
|
||||||
|
rg "pattern1" | rg "pattern2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search with context and color:**
|
||||||
|
```bash
|
||||||
|
rg -C 2 --color always "error" | less -R
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison to grep
|
||||||
|
|
||||||
|
- **Faster:** Typically 5-10x faster than grep
|
||||||
|
- **Smarter:** Respects `.gitignore`, skips binary files
|
||||||
|
- **Better defaults:** Recursive, colored output, line numbers
|
||||||
|
- **Easier:** Simpler syntax for common tasks
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- `rg` is often faster than `grep -r`
|
||||||
|
- Use `-t` for file type filtering instead of `--include`
|
||||||
|
- Combine with other tools: `rg pattern -l | xargs tool`
|
||||||
|
- Add custom types in `~/.ripgreprc`
|
||||||
|
- Use `--stats` to see search performance
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
GitHub: https://github.com/BurntSushi/ripgrep
|
||||||
|
User Guide: https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md
|
||||||
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.
|
||||||
|
|||||||
220
CHANGES.md
220
CHANGES.md
@ -1,5 +1,219 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## 2.17.0 (Unreleased)
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree rooted at that layer in the Layers sidebar; symmetric with the existing `Shift+click` collapse-all gesture, and removes the O(siblings × depth) click cost of unfolding a deep subtree one level at a time [Github #7736](https://github.com/penpot/penpot/issues/7736)
|
||||||
|
- 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)
|
||||||
|
- Preserve vector content when pasting from external tools such as Inkscape: recognise SVG sent as text/plain (with optional XML declaration and HTML comments), skip the raster preview when an SVG sibling is on the clipboard, and ignore empty SVG blobs that some tools advertise alongside the real payload, so pasted graphics arrive editable without spurious "SVG is invalid" warnings [Github #546](https://github.com/penpot/penpot/issues/546)
|
||||||
|
|
||||||
|
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
|
||||||
|
- Adds a **Pixel grid color** picker in the viewport settings, next to the existing canvas color control [Github #7750](https://github.com/penpot/penpot/issues/7750)
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure [Github #9092](https://github.com/penpot/penpot/issues/9092)
|
||||||
|
- Fix plugin API `library.connectLibrary()` returning a non-Promise (or throwing synchronously) when the plugin lacks `library:write` permission — the method now always returns a `Promise` and rejects with a structured error message, matching the contract used by every other Promise-returning plugin method (`restore`, `remove`, `pin`, `saveVersion`, `findVersions`, …)
|
||||||
|
- Fix LDAP provider params schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
|
||||||
|
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide` → `:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
|
||||||
|
- 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 +229,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 +248,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 +284,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
|
||||||
|
|||||||
388
CONTRIBUTING.md
388
CONTRIBUTING.md
@ -1,211 +1,289 @@
|
|||||||
# 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)
|
||||||
|
- [Workflow](#workflow)
|
||||||
|
- [Title format](#title-format)
|
||||||
|
- [Description](#description)
|
||||||
|
- [Branch naming](#branch-naming)
|
||||||
|
- [Review process](#review-process)
|
||||||
|
- [What we won't accept](#what-we-wont-accept)
|
||||||
|
- [Good first issues](#good-first-issues)
|
||||||
|
- [Commit Guidelines](#commit-guidelines)
|
||||||
|
- [Commit types](#commit-types)
|
||||||
|
- [Rules](#rules)
|
||||||
|
- [Examples](#examples)
|
||||||
|
- [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 [GitHub
|
||||||
|
Issue](https://github.com/penpot/penpot/issues) or start a [GitHub
|
||||||
|
Discussion](https://github.com/penpot/penpot/discussions) before starting
|
||||||
|
work on a new feature or significant change. For planned features on the
|
||||||
|
roadmap, reference the corresponding Taiga story. 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.
|
### Title format
|
||||||
|
|
||||||
The commit message format is:
|
Pull request titles **must** follow the same convention as commit subjects:
|
||||||
|
|
||||||
```
|
```
|
||||||
<type> <subject>
|
:emoji: <subject>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
|
||||||
|
- Capitalize the first letter of the subject.
|
||||||
|
- Do not end the subject with a period.
|
||||||
|
- Keep the subject to **70 characters** or fewer.
|
||||||
|
- Use one of the [commit type emojis](#commit-types) listed below.
|
||||||
|
|
||||||
|
When a PR contains multiple unrelated commits, choose the emoji that
|
||||||
|
best represents the dominant change.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```
|
||||||
|
:bug: Fix unexpected error on launching modal
|
||||||
|
:sparkles: Enable new modal for profile
|
||||||
|
:zap: Improve performance of dashboard navigation
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** When a PR is squash-merged, the PR title becomes the
|
||||||
|
> commit message on the main branch. Getting the title right matters.
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Every pull request should include a description that helps reviewers
|
||||||
|
understand the change quickly:
|
||||||
|
|
||||||
|
1. **What and why** — describe the change and its motivation.
|
||||||
|
2. **Link related issues** — use `Closes #1234` or reference a Taiga
|
||||||
|
story (e.g. `Taiga #5678`).
|
||||||
|
3. **Screenshots or recordings** — required for any UI-visible change.
|
||||||
|
4. **Testing notes** — how did you verify the change? Any edge cases?
|
||||||
|
5. **Breaking changes** — call out anything that affects existing users
|
||||||
|
or requires migration steps.
|
||||||
|
|
||||||
|
### Branch naming
|
||||||
|
|
||||||
|
Use a descriptive branch name that reflects the type and scope of the
|
||||||
|
change:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>/<short-description>
|
||||||
|
```
|
||||||
|
|
||||||
|
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
|
||||||
|
|
||||||
|
Optionally include the issue number:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix/9122-email-blacklisting
|
||||||
|
feat/export-webp
|
||||||
|
refactor/layout-sizing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review process
|
||||||
|
|
||||||
|
- Maintainers review PRs when time permits. Please be patient.
|
||||||
|
- Address review feedback by **pushing new commits** — do not
|
||||||
|
force-push during review, as it breaks comment threads.
|
||||||
|
- PRs require at least **one approval** before merge.
|
||||||
|
- We use **squash-merge** by default. The PR title becomes the final
|
||||||
|
commit message, so follow the [title format](#title-format) above.
|
||||||
|
|
||||||
|
### What we won't accept
|
||||||
|
|
||||||
|
To save time on both sides, please avoid submitting PRs that:
|
||||||
|
|
||||||
|
- Introduce new dependencies without prior discussion.
|
||||||
|
- Change the build system or CI configuration without maintainer
|
||||||
|
approval.
|
||||||
|
- Mix unrelated changes in a single PR — keep PRs focused on one
|
||||||
|
concern.
|
||||||
|
- Skip the [discussion step](#workflow) for non-bug-fix changes.
|
||||||
|
|
||||||
|
### Good first issues
|
||||||
|
|
||||||
|
We use the `easy fix` label to mark issues appropriate for newcomers.
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
Commit messages must follow this format:
|
||||||
|
|
||||||
|
```
|
||||||
|
: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.
|
|
||||||
|
|
||||||
|
|||||||
138
README.md
138
README.md
@ -1,53 +1,56 @@
|
|||||||
|
<img width="100%" src="https://github.com/user-attachments/assets/da17b160-f289-436f-b140-972083a08602" />
|
||||||
|
|
||||||
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
|
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
|
||||||
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
|
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://penpot.app/images/readme/github-dark-mode.png">
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://penpot.app/images/readme/github-light-mode.png">
|
|
||||||
<img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png">
|
|
||||||
</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.digitalpublicgoods.net/r/penpot" rel="nofollow">
|
||||||
<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>
|
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg">
|
||||||
<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>
|
||||||
<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://community.penpot.app" rel="nofollow">
|
||||||
|
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">
|
||||||
|
</a>
|
||||||
|
<a href="https://tree.taiga.io/project/penpot/" rel="nofollow">
|
||||||
|
<img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg">
|
||||||
|
</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">
|
||||||
|
</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
|
Penpot is the open-source design platform for teams that build digital products at scale.
|
||||||
)
|
|
||||||
|
|
||||||
<br />
|
Penpot’s key strength lies in giving you **full ownership of your design infrastructure**. Built on open source and designed for [self-hosting](https://help.penpot.app/technical-guide/getting-started/), it puts teams in complete control of their design environment supporting strict compliance and governance requirements. Whether used in the **browser or deployed on your own servers**, Penpot **works with open standards** like SVG, CSS, HTML, and JSON.
|
||||||
|
|
||||||
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.
|
Real-time collaboration strengthens this foundation, helping teams scale and bring design closer to the product through top-tier capabilities. Additionally, developers feel at home using Penpot, because design is expressed as code, enabling a direct translation and shipping products faster.
|
||||||
|
|
||||||
Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and it’s free!
|
Best-in-class native [Design Tokens](https://penpot.dev/collaboration/design-tokens) provide a single source of truth between design and development. They ensure consistency, improve collaboration, and make it easier to manage complex design systems.
|
||||||
|
|
||||||
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 [MCP server](https://penpot.app/penpot-mcp-server) takes it further by enabling multi-directional workflows between design and code. A [powerful open API](https://help.penpot.app/mcp/#quick-start) and plugin system makes the workspace programmable, enabling automation, AI-driven workflows, and integrations with the tools and systems you already use.
|
||||||
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 [CSS Grid and Flex Layout](https://help.penpot.app/user-guide/designing/flexible-layouts/), teams can design responsive interfaces that behave like real code from the start.
|
||||||
|
|
||||||
|
Combined, these features turn Penpot into a **full-stack design platform** for building scalable design systems and fully integrated product development processes.
|
||||||
|
|
||||||
|
If your organization is scaling and needs extra support, we’re here to help. [Talk to us](https://penpot.app/talk-to-us)
|
||||||
|
|
||||||
## Table of contents ##
|
## Table of contents ##
|
||||||
|
|
||||||
@ -60,101 +63,78 @@ For organizations that need extra service for its teams, [get in touch](https://
|
|||||||
|
|
||||||
## Why Penpot ##
|
## Why Penpot ##
|
||||||
|
|
||||||
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 connects design, code, and AI workflows through a code-based approach, making designs readable by developers and AI via the MCP server. This approach helps teams ship what’s actually designed and manage design systems at scale with powerful design tokens. As a self-hosted, open-source and real-time collaboration platform, Penpot offers full flexibility, security, and ownership without vendor lock-in. Learn more about [why Penpot](https://penpot.app/why-penpot) is the platform for your team.
|
||||||
|
|
||||||
### 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 ###
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Building Design Systems: design tokens, components and variants ###
|
Penpot offers [integration](https://penpot.app/integrations-api) into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
|
||||||
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.
|
|
||||||
|
|
||||||
|
### Building Design Systems: design tokens, components and variants ###
|
||||||
|
|
||||||
<br />
|
Penpot brings [design systems](https://penpot.app/design/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.
|
||||||
|
|
||||||
<p align="center">
|
<img width="100%" alt="Penpot Design Systems" 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>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<img src="https://site-assets.plasmic.app/2168cf524dd543caeff32384eb9ea0a1.svg" alt="Open Source" style="width: 65%;">
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Community ##
|
## Community ##
|
||||||
|
|
||||||
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
||||||
|
|
||||||
|
Want to go a step further? Become a [Penpot Ambassador](https://penpot.app/ambassador-program) and help grow the Penpot community in your region while contributing to a global, open design ecosystem.
|
||||||
|
|
||||||
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:
|
Categories include:
|
||||||
|
|
||||||
- [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)
|
||||||
- [#MadeWithPenpot](https://community.penpot.app/c/madewithpenpot/9)
|
|
||||||
- [Events and Announcements](https://community.penpot.app/c/announcements/5)
|
- [Events and Announcements](https://community.penpot.app/c/announcements/5)
|
||||||
- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21)
|
|
||||||
- [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)
|
- [Education](https://community.penpot.app/c/education/28)
|
||||||
|
|
||||||
|
<img width="100%" alt="Pentpot Community" src="https://github.com/user-attachments/assets/4b2a4360-12b5-4994-bd45-641449f86c4e" />
|
||||||
<br />
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://github.com/penpot/penpot/assets/5446186/6ac62220-a16c-46c9-ab21-d24ae357ed03" alt="Community" style="width: 65%;">
|
|
||||||
</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 />
|
<img width="100%" alt="Penpot hub" src="https://github.com/user-attachments/assets/0abc02f0-625c-45ab-ad81-4927bec7a055" />
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Resources ##
|
## Resources ##
|
||||||
|
|
||||||
@ -170,6 +150,8 @@ 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)
|
||||||
|
|
||||||
|
🧑🏫 [UI Design Course](https://penpot.app/courses/)
|
||||||
|
|
||||||
|
|
||||||
## License ##
|
## License ##
|
||||||
|
|
||||||
|
|||||||
@ -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="{{organization-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">
|
||||||
|
{% if organization-initials %}{{organization-initials}}{% endif %}
|
||||||
|
</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
|
||||||
@ -12,7 +13,7 @@ export PENPOT_PUBLIC_URI=https://localhost:3449
|
|||||||
|
|
||||||
export PENPOT_FLAGS="\
|
export PENPOT_FLAGS="\
|
||||||
$PENPOT_FLAGS \
|
$PENPOT_FLAGS \
|
||||||
enable-login-with-password
|
enable-login-with-password \
|
||||||
disable-login-with-ldap \
|
disable-login-with-ldap \
|
||||||
disable-login-with-oidc \
|
disable-login-with-oidc \
|
||||||
disable-login-with-google \
|
disable-login-with-google \
|
||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,7 @@
|
|||||||
[:host {:optional true} :string]
|
[:host {:optional true} :string]
|
||||||
[:port {:optional true} ::sm/int]
|
[:port {:optional true} ::sm/int]
|
||||||
[:bind-dn {:optional true} :string]
|
[:bind-dn {:optional true} :string]
|
||||||
[:bind-passwor {:optional true} :string]
|
[:bind-password {:optional true} :string]
|
||||||
[:query {:optional true} :string]
|
[:query {:optional true} :string]
|
||||||
[:base-dn {:optional true} :string]
|
[:base-dn {:optional true} :string]
|
||||||
[:attrs-email {:optional true} :string]
|
[:attrs-email {:optional true} :string]
|
||||||
|
|||||||
@ -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]
|
||||||
|
[:organization-initials [:maybe :string]]
|
||||||
|
[:organization-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")}))))
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
(when-not provider
|
(when-not provider
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :ldap-not-initialized
|
:code :ldap-not-initialized
|
||||||
:hide "ldap auth provider is not initialized"))
|
:hint "ldap auth provider is not initialized"))
|
||||||
|
|
||||||
(let [info (ldap/authenticate provider params)]
|
(let [info (ldap/authenticate provider params)]
|
||||||
(when-not info
|
(when-not info
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,10 @@
|
|||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
|
[app.email.blacklist :as email.blacklist]
|
||||||
[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 +37,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,23 +85,51 @@
|
|||||||
[: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]
|
||||||
|
[:initials [:maybe :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)]
|
||||||
|
|
||||||
|
(when (and (email.blacklist/enabled? cfg)
|
||||||
|
(email.blacklist/contains? cfg email))
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :email-domain-is-not-allowed
|
||||||
|
:hint "email domain is in the blacklist"))
|
||||||
|
|
||||||
;; When we have email verification disabled and invitation user is
|
;; When we have email verification disabled and invitation user is
|
||||||
;; already present in the database, we proceed to add it to the
|
;; already present in the database, we proceed to add it to the
|
||||||
;; team as-is, without email roundtrip.
|
;; team as-is, without email roundtrip.
|
||||||
@ -103,9 +142,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 +164,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 +199,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)
|
||||||
|
:organization-logo (:logo organization)
|
||||||
|
:organization-initials (:initials 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 initials logo] :as params}]
|
||||||
|
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||||
|
(create-invitation cfg
|
||||||
|
(assoc params
|
||||||
|
:organization {:id id :name name :initials initials :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 +270,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 +352,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,360 @@
|
|||||||
[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]
|
||||||
|
[:initials [:maybe :string]]
|
||||||
|
[: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)))))
|
||||||
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