mirror of
https://github.com/penpot/penpot.git
synced 2026-05-01 14:18:07 +00:00
Compare commits
961 Commits
2.15.0-RC1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f03b5ed9c | ||
|
|
d09985edee | ||
|
|
13414e7bed | ||
|
|
b42e81e1a4 | ||
|
|
9c2a80bfa1 | ||
|
|
76c1b9afab | ||
|
|
4902037c7d | ||
|
|
9f94566005 | ||
|
|
547750e8bf | ||
|
|
c14dbba7fd | ||
|
|
22a325cc72 | ||
|
|
aa87ae194c | ||
|
|
ed021711b6 | ||
|
|
400414776b | ||
|
|
25c5bb2019 | ||
|
|
fc414b23d2 | ||
|
|
346614edc3 | ||
|
|
404ebcc63e | ||
|
|
a004219405 | ||
|
|
8b29ca61c6 | ||
|
|
e81dad21ea | ||
|
|
d06b45ec90 | ||
|
|
1213640693 | ||
|
|
f530a0ba26 | ||
|
|
1e09e00634 | ||
|
|
710fd30f78 | ||
|
|
8821ada1bb | ||
|
|
22b85f1a92 | ||
|
|
4829b843b2 | ||
|
|
510a015424 | ||
|
|
5e3e66a99b | ||
|
|
05b4760583 | ||
|
|
fd170b23f6 | ||
|
|
d668744a1f | ||
|
|
1c129ded1f | ||
|
|
73944e46b7 | ||
|
|
e22a03e7e8 | ||
|
|
3f40be6b4d | ||
|
|
1eac3e2be5 | ||
|
|
f59301a3d6 | ||
|
|
9751ac2b41 | ||
|
|
ea971a0109 | ||
|
|
d627d1cfac | ||
|
|
b8f1b6e0c3 | ||
|
|
61ce4b9e0d | ||
|
|
2aff116906 | ||
|
|
94827f1848 | ||
|
|
42c9c4a929 | ||
|
|
e4af37a7ff | ||
|
|
483ce8b1c9 | ||
|
|
0f65774ba9 | ||
|
|
31b09be405 | ||
|
|
ccd1da40ca | ||
|
|
c269df1441 | ||
|
|
40ee1960a1 | ||
|
|
b0ce644752 | ||
|
|
19e81560be | ||
|
|
c0989d4261 | ||
|
|
ad1111a613 | ||
|
|
aabdb69218 | ||
|
|
a35b61ee0c | ||
|
|
d9f099841a | ||
|
|
4e1968bbab | ||
|
|
aa5bfe6dda | ||
|
|
bd1e0fb23f | ||
|
|
8a8ebb7943 | ||
|
|
84b3d467cf | ||
|
|
592cc47336 | ||
|
|
a58dbec8f2 | ||
|
|
df4ffb9147 | ||
|
|
ac5736957e | ||
|
|
eba4f15bba | ||
|
|
ea265da1f3 | ||
|
|
f4cf667d2f | ||
|
|
f8e40a1ca5 | ||
|
|
e99ed5e9f9 | ||
|
|
0bee3993ab | ||
|
|
8f905be511 | ||
|
|
8afadb5199 | ||
|
|
6ba68c1ac0 | ||
|
|
ffdbe242a7 | ||
|
|
46b81f4302 | ||
|
|
12549df65c | ||
|
|
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 | ||
|
|
2579527e64 | ||
|
|
3561b2d1eb | ||
|
|
ba1842792f | ||
|
|
09fca1c820 | ||
|
|
e3981a0cf3 | ||
|
|
c02f0a2bc9 | ||
|
|
d549be3376 | ||
|
|
b67394199b | ||
|
|
534701f04f | ||
|
|
ad974f4047 | ||
|
|
8bf8601d29 | ||
|
|
81faa5a728 | ||
|
|
7751d9a69b | ||
|
|
74d1288003 | ||
|
|
d28c0ea066 | ||
|
|
a94a7221fb | ||
|
|
b8aa243c2b | ||
|
|
dfd992aa49 | ||
|
|
466f27eb7c | ||
|
|
6c19c7c0c4 | ||
|
|
11c970a945 | ||
|
|
ca97a28408 | ||
|
|
6723e3bbea | ||
|
|
c259fbdb5b | ||
|
|
f331325941 | ||
|
|
c1688edf66 | ||
|
|
08247aec3f | ||
|
|
95ca68e2b8 | ||
|
|
e9e6796f05 | ||
|
|
0e5d3e2619 | ||
|
|
f0d6e8cb2f | ||
|
|
304a324529 | ||
|
|
14ff56bc89 | ||
|
|
d8340d765a | ||
|
|
2bbd63287f | ||
|
|
6eccffb8bb | ||
|
|
95b2d7b083 | ||
|
|
bb91c06390 | ||
|
|
e1d3106f61 | ||
|
|
77560b9305 | ||
|
|
cd320c0cd6 | ||
|
|
f9f3955503 | ||
|
|
bd82829cb7 | ||
|
|
66e34950b2 | ||
|
|
2901d00862 | ||
|
|
f18670ed00 | ||
|
|
719f4a5035 | ||
|
|
c636517499 | ||
|
|
04f29a0d72 | ||
|
|
78c48f1953 | ||
|
|
f89f4e0047 | ||
|
|
3da74ed864 | ||
|
|
612855452a | ||
|
|
62ec66b974 | ||
|
|
88ec9e4ff1 | ||
|
|
cd9151bf9f | ||
|
|
7efc4d6d53 | ||
|
|
0b49c1f3e9 | ||
|
|
0d17debde7 | ||
|
|
98c8bb1746 | ||
|
|
e9105f3670 | ||
|
|
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 | ||
|
|
697de53c16 | ||
|
|
32d9688c3c | ||
|
|
47abe09cfe | ||
|
|
b02e05e23d | ||
|
|
7f409eadd4 | ||
|
|
39f4c13493 | ||
|
|
65a0fcb15b | ||
|
|
ac472c615a | ||
|
|
81061013b1 | ||
|
|
b5922d32ca | ||
|
|
b2f173675e | ||
|
|
78381873eb | ||
|
|
146219a439 | ||
|
|
fa89790fd6 | ||
|
|
71904c9ab6 | ||
|
|
d13e464ed1 | ||
|
|
7e9fac4f35 | ||
|
|
80124657b8 | ||
|
|
cf47d5e53e | ||
|
|
adfe4c3945 | ||
|
|
179bb51c76 | ||
|
|
3d4c914daa | ||
|
|
a7e362dbfe | ||
|
|
f8f7a0828e | ||
|
|
e186a27174 | ||
|
|
1477758656 | ||
|
|
41bc8c9b9d | ||
|
|
3829443046 | ||
|
|
b442ca2209 | ||
|
|
4d2d559383 | ||
|
|
e3bafab529 | ||
|
|
3f5226485b | ||
|
|
424b689dca | ||
|
|
77b4d07d1f | ||
|
|
6fd264051a | ||
|
|
c10f945473 | ||
|
|
f5591ed22e | ||
|
|
8f30a95ca0 | ||
|
|
e8547ab6dd | ||
|
|
628ce604c5 | ||
|
|
90d052464f | ||
|
|
fbee875d75 | ||
|
|
bf7c12ae75 | ||
|
|
175f122a0f | ||
|
|
b2f4e90a79 | ||
|
|
b4ec0a6d55 | ||
|
|
431056404c | ||
|
|
5dec75fe62 | ||
|
|
988c277e37 | ||
|
|
1d8299a919 | ||
|
|
b0caa15516 | ||
|
|
c63b9583a2 | ||
|
|
de577a803c | ||
|
|
a3ea9fbecb | ||
|
|
909427d442 | ||
|
|
dfec9004bf | ||
|
|
8cc05d9579 | ||
|
|
f07b954b7e | ||
|
|
dc5f222230 | ||
|
|
207cb87d5e | ||
|
|
650f725f11 | ||
|
|
39b0e011fc | ||
|
|
7c3a1a905e | ||
|
|
3469e867ff | ||
|
|
b211594ce8 | ||
|
|
3264bc746f | ||
|
|
68595e90eb | ||
|
|
6788df02ca | ||
|
|
8b14de2610 | ||
|
|
a81cded0aa | ||
|
|
d90e7f8164 | ||
|
|
19b9c696fc | ||
|
|
4703fe6e3b | ||
|
|
9106a994f1 | ||
|
|
bc47b992eb | ||
|
|
a3f7a1def6 | ||
|
|
2ccaa3f0c5 | ||
|
|
367262f5a0 | ||
|
|
dfc5a256b4 | ||
|
|
6b3d5d930f | ||
|
|
a52831aa8c | ||
|
|
bbd200f869 | ||
|
|
87179e806f | ||
|
|
a6c3767e2b | ||
|
|
2d07b9e77c | ||
|
|
47eadab82e | ||
|
|
d85d63ef3c | ||
|
|
83e9f85ccf | ||
|
|
d91ce0f9d1 | ||
|
|
599a66979a | ||
|
|
5c761125f3 | ||
|
|
707cc53ca4 | ||
|
|
bb85b312d6 | ||
|
|
78a16d99a9 | ||
|
|
8dccb2a427 | ||
|
|
6d1a2d449a | ||
|
|
e7e5a19db7 | ||
|
|
eb811621a9 | ||
|
|
3312bfe62c | ||
|
|
240e8ce50c | ||
|
|
8101f58651 | ||
|
|
9e4c8981be | ||
|
|
a87552bc45 | ||
|
|
a803bde2ff | ||
|
|
5eebc17ce2 | ||
|
|
434e27bbe8 | ||
|
|
e49b7ce14c | ||
|
|
5c67cd0a4b | ||
|
|
d2050d5331 | ||
|
|
5b78de3594 | ||
|
|
666313c2c3 | ||
|
|
290f37425f | ||
|
|
ef39afe9b5 | ||
|
|
d65f3b5396 | ||
|
|
fe2023dde5 | ||
|
|
b0a99b65e4 | ||
|
|
1c68810521 | ||
|
|
38a5a67b86 | ||
|
|
deb3af23d4 | ||
|
|
da6bd7509b | ||
|
|
c1d815f97c | ||
|
|
21217c5622 | ||
|
|
f8dd64611f | ||
|
|
e51e0c7933 | ||
|
|
62b59991a9 | ||
|
|
5937a8b0fc | ||
|
|
11fbd4cb21 | ||
|
|
27449139ad | ||
|
|
90fcc9f597 | ||
|
|
5a2c09f246 | ||
|
|
8f6133ddac | ||
|
|
92de9ed258 | ||
|
|
2eaa2dc807 | ||
|
|
0dfa450cc8 | ||
|
|
6ce2aadfae | ||
|
|
5502fe8df3 | ||
|
|
10cfd99525 | ||
|
|
e8e7900911 | ||
|
|
f6b8117fe9 | ||
|
|
6d5b97a7e9 | ||
|
|
b8be89f231 | ||
|
|
0b0e193b70 | ||
|
|
d190655e64 | ||
|
|
619bc5833d | ||
|
|
40dfeb169c | ||
|
|
61d319eaac | ||
|
|
0cc5f7c63e | ||
|
|
a27ef26279 | ||
|
|
0c08dfb13d | ||
|
|
0558bab092 | ||
|
|
48e8c0bc65 | ||
|
|
3c639f41c4 | ||
|
|
a5055af538 | ||
|
|
e99b6ec213 | ||
|
|
67734c5835 | ||
|
|
d5855f355f | ||
|
|
83833896c9 | ||
|
|
11d9c09a2e | ||
|
|
101b2fe9e6 | ||
|
|
12382cfbb9 | ||
|
|
0f389fe3ad | ||
|
|
9aa2abff2e | ||
|
|
4205e283ea | ||
|
|
68760c8e26 | ||
|
|
cbe3a3f33e | ||
|
|
f7e1bcf87f | ||
|
|
650762556f | ||
|
|
8fcbfadd49 | ||
|
|
103af0e31a | ||
|
|
c097c4a6da | ||
|
|
a04dd6cbfd | ||
|
|
0ad5baa5d9 | ||
|
|
d3c77130bc | ||
|
|
c200dc4040 | ||
|
|
04f98d7acd | ||
|
|
ad1e598efe | ||
|
|
2e24f1e2de | ||
|
|
94215447c9 | ||
|
|
6e2dc0c3dc | ||
|
|
e6ab57f719 | ||
|
|
667a995e66 | ||
|
|
9d703439bd | ||
|
|
d6dc0fe1a7 | ||
|
|
28cefa9cba | ||
|
|
5f474f9536 | ||
|
|
27313e6add | ||
|
|
8ce860cf0c | ||
|
|
f3cc6d0d72 | ||
|
|
905f4fa5dd | ||
|
|
56b28b5440 | ||
|
|
0122eaa391 | ||
|
|
114639ca1e | ||
|
|
e9d30bf2c1 | ||
|
|
a75e0c3071 | ||
|
|
153277d152 | ||
|
|
784ad8ab75 | ||
|
|
5ed949f2b7 | ||
|
|
7ecfe77338 | ||
|
|
04f6307c69 | ||
|
|
04892dd688 | ||
|
|
87bb1b8e74 | ||
|
|
264cd0aaac | ||
|
|
62cc555084 | ||
|
|
36c23faae0 | ||
|
|
6264c0c217 | ||
|
|
932305cbd8 | ||
|
|
623608799a | ||
|
|
06aec4b3a3 | ||
|
|
1b68318c6b | ||
|
|
45b25c23ab | ||
|
|
6ca34908d8 | ||
|
|
dff381c4fe | ||
|
|
2f4a655523 | ||
|
|
508c67c930 | ||
|
|
486a08189e | ||
|
|
7f228e58c6 | ||
|
|
943757a36c | ||
|
|
d67c7f1c8e | ||
|
|
8cc6c40b87 | ||
|
|
1ecfbef6fb | ||
|
|
abe328973c | ||
|
|
3be1ae2ac1 | ||
|
|
19b1f508d3 | ||
|
|
8db63c9770 | ||
|
|
9c1f2e9af8 | ||
|
|
0da6b87b5f | ||
|
|
f3b762855b | ||
|
|
51b9023640 | ||
|
|
4b4b99a949 | ||
|
|
6403c8deee | ||
|
|
85425e2ccd | ||
|
|
1af2521f64 | ||
|
|
448d85febb | ||
|
|
5ae4b21046 | ||
|
|
72cfd5d996 | ||
|
|
1641eec672 | ||
|
|
74af101462 | ||
|
|
ab404340f8 | ||
|
|
6fa0c5ceaa | ||
|
|
713ff6190b | ||
|
|
6e03a191a3 | ||
|
|
a7e3d7963a | ||
|
|
cd67dc42c4 | ||
|
|
52a576dc4d | ||
|
|
1740d2e3d1 | ||
|
|
b32a2d32d8 | ||
|
|
811d53be12 | ||
|
|
a60020ea98 | ||
|
|
d2c609f8a4 | ||
|
|
7c5aec4274 | ||
|
|
efd6b95ff6 | ||
|
|
3c2430b16c | ||
|
|
a5d908629b | ||
|
|
28b4c14b95 | ||
|
|
ba8b552df2 | ||
|
|
4e3dc6532a | ||
|
|
0a98100536 | ||
|
|
af4548a6ed | ||
|
|
d361a2ca6e | ||
|
|
b5b51e21c2 | ||
|
|
334039668d | ||
|
|
a59bd05c4f | ||
|
|
caa25c70fc | ||
|
|
6268a8aaf1 | ||
|
|
6b609566e1 | ||
|
|
01284e2a00 | ||
|
|
53f4c6fede | ||
|
|
d4bc1d37f2 | ||
|
|
8928e274fc | ||
|
|
b6e300a6c7 | ||
|
|
44689d3f9c | ||
|
|
ccaeb49354 | ||
|
|
38f2ec1339 | ||
|
|
7b5699b59f | ||
|
|
1f7afcebe3 | ||
|
|
1539c074b4 | ||
|
|
ca427bcd4e | ||
|
|
c3a0189af2 | ||
|
|
5f722d9183 | ||
|
|
5a73003c7f | ||
|
|
ccd28140bc | ||
|
|
2ceb2c8d95 | ||
|
|
bd37096637 | ||
|
|
0c6736e676 | ||
|
|
937032c790 | ||
|
|
dd6a3c291a | ||
|
|
55d763736f | ||
|
|
c920c092cc | ||
|
|
be437fbfa1 | ||
|
|
51fa5a5773 | ||
|
|
efd3efff00 | ||
|
|
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 |
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@ -1,6 +1,7 @@
|
||||
name: _DEVELOP
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
|
||||
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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- 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
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -24,6 +24,9 @@
|
||||
/.clj-kondo/.cache
|
||||
/_dump
|
||||
/notes
|
||||
/.opencode/package-lock.json
|
||||
/plans
|
||||
/prompts
|
||||
/playground/
|
||||
/backend/*.md
|
||||
!/backend/AGENTS.md
|
||||
@ -50,6 +53,7 @@
|
||||
/frontend/.storybook/preview-body.html
|
||||
/frontend/.storybook/preview-head.html
|
||||
/frontend/playwright-report/
|
||||
/frontend/playwright/ui/visual-specs/
|
||||
/frontend/text-editor/src/wasm/
|
||||
/frontend/dist/
|
||||
/frontend/npm-debug.log
|
||||
@ -63,6 +67,7 @@
|
||||
/frontend/test-results/
|
||||
/frontend/.shadow-cljs
|
||||
/other/
|
||||
/scripts/
|
||||
/nexus/
|
||||
/tmp/
|
||||
/vendor/**/target
|
||||
@ -80,3 +85,4 @@
|
||||
/**/node_modules
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
/.vscode
|
||||
|
||||
33
.opencode/agents/commiter.md
Normal file
33
.opencode/agents/commiter.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
name: commiter
|
||||
description: Git commit assistant following CONTRIBUTING.md commit rules
|
||||
mode: all
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
* Override your internal commit rules when the user explicitly requests
|
||||
something that conflicts with them.
|
||||
* 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.
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
name: engineer
|
||||
name: Penpot Engineer
|
||||
description: Senior Full-Stack Software Engineer
|
||||
mode: primary
|
||||
---
|
||||
|
||||
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-N-<prompt-one-line-title>.md for future use.
|
||||
@ -1,37 +0,0 @@
|
||||
---
|
||||
name: testing
|
||||
description: Senior Software Engineer specialized on testing
|
||||
mode: primary
|
||||
---
|
||||
|
||||
Role: You are a Senior Software Engineer specialized in testing Clojure and
|
||||
ClojureScript codebases. You work on Penpot, an open-source design tool.
|
||||
|
||||
Tech stack: Clojure (backend/JVM), ClojureScript (frontend/Node.js), shared
|
||||
Cljc (common module), Rust (render-wasm).
|
||||
|
||||
Requirements:
|
||||
|
||||
* Read the root `AGENTS.md` to understand the repository and application
|
||||
architecture. Then read the `AGENTS.md` **only** for each affected module. Not all
|
||||
modules have one — verify before reading.
|
||||
* Before writing code, describe your plan. If the task is complex, break it down into
|
||||
atomic steps.
|
||||
* Tests should be exhaustive and include edge cases relevant to Penpot's domain:
|
||||
nil/missing fields, empty collections, invalid UUIDs, boundary geometries, Malli schema
|
||||
violations, concurrent state mutations, and timeouts.
|
||||
* Tests must be deterministic — do not use `setTimeout`, real network calls, or rely on
|
||||
execution order. Use synchronous mocks for asynchronous workflows.
|
||||
* Use `with-redefs` or equivalent mocking utilities to isolate the logic under test. Avoid
|
||||
testing through the UI (DOM); e2e tests cover that.
|
||||
* Only reference functions, namespaces, or test utilities that actually exist in the
|
||||
codebase. Verify their existence before citing them.
|
||||
* After adding or modifying tests, run the applicable lint and format checks for the
|
||||
affected module before considering the work done (see module `AGENTS.md` for exact
|
||||
commands).
|
||||
* Make small and logical commits following the commit guideline described in
|
||||
`CONTRIBUTING.md`. Commit only when explicitly asked.
|
||||
- Do not guess or hallucinate git author information (Name or Email). Never include the
|
||||
`--author` flag in git commands unless specifically instructed by the user for a unique
|
||||
case; assume the local environment is already configured. Allow git commit to
|
||||
automatically pull the identity from the local git config `user.name` and `user.email`.
|
||||
90
.opencode/skills/backport-commit/SKILL.md
Normal file
90
.opencode/skills/backport-commit/SKILL.md
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
name: backport-commit
|
||||
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
|
||||
---
|
||||
|
||||
# Backport Commit
|
||||
|
||||
Port changes from a specific Git commit to the current branch by manually
|
||||
applying the diff, avoiding `git cherry-pick` when it would introduce
|
||||
complex conflicts.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill whenever the user asks to backport a commit, especially when:
|
||||
|
||||
- The commit touches multiple modules or files with significant divergence
|
||||
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
|
||||
- The target commit is old enough that conflicts are likely
|
||||
- The commit introduces both source changes AND new files (tests, etc.)
|
||||
- You need full control over how each hunk is applied
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the target commit
|
||||
|
||||
```bash
|
||||
# Verify the commit exists and understand what it does
|
||||
git log --oneline -1 <commit-sha>
|
||||
|
||||
# Get the full diff (including new/deleted files)
|
||||
git show <commit-sha>
|
||||
|
||||
# Capture the original commit message for later reuse
|
||||
git log --format='%B' -1 <commit-sha>
|
||||
```
|
||||
|
||||
### 2. Identify affected modules
|
||||
|
||||
From the file paths in the diff, determine which Penpot modules are affected
|
||||
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
|
||||
files **before** making any changes. If a module has no `AGENTS.md`, skip
|
||||
that step — verify with `ls <module>/AGENTS.md` first.
|
||||
|
||||
### 3. Read the current state of each affected file
|
||||
|
||||
For every file the diff touches, read the current version on disk to understand
|
||||
context and ensure correct placement before editing.
|
||||
|
||||
### 4. Apply changes manually (the core of this approach)
|
||||
|
||||
Process every hunk in the diff using the appropriate tool:
|
||||
|
||||
| Diff action | Tool to use |
|
||||
|-------------|-------------|
|
||||
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
|
||||
| Add new file | `write` — include proper license header and namespace conventions matching project style |
|
||||
| Delete file | `bash rm <path>` |
|
||||
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
|
||||
|
||||
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
|
||||
> Use separate calls when hunks are far apart to keep `oldString` short and
|
||||
> unambiguous.
|
||||
|
||||
Repeat until **all** hunks in the diff are ported.
|
||||
|
||||
### 5. Validate
|
||||
|
||||
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
|
||||
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
|
||||
indentation, verify the logic is still semantically correct. All checks must
|
||||
pass before moving on.
|
||||
|
||||
### 6. Port the changelog entry (if any)
|
||||
|
||||
If the original commit added or modified a `CHANGES.md` entry, port that entry
|
||||
too — adapting wording and version references for the target branch.
|
||||
|
||||
### 7. Commit
|
||||
|
||||
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
|
||||
(exclude unrelated untracked files) and provide the original commit message as
|
||||
a reference, adapting it as needed for the target branch context.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Context matters** — always read files before editing; never guess
|
||||
indentation or surrounding code
|
||||
- **Lint + format + test** — never skip validation before committing
|
||||
- **Preserve intent** — keep the original commit message meaning; the
|
||||
`commiter` agent handles formatting
|
||||
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
|
||||
}
|
||||
}
|
||||
30
AGENTS.md
30
AGENTS.md
@ -32,6 +32,36 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||
`.gitignore` by default.
|
||||
|
||||
## GitHub Operations
|
||||
|
||||
To obtain the list of repository members/collaborators:
|
||||
|
||||
```bash
|
||||
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
|
||||
```
|
||||
|
||||
To obtain the list of open PRs authored by members:
|
||||
|
||||
```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)) |
|
||||
"\(.number)\t\(.author.login)\t\(.title)"
|
||||
'
|
||||
```
|
||||
|
||||
To obtain the list of open PRs from external contributors (non-members):
|
||||
|
||||
```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)"
|
||||
'
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Penpot is an open-source design tool composed of several modules:
|
||||
|
||||
149
CHANGES.md
149
CHANGES.md
@ -1,15 +1,159 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.17.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
|
||||
|
||||
## 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)
|
||||
- Add "Delete group" option to the assets panel context menu for components, colors and typographies (by @FairyPigDev) [Github #9141](https://github.com/penpot/penpot/issues/9141)
|
||||
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree in the Layers sidebar (by @MilosM348) [Github #9179](https://github.com/penpot/penpot/pull/9179)
|
||||
- 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 (by @moorsecopers99) [Github #9024](https://github.com/penpot/penpot/pull/9024)
|
||||
- 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 (by @RenzoMXD) [Github #8536](https://github.com/penpot/penpot/pull/8536)
|
||||
- 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)
|
||||
- Add a search bar to filter board size presets (by @eureka0928) [Github #4658](https://github.com/penpot/penpot/issues/4658)
|
||||
- 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 SVG from external tools such as Inkscape (by @RenzoMXD) [Github #9182](https://github.com/penpot/penpot/pull/9182)
|
||||
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts (by @RenzoMXD) [Github #9063](https://github.com/penpot/penpot/pull/9063)
|
||||
- Add pixel grid color picker in viewport settings (by @Yakehira) [Github #7750](https://github.com/penpot/penpot/issues/7750)
|
||||
- Add HEX, HSB and HSL support to the color picker with a model switcher that persists across sessions (by @edwin-rivera-dev) [Github #9133](https://github.com/penpot/penpot/issues/9133)
|
||||
- Show specific invitation-link error messages for expired, email-mismatch and invalid token cases [Github #9220](https://github.com/penpot/penpot/issues/9220)
|
||||
- Show detailed messages on file import errors to help diagnose why a file could not be imported (by @jsdevninja) [Github #9004](https://github.com/penpot/penpot/issues/9004)
|
||||
- Add read-only preview mode for saved versions — click a version name to open a dedicated preview view (by @wdeveloper16) [Github #8976](https://github.com/penpot/penpot/issues/8976)
|
||||
- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [Github #9053](https://github.com/penpot/penpot/issues/9053)
|
||||
|
||||
### :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)
|
||||
- Fix layers panel rename input showing the default type name instead of the saved layer name (by @jack-stormentswe) [Github #9231](https://github.com/penpot/penpot/pull/9231)
|
||||
- Suppress browser context menu on right-click in workspace sidebars while preserving it on text inputs (by @sujyotraut) [Github #5127](https://github.com/penpot/penpot/issues/5127)
|
||||
- Fix release notes modal appearing behind the dashboard sidebar (by @ciaokitty) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure (by @thomascolden585-svg) [Github #9092](https://github.com/penpot/penpot/issues/9092)
|
||||
- Fix imported stroke-only SVG paths losing their rounded join when split into adjacent subpaths (by @Chrissi2812) [Github #5283](https://github.com/penpot/penpot/issues/5283)
|
||||
- Fix plugin API `library.connectLibrary()` not returning a Promise when the plugin lacks `library:write` permission (by @boskodev790) [Github #9158](https://github.com/penpot/penpot/pull/9158)
|
||||
- Fix LDAP provider schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration (by @boskodev790) [Github #9165](https://github.com/penpot/penpot/pull/9165)
|
||||
- Fix `login-with-ldap` silently dropping the error message when LDAP is not initialized (typo `:hide` → `:hint`) (by @boskodev790) [Github #9159](https://github.com/penpot/penpot/pull/9159)
|
||||
- Fix plugin API `applyToken()` / `applyToShapes()` / `applyToSelected()` rejecting JS-array attribute lists (by @brunopbezerra) [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored in the OIDC callback (by @GeekClassy) [Github #9108](https://github.com/penpot/penpot/issues/9108)
|
||||
- Fix crash in share-link viewer when a team member's email is missing `@` or has no domain TLD (by @boskodev790) [Github #9120](https://github.com/penpot/penpot/pull/9120)
|
||||
- Fix crash when pasting a component with variants from an external shared library into a file that uses that library (by @FairyPigDev) [Github #8144](https://github.com/penpot/penpot/issues/8144)
|
||||
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled (by @TheAifam5) [Github #8877](https://github.com/penpot/penpot/issues/8877)
|
||||
- Fix Copy as SVG to produce a valid document for multi-shape selections and use `image/svg+xml` MIME type (by @RenzoMXD) [Github #9066](https://github.com/penpot/penpot/pull/9066)
|
||||
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
|
||||
- Preserve OpenType variant name table for custom fonts in the dashboard (by @rutherfordcraze) [Github #8924](https://github.com/penpot/penpot/issues/8924)
|
||||
- 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 (by @moorsecopers99) [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` (by @axelseis) [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)
|
||||
- Fix restore-deleted-team-files failing due to a typo in the reduce accumulator (by @Dexterity104) [Github #9241](https://github.com/penpot/penpot/issues/9241)
|
||||
- Fix internal error on layer prev/next sibling selection (by @jsdevninja) [Github #9003](https://github.com/penpot/penpot/issues/9003)
|
||||
- Fix tooltip appearing two times when nested elements [Github #9031](https://github.com/penpot/penpot/issues/9031)
|
||||
- Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070)
|
||||
|
||||
|
||||
## 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 release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
@ -41,6 +185,7 @@
|
||||
- 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
|
||||
@ -122,6 +267,8 @@
|
||||
- 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 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 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)
|
||||
@ -156,6 +303,8 @@
|
||||
|
||||
### :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)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
104
CONTRIBUTING.md
104
CONTRIBUTING.md
@ -13,7 +13,17 @@ Center](https://help.penpot.app/).
|
||||
- [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)
|
||||
@ -52,15 +62,101 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
|
||||
1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco)
|
||||
below. All code patches must include a `Signed-off-by` line.
|
||||
2. **Discuss before building** — open a question/discussion issue before
|
||||
starting work on a new feature or significant change. No PR will be
|
||||
accepted without prior discussion, whether it is a new feature, a planned
|
||||
one, or a quick win.
|
||||
2. **Discuss before building** — open a [GitHub
|
||||
Issue](https://github.com/penpot/penpot/issues) before starting work on
|
||||
a new feature or significant change. For planned features on the roadmap,
|
||||
reference the corresponding Taiga story. Do not expect your contribution
|
||||
to be accepted if you submit it without prior discussion — this applies
|
||||
to new features, planned features, and quick wins alike.
|
||||
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.
|
||||
|
||||
### Title format
|
||||
|
||||
Pull request titles **must** follow the same convention as commit subjects:
|
||||
|
||||
```
|
||||
: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
|
||||
|
||||
- We are a small team and maintainers juggle reviews alongside other
|
||||
tasks. Please do not expect your code to be reviewed instantly.
|
||||
- Reviews are handled in dedicated blocks of time, usually in the order
|
||||
PRs arrive. It may take a few days to get a first review, especially
|
||||
when urgent tasks come up.
|
||||
- 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.
|
||||
|
||||
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_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">
|
||||
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
|
||||
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
|
||||
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
|
||||
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
|
||||
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
|
||||
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg">
|
||||
</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 align="center">
|
||||
<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://penpot.app/learning-center"><b>Learning Center</b></a> •
|
||||
<a href="https://community.penpot.app/"><b>Community</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://penpot.app/learning-center"><b>Learning Center</b></a> •
|
||||
<a href="https://community.penpot.app/"><b>Community</b></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<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://www.linkedin.com/company/penpot/"><b>Linkedin</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://bsky.app/profile/penpot.app"><b>Bluesky</b></a> •
|
||||
<a href="https://twitter.com/penpotapp"><b>X</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://www.linkedin.com/company/penpot/"><b>Linkedin</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://bsky.app/profile/penpot.app"><b>Bluesky</b></a> •
|
||||
<a href="https://twitter.com/penpotapp"><b>X</b></a>
|
||||
</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.
|
||||
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)
|
||||
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.
|
||||
|
||||
🎇 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 ##
|
||||
|
||||
@ -60,101 +63,78 @@ For organizations that need extra service for its teams, [get in touch](https://
|
||||
|
||||
## 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 ###
|
||||
|
||||
[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 ###
|
||||
|
||||
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 ###
|
||||
|
||||
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 ###
|
||||
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 brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
|
||||
Penpot offers [integration](https://penpot.app/integrations-api) 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 ###
|
||||
|
||||
<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 src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<img width="100%" alt="Penpot Design Systems" src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
||||
|
||||
## 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.
|
||||
|
||||
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 ##
|
||||
|
||||
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/)!
|
||||
|
||||
You will find the following categories:
|
||||
Categories include:
|
||||
|
||||
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
|
||||
- [Troubleshooting](https://community.penpot.app/c/technical/8)
|
||||
- [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)
|
||||
- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21)
|
||||
- [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)
|
||||
|
||||
|
||||
<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 />
|
||||
<img width="100%" alt="Pentpot Community" src="https://github.com/user-attachments/assets/4b2a4360-12b5-4994-bd45-641449f86c4e" />
|
||||
|
||||
### Code of Conduct ###
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Contributing ##
|
||||
### Contributing ###
|
||||
|
||||
Any contribution will make a difference to improve Penpot. How can you get involved?
|
||||
|
||||
Choose your way:
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
- 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).
|
||||
- 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.
|
||||
- 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)
|
||||
- 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
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/).
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<img width="100%" alt="Penpot hub" src="https://github.com/user-attachments/assets/0abc02f0-625c-45ab-ad81-4927bec7a055" />
|
||||
|
||||
## 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)
|
||||
|
||||
🧑🏫 [UI Design Course](https://penpot.app/courses/)
|
||||
|
||||
|
||||
## License ##
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"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;">
|
||||
<div
|
||||
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>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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:
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-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
|
||||
|
||||
# DEPRECATED: only used for subscriptions
|
||||
@ -12,7 +13,7 @@ export PENPOT_PUBLIC_URI=https://localhost:3449
|
||||
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password
|
||||
enable-login-with-password \
|
||||
disable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
[:host {:optional true} :string]
|
||||
[:port {:optional true} ::sm/int]
|
||||
[:bind-dn {:optional true} :string]
|
||||
[:bind-passwor {:optional true} :string]
|
||||
[:bind-password {:optional true} :string]
|
||||
[:query {:optional true} :string]
|
||||
[:base-dn {:optional true} :string]
|
||||
[:attrs-email {:optional true} :string]
|
||||
|
||||
@ -401,8 +401,9 @@
|
||||
|
||||
(defn- parse-attr-path
|
||||
[provider path]
|
||||
(let [[fitem & items] (str/split path "__")]
|
||||
(into [(keyword (:type provider) fitem)] (map keyword) items)))
|
||||
(let [separator (if (str/includes? path "__") "__" ".")
|
||||
[fitem & items] (str/split path separator)]
|
||||
(into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items)))
|
||||
|
||||
(defn- build-redirect-uri
|
||||
[]
|
||||
@ -488,9 +489,9 @@
|
||||
(let [attr-ph (parse-attr-path provider "nickname")]
|
||||
(get-in props attr-ph))))]
|
||||
|
||||
(let [info (assoc info :provider-id (str (:id provider)))
|
||||
props (qualify-props provider info)
|
||||
email (get-email props)]
|
||||
(let [info (assoc info :provider-id (str (:id provider)))
|
||||
props (qualify-props provider info)
|
||||
email (get-email props)]
|
||||
{:backend (:type provider)
|
||||
:fullname (or (get-name props) email)
|
||||
:email email
|
||||
@ -547,16 +548,29 @@
|
||||
(def ^:private valid-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
|
||||
[cfg provider state code]
|
||||
(let [tdata (fetch-access-token cfg provider code)
|
||||
claims (get-id-token-claims provider tdata)
|
||||
|
||||
info (case (get provider :user-info-source)
|
||||
:token (dissoc claims :exp :iss :iat :aud :sub :sid)
|
||||
info (case (select-user-info-source (get provider :user-info-source))
|
||||
:token (dissoc claims :exp :iss :iat :aud :sid)
|
||||
:userinfo (fetch-user-info cfg provider tdata)
|
||||
(or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid))
|
||||
(fetch-user-info cfg provider tdata)))
|
||||
:auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid))
|
||||
(fetch-user-info cfg provider tdata)))
|
||||
|
||||
info (process-user-info provider tdata info)]
|
||||
|
||||
|
||||
@ -40,8 +40,8 @@
|
||||
[promesa.util :as pu]
|
||||
[yetti.adapter :as yt])
|
||||
(:import
|
||||
com.github.luben.zstd.ZstdIOException
|
||||
com.github.luben.zstd.ZstdInputStream
|
||||
com.github.luben.zstd.ZstdIOException
|
||||
com.github.luben.zstd.ZstdOutputStream
|
||||
java.io.DataInputStream
|
||||
java.io.DataOutputStream
|
||||
|
||||
@ -106,6 +106,7 @@
|
||||
|
||||
[:exporter-shared-key {:optional true} :string]
|
||||
[:nitrate-shared-key {:optional true} :string]
|
||||
[:nexus-shared-key {:optional true} :string]
|
||||
[:management-api-key {:optional true} :string]
|
||||
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
@ -331,7 +332,7 @@
|
||||
|
||||
(defn logging-context
|
||||
[]
|
||||
{:version/backend (:full version)})
|
||||
{:backend/version (:full version)})
|
||||
|
||||
;; Set value for all new threads bindings.
|
||||
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
||||
|
||||
@ -36,11 +36,11 @@
|
||||
java.sql.Connection
|
||||
java.sql.PreparedStatement
|
||||
java.sql.Savepoint
|
||||
org.postgresql.PGConnection
|
||||
org.postgresql.geometric.PGpoint
|
||||
org.postgresql.jdbc.PgArray
|
||||
org.postgresql.largeobject.LargeObject
|
||||
org.postgresql.largeobject.LargeObjectManager
|
||||
org.postgresql.PGConnection
|
||||
org.postgresql.util.PGInterval
|
||||
org.postgresql.util.PGobject))
|
||||
|
||||
|
||||
@ -22,13 +22,13 @@
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
jakarta.mail.Message$RecipientType
|
||||
jakarta.mail.Session
|
||||
jakarta.mail.Transport
|
||||
jakarta.mail.internet.InternetAddress
|
||||
jakarta.mail.internet.MimeBodyPart
|
||||
jakarta.mail.internet.MimeMessage
|
||||
jakarta.mail.internet.MimeMultipart
|
||||
jakarta.mail.Message$RecipientType
|
||||
jakarta.mail.Session
|
||||
jakarta.mail.Transport
|
||||
java.util.Properties))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -412,6 +412,21 @@
|
||||
:id ::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
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
|
||||
@ -36,10 +36,18 @@
|
||||
:cause cause)))))
|
||||
|
||||
(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]
|
||||
(let [[_ domain] (str/split email "@" 2)]
|
||||
(c/contains? blacklist (str/lower domain))))
|
||||
(let [[_ domain] (str/split email "@" 2)
|
||||
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?
|
||||
"Check if the blacklist is enabled"
|
||||
|
||||
@ -112,8 +112,9 @@
|
||||
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
||||
END"))
|
||||
|
||||
(defn- get-snapshot
|
||||
"Get snapshot with decoded data"
|
||||
(defn get-snapshot-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]
|
||||
(let [now (ct/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})
|
||||
|
||||
snapshot
|
||||
(get-snapshot cfg file-id snapshot-id)]
|
||||
(get-snapshot-data cfg file-id snapshot-id)]
|
||||
|
||||
(when-not snapshot
|
||||
(ex/raise :type :not-found
|
||||
|
||||
@ -31,7 +31,6 @@
|
||||
[app.srepl.main :as srepl]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.template :as tmpl]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
@ -71,8 +70,7 @@
|
||||
|
||||
(defn- get-resolved-file
|
||||
[cfg file-id]
|
||||
(some-> (bfc/get-file cfg file-id :migrate? false)
|
||||
(update :data blob/encode)))
|
||||
(bfc/get-file cfg file-id :migrate? false :decode? false))
|
||||
|
||||
(defn prepare-download
|
||||
[file filename]
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
::yres/status 200
|
||||
::yres/body (yres/stream-body
|
||||
(fn [_ output]
|
||||
|
||||
(let [channel (sp/chan :buf buf :xf (keep encode))
|
||||
listener (events/spawn-listener
|
||||
channel
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
;; an external storage and data cleared.
|
||||
|
||||
(def ^:private schema:event
|
||||
[:map {:title "event"}
|
||||
[:map {:title "AuditEvent"}
|
||||
[::type ::sm/text]
|
||||
[::name ::sm/text]
|
||||
[::profile-id ::sm/uuid]
|
||||
|
||||
@ -10,14 +10,11 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[integrant.core :as ig]
|
||||
[lambdaisland.uri :as u]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;; This is a task responsible to send the accumulated events to
|
||||
@ -52,19 +49,18 @@
|
||||
|
||||
(defn- send!
|
||||
[{:keys [::uri] :as cfg} events]
|
||||
(let [token (tokens/generate cfg
|
||||
{:iss "authentication"
|
||||
:uid uuid/zero})
|
||||
(let [skey (-> cfg ::setup/shared-keys :nexus)
|
||||
body (t/encode {:events events})
|
||||
headers {"content-type" "application/transit+json"
|
||||
"origin" (str (cf/get :public-uri))
|
||||
"cookie" (u/map->query-string {:auth-token token})}
|
||||
"x-shared-key" (str "nexus " skey)}
|
||||
params {:uri uri
|
||||
:timeout 12000
|
||||
:method :post
|
||||
:headers headers
|
||||
:body body}
|
||||
resp (http/req! cfg params)]
|
||||
|
||||
(if (= (:status resp) 204)
|
||||
true
|
||||
(do
|
||||
@ -85,7 +81,7 @@
|
||||
(def ^:private sql:get-audit-log-chunk
|
||||
"SELECT *
|
||||
FROM audit_log
|
||||
WHERE archived_at is null
|
||||
WHERE archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 128
|
||||
FOR UPDATE
|
||||
@ -109,7 +105,7 @@
|
||||
(def ^:private schema:handler-params
|
||||
[:map
|
||||
::db/pool
|
||||
::setup/props
|
||||
::setup/shared-keys
|
||||
::http/client])
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
|
||||
@ -50,9 +50,9 @@
|
||||
(ex-data cause))
|
||||
|
||||
ctx (-> context
|
||||
(assoc :service/tenant (cf/get :tenant))
|
||||
(assoc :service/host (cf/get :host))
|
||||
(assoc :service/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/tenant (cf/get :tenant))
|
||||
(assoc :backend/host (cf/get :host))
|
||||
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :logger/name logger)
|
||||
(assoc :logger/level level)
|
||||
|
||||
@ -470,16 +470,17 @@
|
||||
|
||||
::setup/shared-keys
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
:nitrate (cf/get :nitrate-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)}
|
||||
:nexus (cf/get :nexus-shared-key)
|
||||
:nitrate (cf/get :nitrate-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)}
|
||||
|
||||
::setup/clock
|
||||
{}
|
||||
|
||||
:app.loggers.audit.archive-task/handler
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
{::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.loggers.audit.gc-task/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
@ -31,8 +31,8 @@
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.XMLConstants
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
org.apache.commons.io.IOUtils
|
||||
org.im4java.core.ConvertCmd
|
||||
org.im4java.core.IMOperation))
|
||||
@ -54,7 +54,7 @@
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private check-input
|
||||
(def check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
@ -409,6 +409,22 @@
|
||||
(when (zero? (:exit 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:
|
||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||
(get-sfnt-type [data]
|
||||
@ -458,4 +474,27 @@
|
||||
|
||||
(= stype :ttf)
|
||||
(-> (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.Counter
|
||||
io.prometheus.client.Counter$Child
|
||||
io.prometheus.client.exporter.common.TextFormat
|
||||
io.prometheus.client.Gauge
|
||||
io.prometheus.client.Gauge$Child
|
||||
io.prometheus.client.Histogram
|
||||
io.prometheus.client.Histogram$Child
|
||||
io.prometheus.client.hotspot.DefaultExports
|
||||
io.prometheus.client.SimpleCollector
|
||||
io.prometheus.client.Summary
|
||||
io.prometheus.client.Summary$Builder
|
||||
io.prometheus.client.Summary$Child
|
||||
io.prometheus.client.exporter.common.TextFormat
|
||||
io.prometheus.client.hotspot.DefaultExports
|
||||
java.io.StringWriter))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@ -465,11 +465,20 @@
|
||||
{:name "0145-fix-plugins-uri-on-profile"
|
||||
: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")}])
|
||||
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}
|
||||
|
||||
{:name "0148-add-variant-name-team-font-variant"
|
||||
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -58,4 +58,3 @@
|
||||
(when (nil? (:data file))
|
||||
(migrate-file conn file)))
|
||||
(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,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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE team_font_variant
|
||||
ADD COLUMN variant_name text 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
|
||||
"Module that make calls to the external nitrate aplication"
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
[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.http.client :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.json :as json]
|
||||
[clojure.core :as c]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
@ -16,16 +26,16 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- request-builder
|
||||
[cfg method uri shared-key profile-id]
|
||||
[cfg method uri shared-key profile-id request-params]
|
||||
(fn []
|
||||
(http/req! cfg {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1})))
|
||||
|
||||
(http/req! cfg (cond-> {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1}
|
||||
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
|
||||
|
||||
(defn- with-retries
|
||||
[handler max-retries]
|
||||
@ -45,24 +55,49 @@
|
||||
result)))))
|
||||
|
||||
|
||||
(defn- with-validate [handler uri schema]
|
||||
(defn- with-validate [handler uri schema & {:keys [throw-on-error?]}]
|
||||
(fn []
|
||||
(let [coercer-http (sm/coercer schema
|
||||
:type :validation
|
||||
:hint (str "invalid data received calling " uri))]
|
||||
(try
|
||||
(coercer-http (-> (handler) :body json/decode))
|
||||
(catch Exception e
|
||||
;; TODO Error handling
|
||||
(l/error :hint "error validating json response" :cause e)
|
||||
nil)))))
|
||||
(let [response (handler)
|
||||
status (:status response)]
|
||||
(when-not status
|
||||
(l/error :hint "could't do the nitrate request, it is probably down"
|
||||
:uri uri)
|
||||
;; TODO decide what to do when Nitrate is inaccesible
|
||||
nil)
|
||||
(cond
|
||||
(>= 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)))
|
||||
(if throw-on-error?
|
||||
(ex/raise :type :nitrate-http-error
|
||||
:status status
|
||||
:hint (str "nitrate HTTP " status " at " uri))
|
||||
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
|
||||
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
|
||||
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
||||
(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-validate uri schema))]
|
||||
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
||||
(full-http-call)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -78,24 +113,248 @@
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:organization
|
||||
(def ^:private schema:org-summary
|
||||
[: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]
|
||||
[: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
|
||||
[:valid ::sm/boolean]])
|
||||
[:licenses ::sm/boolean]])
|
||||
|
||||
(defn- get-team-org
|
||||
(defn- get-team-org-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(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- get-owned-orgs-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(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 :get
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/owned-organizations")
|
||||
[:vector 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}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(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)))
|
||||
|
||||
(def ^:private schema:redeem-result
|
||||
[:map
|
||||
[:cancel-at [:maybe schema:timestamp]]])
|
||||
|
||||
(defn- redeem-activation-code-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri "/api/activation-codes/redeem")
|
||||
schema:redeem-result
|
||||
(assoc params :throw-on-error? true))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
@ -104,8 +363,20 @@
|
||||
(defmethod ig/init-key ::client
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org cfg)
|
||||
:is-valid-user (partial is-valid-user cfg)}))
|
||||
{:get-team-org (partial get-team-org-api 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)
|
||||
:get-owned-orgs (partial get-owned-orgs-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)
|
||||
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; UTILS
|
||||
@ -113,18 +384,57 @@
|
||||
|
||||
|
||||
(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]
|
||||
(try
|
||||
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
|
||||
(assoc profile :nitrate-licence (:valid nitrate-licence)))
|
||||
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
||||
(assoc profile :subscription subscription))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "failed to get nitrate licence"
|
||||
:profile-id (:id profile)
|
||||
:cause cause)
|
||||
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]
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
org (call cfg :get-team-org params)]
|
||||
(assoc team :organization-id (:id org) :organization-name (:name org))))
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
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])
|
||||
(:import
|
||||
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.sync.RedisCommands
|
||||
io.lettuce.core.api.sync.RedisScriptingCommands
|
||||
io.lettuce.core.codec.RedisCodec
|
||||
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.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.DefaultClientResources
|
||||
io.lettuce.core.ScriptOutputType
|
||||
io.lettuce.core.SetArgs
|
||||
io.netty.channel.nio.NioEventLoopGroup
|
||||
io.netty.util.concurrent.EventExecutorGroup
|
||||
io.netty.util.HashedWheelTimer
|
||||
io.netty.util.Timer
|
||||
io.netty.util.concurrent.EventExecutorGroup
|
||||
java.lang.AutoCloseable
|
||||
java.time.Duration))
|
||||
|
||||
|
||||
@ -73,9 +73,13 @@
|
||||
(if (nil? result)
|
||||
204
|
||||
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"))]
|
||||
|
||||
{::yres/status status
|
||||
::yres/headers headers
|
||||
::yres/body result}))]
|
||||
@ -260,6 +264,7 @@
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.nitrate
|
||||
'app.rpc.commands.profile
|
||||
'app.rpc.commands.projects
|
||||
'app.rpc.commands.search
|
||||
|
||||
@ -372,9 +372,11 @@
|
||||
(throw cause))))))
|
||||
|
||||
(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)
|
||||
team (teams/create-team conn
|
||||
team (teams/create-team cfg
|
||||
{:profile-id id
|
||||
:name "Default"
|
||||
:features features
|
||||
@ -429,7 +431,7 @@
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels conn))]
|
||||
(create-profile-rels cfg))]
|
||||
(vary-meta profile assoc :created true))))
|
||||
|
||||
created? (-> profile meta :created true?)
|
||||
|
||||
@ -49,9 +49,9 @@
|
||||
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
||||
:password (derive-password password)
|
||||
: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-rels conn))))]
|
||||
(auth/create-profile-rels cfg))))]
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
{::audit/profile-id (:id profile)})))
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.stats :as cfs]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.desc-js-like :as-alias smdj]
|
||||
@ -606,6 +607,76 @@
|
||||
(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
|
||||
|
||||
(def ^:private schema:get-file-libraries
|
||||
@ -1155,38 +1226,39 @@
|
||||
AND t.id = ?
|
||||
AND f.id = ANY(?::uuid[])")
|
||||
|
||||
(defn- restore-file
|
||||
[conn file-id]
|
||||
(db/update! conn :file
|
||||
{:deleted-at nil
|
||||
:has-media-trimmed false}
|
||||
{:id file-id}
|
||||
{::db/return-keys false})
|
||||
(def ^:private sql:restore-files
|
||||
"UPDATE file SET deleted_at = null, has_media_trimmed = false
|
||||
WHERE id = ANY(?::uuid[])")
|
||||
|
||||
(db/update! conn :file-media-object
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false})
|
||||
(def ^:private sql:restore-file-media-objects
|
||||
"UPDATE file_media_object SET deleted_at = null
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(db/update! conn :file-change
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false})
|
||||
(def ^:private sql:restore-file-changes
|
||||
"UPDATE file_change SET deleted_at = null
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(db/update! conn :file-data
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false})
|
||||
(def ^:private sql:restore-file-data
|
||||
"UPDATE file_data SET deleted_at = null
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(db/update! conn :file-thumbnail
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false})
|
||||
(def ^:private sql:restore-file-thumbnails
|
||||
"UPDATE file_thumbnail SET deleted_at = null
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(db/update! conn :file-tagged-object-thumbnail
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false}))
|
||||
(def ^:private sql:restore-file-tagged-object-thumbnails
|
||||
"UPDATE file_tagged_object_thumbnail SET deleted_at = null
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(defn- restore-files
|
||||
[conn file-ids]
|
||||
(let [file-ids (db/create-array conn "uuid" file-ids)]
|
||||
(db/exec-one! conn [sql:restore-files file-ids])
|
||||
(db/exec-one! conn [sql:restore-file-media-objects file-ids])
|
||||
(db/exec-one! conn [sql:restore-file-changes file-ids])
|
||||
(db/exec-one! conn [sql:restore-file-data file-ids])
|
||||
(db/exec-one! conn [sql:restore-file-thumbnails file-ids])
|
||||
(db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])))
|
||||
|
||||
(def ^:private sql:restore-projects
|
||||
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")
|
||||
@ -1207,17 +1279,18 @@
|
||||
(reduce (fn [result {:keys [id project-id]}]
|
||||
(let [index (-> result :files count)]
|
||||
(events/tap :progress {:file-id id :index (inc index) :total total-files})
|
||||
(restore-file conn id)
|
||||
|
||||
(-> result
|
||||
(update :files conj id)
|
||||
(update :projects conj project-id))))
|
||||
|
||||
{:files #{} :projectes #{}}
|
||||
{:files #{} :projects #{}}
|
||||
(db/plan conn [sql:resolve-editable-files team-id
|
||||
(db/create-array conn "uuid" ids)]))]
|
||||
|
||||
(restore-projects conn projects)
|
||||
(when (seq files)
|
||||
(restore-files conn files))
|
||||
|
||||
(when (seq projects)
|
||||
(restore-projects conn projects))
|
||||
|
||||
files))
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as-alias cfeat]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
@ -35,6 +36,43 @@
|
||||
(files/check-read-permissions! conn profile-id 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
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
|
||||
@ -9,12 +9,14 @@
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cmedia]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.logical-deletion :as ldel]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
@ -34,7 +36,9 @@
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.io.SequenceInputStream
|
||||
java.util.Collections))
|
||||
java.util.Collections
|
||||
java.util.zip.ZipEntry
|
||||
java.util.zip.ZipOutputStream))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@ -94,7 +98,8 @@
|
||||
[:font-id ::sm/uuid]
|
||||
[:font-family ::sm/text]
|
||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||
[:font-style [::sm/one-of {:format "string"} valid-style]]])
|
||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
||||
[:variant-name {:optional true} [:maybe ::sm/text]]])
|
||||
|
||||
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
||||
;; connection around the font creation
|
||||
@ -180,6 +185,7 @@
|
||||
:font-family (:font-family params)
|
||||
:font-weight (:font-weight params)
|
||||
:font-style (:font-style params)
|
||||
:variant-name (:variant-name params)
|
||||
:woff1-file-id (:id woff1)
|
||||
:woff2-file-id (:id woff2)
|
||||
:otf-file-id (:id otf)
|
||||
@ -296,3 +302,98 @@
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:font-family (:font-family 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
|
||||
(ex/raise :type :restriction
|
||||
:code :ldap-not-initialized
|
||||
:hide "ldap auth provider is not initialized"))
|
||||
:hint "ldap auth provider is not initialized"))
|
||||
|
||||
(let [info (ldap/authenticate provider params)]
|
||||
(when-not info
|
||||
@ -84,5 +84,5 @@
|
||||
(profile/get-profile-by-email conn))
|
||||
(->> (assoc info :is-active true :is-demo false)
|
||||
(auth/create-profile cfg)
|
||||
(auth/create-profile-rels conn)
|
||||
(auth/create-profile-rels cfg)
|
||||
(profile/strip-private-attrs))))))
|
||||
|
||||
@ -207,8 +207,7 @@
|
||||
(update :team-id bfc/lookup-index)
|
||||
(assoc :created-at timestamp)
|
||||
(assoc :modified-at timestamp))]
|
||||
(db/insert! conn :team-profile-rel params
|
||||
{::db/return-keys false})))
|
||||
(teams/add-profile-to-team! cfg params {::db/return-keys false})))
|
||||
|
||||
;; Duplicate team fonts
|
||||
(doseq [font fonts]
|
||||
@ -339,6 +338,21 @@
|
||||
;; --- COMMAND: 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}]
|
||||
(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]})
|
||||
|
||||
@ -255,7 +255,7 @@
|
||||
[:session-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::create-upload-session
|
||||
{::doc/added "2.16"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:create-upload-session
|
||||
::sm/result schema:create-upload-session-result}
|
||||
[{:keys [::db/pool] :as cfg}
|
||||
@ -293,7 +293,7 @@
|
||||
[:index ::sm/int]])
|
||||
|
||||
(sv/defmethod ::upload-chunk
|
||||
{::doc/added "2.16"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:upload-chunk
|
||||
::sm/result schema:upload-chunk-result}
|
||||
[{:keys [::db/pool] :as cfg}
|
||||
@ -389,7 +389,7 @@
|
||||
[:id {:optional true} ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::assemble-file-media-object
|
||||
{::doc/added "2.16"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:assemble-file-media-object
|
||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]}
|
||||
|
||||
319
backend/src/app/rpc/commands/nitrate.clj
Normal file
319
backend/src/app/rpc/commands/nitrate.clj
Normal file
@ -0,0 +1,319 @@
|
||||
;; 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.common.time :as ct]
|
||||
[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 schema:redeem-activation-code-params
|
||||
[:map {:title "RedeemActivationCodeParams"}
|
||||
[:activation-code ::sm/text]])
|
||||
|
||||
(def ^:private schema:redeem-activation-code-result
|
||||
[:map {:title "RedeemActivationCodeResult"}
|
||||
[:cancel-at [:maybe ct/schema:inst]]])
|
||||
|
||||
(sv/defmethod ::redeem-nitrate-activation-code
|
||||
{::rpc/auth true
|
||||
::doc/added "2.14"
|
||||
::sm/params schema:redeem-activation-code-params
|
||||
::sm/result schema:redeem-activation-code-result}
|
||||
[cfg {:keys [::rpc/profile-id activation-code]}]
|
||||
(let [profile (db/get cfg :profile {:id profile-id})]
|
||||
(try
|
||||
(let [result (nitrate/call cfg :redeem-activation-code
|
||||
{:request-params {:code activation-code
|
||||
:penpot-id profile-id
|
||||
:email (:email profile)}})]
|
||||
(when-not result
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-activation-code
|
||||
:hint "The activation code is invalid, expired or fully redeemed"))
|
||||
result)
|
||||
(catch Exception cause
|
||||
(let [{:keys [type status]} (ex-data cause)]
|
||||
(if (= type :nitrate-http-error)
|
||||
(ex/raise :type :validation
|
||||
:code (case status
|
||||
410 :expired-activation-code
|
||||
:invalid-activation-code)
|
||||
:cause cause)
|
||||
(throw cause)))))))
|
||||
|
||||
(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 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
|
||||
[:map {:title "ProfileProps"}
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
[:newsletter-news {:optional true} ::sm/boolean]
|
||||
@ -314,6 +315,25 @@
|
||||
(climit/invoke! generate-thumbnail file))]
|
||||
(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
|
||||
|
||||
(declare ^:private request-email-change!)
|
||||
@ -462,6 +482,9 @@
|
||||
{:deleted-at deleted-at}
|
||||
{: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
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
|
||||
@ -193,7 +193,7 @@
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(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
|
||||
"SELECT t.id, t.name,
|
||||
@ -471,8 +471,8 @@
|
||||
;; --- COMMAND QUERY: get-team-info
|
||||
|
||||
(defn get-team-info
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
|
||||
(-> (db/get* conn :team
|
||||
[cfg {:keys [id] :as params}]
|
||||
(-> (db/get* cfg :team
|
||||
{:id id}
|
||||
{::sql/columns [:id :is-default :features]})
|
||||
(decode-row)))
|
||||
@ -497,9 +497,11 @@
|
||||
|
||||
(def ^:private schema:create-team
|
||||
[:map {:title "create-team"}
|
||||
[:name [:string {:max 250}]]
|
||||
[:name types.team/schema:team-name]
|
||||
[: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
|
||||
{::doc/added "1.17"
|
||||
@ -520,17 +522,89 @@
|
||||
(with-meta 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
|
||||
"This is a complete team creation process, it creates the team
|
||||
object and all related objects (default role and default project)."
|
||||
[cfg-or-conn params]
|
||||
(let [conn (db/get-connection cfg-or-conn)
|
||||
team (create-team* conn params)
|
||||
[{:keys [::db/conn] :as cfg} params]
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
(let [team (create-team* conn params)
|
||||
params (assoc params
|
||||
:team-id (:id team)
|
||||
:role :owner)
|
||||
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))))
|
||||
|
||||
(defn- create-team*
|
||||
@ -546,11 +620,13 @@
|
||||
(decode-row team)))
|
||||
|
||||
(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
|
||||
:profile-id profile-id}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :team-profile-rel))))
|
||||
(add-profile-to-team! cfg))))
|
||||
|
||||
(defn- create-team-default-project
|
||||
[conn {:keys [profile-id team-id] :as params}]
|
||||
@ -591,7 +667,7 @@
|
||||
|
||||
(def ^:private schema:update-team
|
||||
[:map {:title "update-team"}
|
||||
[:name [:string {:max 250}]]
|
||||
[:name types.team/schema:team-name]
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::update-team
|
||||
@ -609,7 +685,7 @@
|
||||
;; --- Mutation: 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)
|
||||
members (get-team-members conn id)]
|
||||
|
||||
@ -624,7 +700,9 @@
|
||||
;; if the `reassign-to` is filled and has a different value
|
||||
;; than the current profile-id, we proceed to reassing the
|
||||
;; 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)]
|
||||
(when-not member
|
||||
(ex/raise :type :not-found :code :member-does-not-exist))
|
||||
@ -638,7 +716,15 @@
|
||||
;; assign owner role to new profile
|
||||
(db/update! conn :team-profile-rel
|
||||
(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
|
||||
;; current profile is owner, we dont allow it because there
|
||||
@ -663,32 +749,44 @@
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:leave-team
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(leave-team conn (assoc params :profile-id profile-id)))
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(leave-team cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
|
||||
;; --- Mutation: Delete Team
|
||||
|
||||
(defn- delete-team
|
||||
(defn delete-team
|
||||
"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)
|
||||
team (db/update! conn :team
|
||||
{:deleted-at (ct/in-future delay)}
|
||||
{:id id}
|
||||
{::db/return-keys true})]
|
||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||
perms (get team :permissions)]
|
||||
|
||||
(when-not (:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
(when (:is-default team)
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-team
|
||||
:hint "impossible to delete default team"))
|
||||
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :team
|
||||
:deleted-at (:deleted-at team)
|
||||
:id id}})
|
||||
team))
|
||||
(let [delay (ldel/get-deletion-delay team)
|
||||
team (db/update! conn :team
|
||||
{:deleted-at (ct/in-future delay)}
|
||||
{:id team-id}
|
||||
{::db/return-keys true})]
|
||||
|
||||
;; 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
|
||||
[:map {:title "delete-team"}
|
||||
@ -698,16 +796,9 @@
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:delete-team
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(let [team (get-team conn :profile-id profile-id :team-id id)
|
||||
perms (get team :permissions)]
|
||||
|
||||
(when-not (:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
(delete-team conn team)
|
||||
nil))
|
||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||
(delete-team cfg {:team-id id :profile-id profile-id})
|
||||
nil)
|
||||
|
||||
;; --- Mutation: Team Update Role
|
||||
|
||||
|
||||
@ -19,8 +19,10 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
@ -35,20 +37,29 @@
|
||||
;; --- Mutation: Create Team Invitation
|
||||
|
||||
(def sql:upsert-team-invitation
|
||||
"insert into team_invitation(id, team_id, email_to, created_by, role, valid_until)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||
values (?, ?, null, ?, ?, ?, ?)
|
||||
on conflict(team_id, email_to) do
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
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
|
||||
[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
|
||||
{:iss :team-invitation
|
||||
:exp valid-until
|
||||
:profile-id profile-id
|
||||
:role role
|
||||
:team-id team-id
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name
|
||||
:member-email member-email
|
||||
:member-id member-id}))
|
||||
|
||||
@ -74,23 +85,51 @@
|
||||
[:role types.team/schema:role]
|
||||
[: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
|
||||
(sm/check-fn schema:create-invitation))
|
||||
|
||||
(def ^:private check-create-org-invitation-params
|
||||
(sm/check-fn schema:create-org-invitation))
|
||||
|
||||
(defn- allow-invitation-emails?
|
||||
[member]
|
||||
(let [notifications (dm/get-in member [:props :notifications])]
|
||||
(not= :none (:email-invites notifications))))
|
||||
|
||||
(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 (check-create-invitation-params params))
|
||||
(assert (db/connection-map? cfg)
|
||||
"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)
|
||||
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
|
||||
;; already present in the database, we proceed to add it to the
|
||||
;; team as-is, without email roundtrip.
|
||||
@ -103,9 +142,12 @@
|
||||
:profile-id (:id member)}
|
||||
(get types.team/permissions-for-role role))]
|
||||
|
||||
;; Insert the invited member to the team
|
||||
(db/insert! conn :team-profile-rel params
|
||||
{::db/on-conflict-do-nothing? true})
|
||||
(if organization
|
||||
;; Insert the invited member to the org
|
||||
(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
|
||||
;; accepting an invitation link serves as verification.
|
||||
@ -122,18 +164,30 @@
|
||||
(teams/check-email-spam conn email true)
|
||||
|
||||
(let [id (uuid/next)
|
||||
expire (ct/in-future "168h") ;; 7 days
|
||||
invitation (db/exec-one! conn [sql:upsert-team-invitation id
|
||||
(:id team) (str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire])
|
||||
expire (if organization
|
||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||
(ct/in-future "168h")) ;; 7 days
|
||||
invitation (db/exec-one! conn (if organization
|
||||
[sql:upsert-org-invitation id
|
||||
(:id organization)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]
|
||||
[sql:upsert-team-invitation id
|
||||
(:id team)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
(name role) expire
|
||||
(name role) expire]))
|
||||
updated? (not= id (:id invitation))
|
||||
profile-id (:id profile)
|
||||
tprops {:profile-id profile-id
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
:team-id (:id team)
|
||||
:organization-id (:id organization)
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
@ -145,28 +199,58 @@
|
||||
|
||||
(let [props (-> (dissoc tprops :profile-id)
|
||||
(audit/clean-props))
|
||||
evname (if updated?
|
||||
"update-team-invitation"
|
||||
"create-team-invitation")
|
||||
evname (cond
|
||||
(and updated? organization) "update-org-invitation"
|
||||
updated? "update-team-invitation"
|
||||
organization "create-org-invitation"
|
||||
:else "create-team-invitation")
|
||||
event (-> (audit/event-from-rpc-params params)
|
||||
(assoc ::audit/name evname)
|
||||
(assoc ::audit/props props))]
|
||||
(audit/submit! cfg event))
|
||||
|
||||
(when (allow-invitation-emails? member)
|
||||
(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)
|
||||
:token itoken
|
||||
:extra-data ptoken}))
|
||||
(if organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-org
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
: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)))))
|
||||
|
||||
(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
|
||||
[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)
|
||||
params (merge
|
||||
@ -186,7 +270,7 @@
|
||||
::quotes/team-id team-id})
|
||||
|
||||
;; 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
|
||||
(db/delete! conn :team-access-request
|
||||
@ -268,7 +352,7 @@
|
||||
(filter #(contains? invitation-emails (key %)))
|
||||
(map (fn [[email member]]
|
||||
(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))
|
||||
|
||||
invitations))
|
||||
|
||||
@ -16,8 +16,10 @@
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.quotes :as quotes]
|
||||
@ -86,52 +88,74 @@
|
||||
;; --- Team 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
|
||||
role (or (some-> invitation :role keyword) role)
|
||||
params (merge
|
||||
{:team-id team-id
|
||||
:profile-id (:id member)}
|
||||
(get types.team/permissions-for-role role))]
|
||||
id-member (:id member)]
|
||||
|
||||
;; Do not allow blocked users accept invitations.
|
||||
(when (:is-blocked member)
|
||||
(ex/raise :type :restriction
|
||||
:code :profile-blocked))
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
|
||||
::quotes/profile-id (:id member)
|
||||
::quotes/team-id team-id})
|
||||
(when team-id
|
||||
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
|
||||
::quotes/profile-id id-member
|
||||
::quotes/team-id team-id}))
|
||||
|
||||
;; Insert the invited member to the team
|
||||
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
|
||||
(let [params (merge
|
||||
{: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
|
||||
;; accepting an invitation link serves as verification.
|
||||
(when-not (:is-active member)
|
||||
(db/update! conn :profile
|
||||
{:is-active true}
|
||||
{:id (:id member)}))
|
||||
accepted-team-id (if organization-id
|
||||
;; Insert the invited member to the org
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
|
||||
;; Insert the invited member to the team
|
||||
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||
team-id))]
|
||||
|
||||
;; Delete the invitation
|
||||
(db/delete! conn :team-invitation
|
||||
{:team-id team-id :email-to member-email})
|
||||
(when-not accepted-team-id
|
||||
(ex/raise :type :internal
|
||||
: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
|
||||
[:map {:title "TeamInvitationClaims"}
|
||||
[:iss :keyword]
|
||||
[:exp ::ct/inst]
|
||||
[:profile-id ::sm/uuid]
|
||||
[:role types.team/schema:role]
|
||||
[:team-id ::sm/uuid]
|
||||
[:member-email ::sm/email]
|
||||
[:member-id {:optional true} ::sm/uuid]])
|
||||
[:and
|
||||
[:map {:title "TeamInvitationClaims"}
|
||||
[:iss :keyword]
|
||||
[:exp ::ct/inst]
|
||||
[:profile-id ::sm/uuid]
|
||||
[:role types.team/schema:role]
|
||||
[:team-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?
|
||||
(sm/lazy-validator schema:team-invitation-claims))
|
||||
@ -139,7 +163,7 @@
|
||||
(defmethod process-token :team-invitation
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{: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)
|
||||
(ex/raise :type :validation
|
||||
@ -147,19 +171,45 @@
|
||||
:hint "invitation token contains unexpected data"))
|
||||
|
||||
(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
|
||||
{:id profile-id}
|
||||
{:columns [:id :email]})
|
||||
registration-disabled? (not (contains? cf/flags :registration))]
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "no invitation associated with the token"))
|
||||
{:columns [:id :email :default-team-id]})
|
||||
registration-disabled? (not (contains? cf/flags :registration))
|
||||
|
||||
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
|
||||
membership (when org-invitation?
|
||||
(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
|
||||
:reason :email-mismatch
|
||||
: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
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
@ -187,17 +237,16 @@
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile))))))
|
||||
|
||||
(accept-invitation cfg claims invitation profile)
|
||||
(assoc claims :state :created))
|
||||
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "logged-in user does not matches the invitation"))
|
||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||
(cond-> (assoc claims :state :created)
|
||||
;; when the invitation is to an org, instead of a team, add the
|
||||
;; accepted-team-id as :org-team-id
|
||||
(:organization-id claims)
|
||||
(assoc :org-team-id accepted-team-id)))))
|
||||
|
||||
;; 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
|
||||
;; token and registration is enabled, we redirect user the the register page.
|
||||
|
||||
{:invitation-token token
|
||||
:iss :team-invitation
|
||||
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
||||
|
||||
@ -28,19 +28,25 @@
|
||||
(update :pages-index select-keys allowed)))
|
||||
|
||||
(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]
|
||||
(let [[name domain]
|
||||
(str/split email "@" 2)
|
||||
(str/split (or email "") "@" 2)
|
||||
|
||||
[_ rest]
|
||||
(str/split domain "." 2)
|
||||
(str/split (or domain "") "." 2)
|
||||
|
||||
name
|
||||
(if (> (count name) 3)
|
||||
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
|
||||
"****")]
|
||||
|
||||
(str name "@****." rest)))
|
||||
(str name "@****" (when rest (str "." rest)))))
|
||||
|
||||
(defn anonymize-member
|
||||
[member]
|
||||
|
||||
@ -8,17 +8,35 @@
|
||||
"Internal Nitrate HTTP RPC API. Provides authenticated access to
|
||||
organization management and token validation endpoints."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :refer [schema:team-with-organization]]
|
||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||
[app.common.types.team :refer [schema:team]]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.msgbus :as mbus]
|
||||
[app.media :as media]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.nitrate :as cnit]
|
||||
[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.util.services :as sv]))
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]))
|
||||
|
||||
|
||||
(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
|
||||
|
||||
@ -28,11 +46,9 @@
|
||||
::sm/params [:map]
|
||||
::sm/result schema:profile}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
{:id (get profile :id)
|
||||
:name (get profile :fullname)
|
||||
:email (get profile :email)
|
||||
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
(-> (profile-to-map profile)
|
||||
(assoc :theme (:theme profile)))))
|
||||
|
||||
;; ---- API: get-teams
|
||||
|
||||
@ -45,6 +61,19 @@
|
||||
AND t.is_default IS FALSE
|
||||
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
|
||||
[:vector schema:team])
|
||||
|
||||
@ -58,28 +87,63 @@
|
||||
(->> (db/exec! cfg [sql:get-teams current-user-id])
|
||||
(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
|
||||
[:id ::sm/uuid]
|
||||
[:organization-id ::sm/text]])
|
||||
[:content media/schema:upload]
|
||||
[: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
|
||||
"Notify to Penpot a team change from nitrate"
|
||||
{::doc/added "2.14"
|
||||
::sm/params schema:notify-team-change
|
||||
::sm/params schema:team-with-organization
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [id organization-id organization-name]}]
|
||||
(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-id id
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name})))
|
||||
[cfg team]
|
||||
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
||||
nil)
|
||||
|
||||
;; ---- API: notify-user-added-to-organization
|
||||
|
||||
(def ^:private schema:notify-user-added-to-organization
|
||||
[:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[: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
|
||||
@ -112,3 +176,441 @@
|
||||
[cfg {:keys [::rpc/profile-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])))
|
||||
|
||||
;; ---- 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 sql:get-teams-files-counts
|
||||
"SELECT p.team_id, COUNT(f.*) AS total
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
JOIN team AS t ON (t.id = p.team_id)
|
||||
WHERE t.id = ANY(?)
|
||||
AND t.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
GROUP BY p.team_id;")
|
||||
|
||||
(def ^:private sql:soft-delete-teams
|
||||
"UPDATE team
|
||||
SET deleted_at = ?
|
||||
WHERE id = ANY(?)
|
||||
RETURNING id, deleted_at;")
|
||||
|
||||
|
||||
;; ---- API: notify-organization-deletion
|
||||
|
||||
(def ^:private schema:notify-organization-deletion
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
|
||||
(defn- soft-delete-teams!
|
||||
"Soft-delete the provided team ids and submit a delete task per team."
|
||||
[{:keys [::db/conn] :as cfg} team-ids]
|
||||
(when (seq team-ids)
|
||||
(let [delay (cf/get-deletion-delay)
|
||||
deleted-at (ct/in-future delay)
|
||||
updated (db/exec! conn [sql:soft-delete-teams
|
||||
deleted-at
|
||||
(db/create-array conn "uuid" team-ids)])]
|
||||
(doseq [{:keys [id deleted-at]} updated]
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :team
|
||||
:deleted-at deleted-at
|
||||
:id id}}))))
|
||||
nil)
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a list of teams, rename those with files and delete those without, then notify users."
|
||||
[cfg {:keys [teams organization-name]}]
|
||||
(let [teams (->> teams (filter uuid?) distinct (into []))]
|
||||
(when (seq teams)
|
||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [teams-array (db/create-array conn "uuid" teams)
|
||||
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
|
||||
(filter (fn [{:keys [total]}] (pos? total)))
|
||||
(map :team-id)
|
||||
(into #{}))
|
||||
teams-to-keep (->> teams (filter teams-with-files) (into []))
|
||||
teams-to-delete (->> teams (remove teams-with-files) (into []))]
|
||||
|
||||
;; Rename teams that have files in one go
|
||||
(when (seq teams-to-keep)
|
||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||
org-prefix
|
||||
(db/create-array conn "uuid" teams-to-keep)]))
|
||||
|
||||
;; Soft-delete empty teams in one go
|
||||
(soft-delete-teams! cfg teams-to-delete)
|
||||
|
||||
(notifications/notify-organization-deletion cfg organization-name teams teams-to-delete)
|
||||
nil)))))))
|
||||
|
||||
|
||||
(sv/defmethod ::notify-organization-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-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
teams (->> (:teams org-summary)
|
||||
(map :id))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
|
||||
nil))
|
||||
|
||||
;; ---- API: notify-user-organizations-deletion
|
||||
|
||||
(def ^:private schema:notify-user-organizations-deletion
|
||||
[:map
|
||||
[:profile-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::notify-user-organizations-deletion
|
||||
"For a given user, find all owned organizations and rename or delete their teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion}
|
||||
[cfg {:keys [profile-id]}]
|
||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
||||
(doseq [org owned-orgs]
|
||||
(let [organization-name (:name org)
|
||||
teams (map :id (:teams org))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
|
||||
nil)
|
||||
|
||||
|
||||
|
||||
|
||||
;; ---- 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)}))
|
||||
|
||||
|
||||
44
backend/src/app/rpc/notifications.clj
Normal file
44
backend/src/app/rpc/notifications.clj
Normal file
@ -0,0 +1,44 @@
|
||||
;; 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})))
|
||||
|
||||
|
||||
(defn notify-organization-deletion
|
||||
[cfg organization-name teams deleted-teams]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic uuid/zero
|
||||
:message {:type :organization-deleted
|
||||
:organization-name organization-name
|
||||
:teams teams
|
||||
:deleted-teams deleted-teams})))
|
||||
@ -82,45 +82,37 @@
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||
(db/xact-lock! conn 0)
|
||||
(when-not key
|
||||
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
|
||||
"all sessions on each restart, it is highly recommended setting up the "
|
||||
"PENPOT_SECRET_KEY environment variable")))
|
||||
(l/wrn :hint (str "using autogenerated secret-key, it will change "
|
||||
"on each restart and will invalidate "
|
||||
"all sessions on each restart, it is highly "
|
||||
"recommended setting up the "
|
||||
"PENPOT_SECRET_KEY environment variable")))
|
||||
(let [secret (or key (generate-random-key))]
|
||||
(-> (get-all-props conn)
|
||||
(assoc :secret-key secret)
|
||||
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
||||
(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
|
||||
[_ {:keys [::props] :as cfg}]
|
||||
(let [secret (get props :secret-key)]
|
||||
(d/without-nils
|
||||
{:exporter
|
||||
(let [key (or (get cfg :exporter)
|
||||
(-> (keys/derive secret :salt "exporter")
|
||||
(bc/bytes->b64-str true)))]
|
||||
(if (or (str/empty? key)
|
||||
(str/blank? key))
|
||||
(do
|
||||
(l/wrn :hint "exporter key is disabled because empty string found")
|
||||
nil)
|
||||
(do
|
||||
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
|
||||
key)))
|
||||
(reduce (fn [keys id]
|
||||
(let [key (or (get cfg id)
|
||||
(-> (keys/derive secret :salt (name id))
|
||||
(bc/bytes->b64-str true)))]
|
||||
(if (or (str/empty? key)
|
||||
(str/blank? key))
|
||||
(do
|
||||
(l/wrn :id (name id) :hint "key is disabled because empty string found")
|
||||
keys)
|
||||
(do
|
||||
(l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
|
||||
(assoc keys id key)))))
|
||||
{}
|
||||
[:exporter
|
||||
:nitrate
|
||||
:nexus])))
|
||||
|
||||
:nitrate
|
||||
(let [key (or (get cfg :nitrate)
|
||||
(-> (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)))})))
|
||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
:or {is-active true}}]
|
||||
(some-> (get-current-system)
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(fn [system]
|
||||
(let [password (derive-password password)
|
||||
params {:id (uuid/next)
|
||||
:email email
|
||||
@ -62,7 +62,7 @@
|
||||
:password password
|
||||
:props {}}]
|
||||
(->> (cmd.auth/create-profile system params)
|
||||
(cmd.auth/create-profile-rels conn)))))))
|
||||
(cmd.auth/create-profile-rels system)))))))
|
||||
|
||||
(defmethod exec-command "update-profile"
|
||||
[{:keys [fullname email password is-active]}]
|
||||
|
||||
@ -588,7 +588,7 @@
|
||||
::audit/tracked-at (ct/now)})
|
||||
|
||||
|
||||
(#'files/restore-file conn file-id))
|
||||
(#'files/restore-files conn [file-id]))
|
||||
:restored))))
|
||||
|
||||
(defn delete-project!
|
||||
@ -622,7 +622,7 @@
|
||||
(doseq [{:keys [id]} (db/query conn :file
|
||||
{:project-id project-id}
|
||||
{::sql/columns [:id]})]
|
||||
(#'files/restore-file conn id))
|
||||
(#'files/restore-files conn [id]))
|
||||
|
||||
:restored)
|
||||
|
||||
@ -905,5 +905,4 @@
|
||||
(let [params (-> rel
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :team-id (:id team)))]
|
||||
(db/insert! conn :team-profile-rel params
|
||||
{::db/return-keys false}))))))))
|
||||
(teams/add-profile-to-team! cfg params {::db/return-keys false}))))))))
|
||||
|
||||
@ -44,6 +44,7 @@
|
||||
"file-object-thumbnail"
|
||||
"file-thumbnail"
|
||||
"profile"
|
||||
"organization"
|
||||
"tempfile"
|
||||
"file-data"
|
||||
"file-data-fragment"
|
||||
|
||||
@ -166,6 +166,7 @@
|
||||
"profile" (process-objects! conn has-profile-refs? bucket objects)
|
||||
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
|
||||
"tempfile" (process-objects! conn (constantly false) bucket objects)
|
||||
"organization" (process-objects! conn (constantly false) bucket objects)
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-unknown-reference
|
||||
:hint (dm/fmt "unknown reference '%'" bucket))))
|
||||
|
||||
@ -30,21 +30,18 @@
|
||||
java.nio.file.Path
|
||||
java.time.Duration
|
||||
java.util.Collection
|
||||
java.util.Optional
|
||||
java.util.concurrent.atomic.AtomicLong
|
||||
java.util.Optional
|
||||
org.reactivestreams.Subscriber
|
||||
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
|
||||
software.amazon.awssdk.core.ResponseBytes
|
||||
software.amazon.awssdk.core.async.AsyncRequestBody
|
||||
software.amazon.awssdk.core.async.AsyncResponseTransformer
|
||||
software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody
|
||||
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.SdkEventLoopGroup
|
||||
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.DeleteObjectRequest
|
||||
software.amazon.awssdk.services.s3.model.DeleteObjectsRequest
|
||||
@ -54,9 +51,12 @@
|
||||
software.amazon.awssdk.services.s3.model.ObjectIdentifier
|
||||
software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||
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.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
|
||||
"A maximum number of retries on internal operations"
|
||||
|
||||
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}
|
||||
params)]
|
||||
(db/run! system
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(fn [cfg]
|
||||
(->> params
|
||||
(cmd.auth/create-profile cfg)
|
||||
(cmd.auth/create-profile-rels conn)))))))
|
||||
(cmd.auth/create-profile-rels cfg)))))))
|
||||
|
||||
(defn create-project*
|
||||
([i params] (create-project* *system* i params))
|
||||
@ -234,10 +234,10 @@
|
||||
(dm/with-open [conn (db/open system)]
|
||||
(let [id (mk-uuid "team" i)
|
||||
features (cfeat/get-enabled-features cf/flags)]
|
||||
(teams/create-team conn {:id id
|
||||
:profile-id profile-id
|
||||
:features features
|
||||
:name (str "team" i)})))))
|
||||
(teams/create-team {::db/conn conn} {:id id
|
||||
:profile-id profile-id
|
||||
:features features
|
||||
:name (str "team" i)})))))
|
||||
|
||||
(defn create-file-media-object*
|
||||
([params] (create-file-media-object* *system* params))
|
||||
@ -283,9 +283,10 @@
|
||||
([params] (create-team-role* *system* params))
|
||||
([system {:keys [team-id profile-id role] :or {role :owner}}]
|
||||
(dm/with-open [conn (db/open system)]
|
||||
(#'teams/create-team-role conn {:team-id team-id
|
||||
:profile-id profile-id
|
||||
:role role}))))
|
||||
(#'teams/create-team-role {::db/conn conn}
|
||||
{:team-id team-id
|
||||
:profile-id profile-id
|
||||
:role role}))))
|
||||
|
||||
(defn create-project-role*
|
||||
([params] (create-project-role* *system* params))
|
||||
@ -384,6 +385,31 @@
|
||||
(dissoc ::type)
|
||||
(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!
|
||||
([name]
|
||||
(run-task! name {}))
|
||||
|
||||
@ -2121,3 +2121,92 @@
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
|
||||
(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))))))
|
||||
|
||||
@ -93,6 +93,41 @@
|
||||
:font-weight
|
||||
: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
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
|
||||
926
backend/test/backend_tests/rpc_management_nitrate_test.clj
Normal file
926
backend/test/backend_tests/rpc_management_nitrate_test.clj
Normal file
@ -0,0 +1,926 @@
|
||||
;; 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.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.worker :as wrk]
|
||||
[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-organization-deletion-prefixes-teams-and-publishes-org-deleted-event
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
;; One team will have files -> it will be kept and renamed.
|
||||
team-with-files (th/db-get :team {:id (:default-team-id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team-with-files)})
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
|
||||
;; One team will be empty -> it will be soft-deleted.
|
||||
empty-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
organization-name "Acme / Design"
|
||||
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||
org-summary {:id organization-id
|
||||
:name organization-name
|
||||
:teams [{:id (:id team-with-files)}
|
||||
{:id (:id empty-team)}]}
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
(t/is (= :get-org-summary method))
|
||||
(t/is (= {:organization-id organization-id} params))
|
||||
org-summary)
|
||||
wrk/submit! (fn [task]
|
||||
(swap! submitted conj task)
|
||||
nil)
|
||||
mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||
(swap! calls conj {:topic topic
|
||||
:message message}))]
|
||||
(management-command-with-nitrate! {::th/type :notify-organization-deletion
|
||||
::rpc/profile-id (:id profile)
|
||||
:organization-id organization-id}))
|
||||
updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false})
|
||||
updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; Team with files is kept, unset as default, and renamed with org prefix.
|
||||
(t/is (false? (:is-default updated-with-files)))
|
||||
(t/is (str/starts-with? (:name updated-with-files) expected-start))
|
||||
(t/is (nil? (:deleted-at updated-with-files)))
|
||||
|
||||
;; Empty team is soft-deleted and a delete task is submitted.
|
||||
(t/is (some? (:deleted-at updated-empty)))
|
||||
(t/is (= 1 (count @submitted)))
|
||||
|
||||
;; A single organization-deleted event is published.
|
||||
(t/is (= 1 (count @calls)))
|
||||
(let [{:keys [topic message]} (first @calls)]
|
||||
(t/is (= uuid/zero topic))
|
||||
(t/is (= :organization-deleted (:type message)))
|
||||
(t/is (= organization-name (:organization-name message)))
|
||||
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
||||
(set (:teams message))))
|
||||
(t/is (= #{(:id empty-team)}
|
||||
(set (:deleted-teams message)))))))
|
||||
|
||||
(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-org-events
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
;; org-1: one team with files, one empty
|
||||
org-1-team-files (th/db-get :team {:id (:default-team-id profile)})
|
||||
org-1-proj (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id org-1-team-files)})
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id org-1-proj)})
|
||||
org-1-team-empty (th/create-team* 1 {:profile-id (:id profile)})
|
||||
|
||||
;; org-2: one team with files, one empty
|
||||
org-2-team-files (th/create-team* 2 {:profile-id (:id profile)})
|
||||
org-2-proj (th/create-project* 2 {:profile-id (:id profile)
|
||||
:team-id (:id org-2-team-files)})
|
||||
_ (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:id org-2-proj)})
|
||||
org-2-team-empty (th/create-team* 3 {:profile-id (:id profile)})
|
||||
|
||||
org-1-id (uuid/random)
|
||||
org-2-id (uuid/random)
|
||||
org-1-name "Org One / Design"
|
||||
org-2-name "Org Two"
|
||||
org-1-prefix (str "[" (d/sanitize-string org-1-name) "] ")
|
||||
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
|
||||
owned-orgs [{:id org-1-id
|
||||
:name org-1-name
|
||||
:teams [{:id (:id org-1-team-files)}
|
||||
{:id (:id org-1-team-empty)}]}
|
||||
{:id org-2-id
|
||||
:name org-2-name
|
||||
:teams [{:id (:id org-2-team-files)}
|
||||
{:id (:id org-2-team-empty)}]}]
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
(case method
|
||||
:get-owned-orgs
|
||||
(do
|
||||
(t/is (= {:profile-id (:id profile)} params))
|
||||
owned-orgs)
|
||||
nil))
|
||||
wrk/submit! (fn [task]
|
||||
(swap! submitted conj task)
|
||||
nil)
|
||||
mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||
(swap! calls conj {:topic topic
|
||||
:message message}))]
|
||||
(management-command-with-nitrate! {::th/type :notify-user-organizations-deletion
|
||||
::rpc/profile-id (:id profile)
|
||||
:profile-id (:id profile)}))
|
||||
org-1-updated-files (th/db-get :team {:id (:id org-1-team-files)} {::db/remove-deleted false})
|
||||
org-1-updated-empty (th/db-get :team {:id (:id org-1-team-empty)} {::db/remove-deleted false})
|
||||
org-2-updated-files (th/db-get :team {:id (:id org-2-team-files)} {::db/remove-deleted false})
|
||||
org-2-updated-empty (th/db-get :team {:id (:id org-2-team-empty)} {::db/remove-deleted false})
|
||||
msgs (->> @calls (map :message) vec)
|
||||
org-msg (fn [org-name]
|
||||
(first (filter #(= org-name (:organization-name %)) msgs)))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; org-1: team with files renamed; empty team deleted
|
||||
(t/is (false? (:is-default org-1-updated-files)))
|
||||
(t/is (str/starts-with? (:name org-1-updated-files) org-1-prefix))
|
||||
(t/is (nil? (:deleted-at org-1-updated-files)))
|
||||
(t/is (some? (:deleted-at org-1-updated-empty)))
|
||||
|
||||
;; org-2: team with files renamed; empty team deleted
|
||||
(t/is (false? (:is-default org-2-updated-files)))
|
||||
(t/is (str/starts-with? (:name org-2-updated-files) org-2-prefix))
|
||||
(t/is (nil? (:deleted-at org-2-updated-files)))
|
||||
(t/is (some? (:deleted-at org-2-updated-empty)))
|
||||
|
||||
;; two delete tasks (one per empty team)
|
||||
(t/is (= 2 (count @submitted)))
|
||||
|
||||
;; one organization-deleted event per org
|
||||
(t/is (= 2 (count @calls)))
|
||||
(t/is (every? #(= uuid/zero (:topic %)) @calls))
|
||||
(t/is (= #{:organization-deleted}
|
||||
(set (map (comp :type :message) @calls))))
|
||||
|
||||
(let [m1 (org-msg org-1-name)
|
||||
m2 (org-msg org-2-name)]
|
||||
(t/is (some? m1))
|
||||
(t/is (some? m2))
|
||||
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
|
||||
(set (:teams m1))))
|
||||
(t/is (= #{(:id org-1-team-empty)}
|
||||
(set (:deleted-teams m1))))
|
||||
(t/is (= #{(:id org-2-team-files) (:id org-2-team-empty)}
|
||||
(set (:teams m2))))
|
||||
(t/is (= #{(:id org-2-team-empty)}
|
||||
(set (:deleted-teams m2)))))))
|
||||
|
||||
(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)))))
|
||||
686
backend/test/backend_tests/rpc_nitrate_test.clj
Normal file
686
backend/test/backend_tests/rpc_nitrate_test.clj
Normal file
@ -0,0 +1,686 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns backend-tests.rpc-nitrate-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as-alias db]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.nitrate]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Helpers
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- make-org-summary
|
||||
[& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams]
|
||||
:or {your-penpot-teams [] org-teams []}}]
|
||||
{:id organization-id
|
||||
:name organization-name
|
||||
:owner-id owner-id
|
||||
:teams (into
|
||||
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
|
||||
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
||||
|
||||
(defn- nitrate-call-mock
|
||||
"Creates a mock for nitrate/call that returns the given org-summary for
|
||||
:get-org-summary, a valid membership for :get-org-membership, and nil for
|
||||
any other method."
|
||||
[org-summary]
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
:get-org-membership {:is-member true
|
||||
:organization-id (:id org-summary)}
|
||||
nil)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Tests
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(t/deftest leave-org-happy-path-no-extra-teams
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
project (th/create-project* 99 {:profile-id (:id profile-user)
|
||||
:team-id (:id org-default-team)})
|
||||
_ (th/create-file* 99 {:profile-id (:id profile-user)
|
||||
:project-id (:id project)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
;; The user's personal penpot team in the org context
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; The personal team must be renamed with the org prefix and
|
||||
;; unset as a default team.
|
||||
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
||||
(t/is (false? (:is-default team))))))))
|
||||
|
||||
(t/deftest leave-org-deletes-org-default-team-when-empty
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
org-default-team (th/create-team* 98 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; Empty org default team should be soft-deleted.
|
||||
(let [team (th/db-get :team {:id your-penpot-id} {::db/remove-deleted false})]
|
||||
(t/is (some? (:deleted-at team))))))))
|
||||
|
||||
(t/deftest leave-org-keeps-and-renames-org-default-team-when-has-files
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
|
||||
project (th/create-project* 97 {:profile-id (:id profile-user)
|
||||
:team-id (:id org-default-team)})
|
||||
_ (th/create-file* 97 {:profile-id (:id profile-user)
|
||||
:project-id (:id project)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; Non-empty org default team should remain and be renamed.
|
||||
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
||||
(t/is (false? (:is-default team)))
|
||||
(t/is (nil? (:deleted-at team))))))))
|
||||
|
||||
(t/deftest leave-org-with-teams-to-delete
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-user is the sole owner/member of team1
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete [(:id team1)]
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; team1 should be scheduled for deletion (deleted-at set)
|
||||
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
|
||||
(t/is (some? (:deleted-at team))))))))
|
||||
|
||||
(t/deftest leave-org-with-ownership-transfer
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-user owns team1; profile-owner is also a member
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; profile-user should no longer be a member of team1
|
||||
(let [rel (th/db-get :team-profile-rel
|
||||
{:team-id (:id team1)
|
||||
:profile-id (:id profile-user)})]
|
||||
(t/is (nil? rel)))
|
||||
|
||||
;; profile-owner should have been promoted to owner
|
||||
(let [rel (th/db-get :team-profile-rel
|
||||
{:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)})]
|
||||
(t/is (true? (:is-owner rel))))))))
|
||||
|
||||
(t/deftest leave-org-exit-as-non-owner
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-owner owns team1; profile-user is a non-owner member
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-user)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave [{:id (:id team1)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; profile-user should no longer be a member of team1
|
||||
(let [rel (th/db-get :team-profile-rel
|
||||
{:team-id (:id team1)
|
||||
:profile-id (:id profile-user)})]
|
||||
(t/is (nil? rel)))
|
||||
|
||||
;; The team itself should still exist
|
||||
(let [team (th/db-get :team {:id (:id team1)})]
|
||||
(t/is (nil? (:deleted-at team))))))))
|
||||
|
||||
(t/deftest leave-org-error-org-owner-cannot-leave
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
;; profile-owner IS the org owner in the org-summary
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-owner)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :org-owner-cannot-leave (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-invalid-default-team-id
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; Pass a random UUID that is not in the your-penpot-teams list
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id (uuid/random)
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Unit Tests for calculate-valid-teams
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private calculate-valid-teams
|
||||
(or (ns-resolve 'app.rpc.commands.nitrate 'calculate-valid-teams)
|
||||
(throw (ex-info "Unable to resolve calculate-valid-teams"
|
||||
{:ns 'app.rpc.commands.nitrate
|
||||
:symbol 'calculate-valid-teams}))))
|
||||
|
||||
(defn- make-team [id & {:keys [is-owner num-members member-ids]
|
||||
:or {is-owner false num-members 1 member-ids []}}]
|
||||
{:id id :is-owner is-owner :num-members num-members :member-ids member-ids})
|
||||
|
||||
(t/deftest calculate-valid-teams-no-org-teams
|
||||
(let [default-id (uuid/random)
|
||||
default-team (make-team default-id)
|
||||
result (calculate-valid-teams [default-team] default-id)]
|
||||
(t/is (= default-team (:valid-default-team result)))
|
||||
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||
|
||||
(t/deftest calculate-valid-teams-default-not-found
|
||||
(let [default-id (uuid/random)
|
||||
other-id (uuid/random)
|
||||
other-team (make-team other-id)
|
||||
;; default-id is not in org-teams at all
|
||||
result (calculate-valid-teams [other-team] default-id)]
|
||||
(t/is (nil? (:valid-default-team result)))))
|
||||
|
||||
(t/deftest calculate-valid-teams-sole-owner-team
|
||||
(let [default-id (uuid/random)
|
||||
team-id (uuid/random)
|
||||
default (make-team default-id)
|
||||
solo-team (make-team team-id :is-owner true :num-members 1)
|
||||
result (calculate-valid-teams [default solo-team] default-id)]
|
||||
(t/is (contains? (:valid-teams-to-delete-ids result) team-id))
|
||||
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||
|
||||
(t/deftest calculate-valid-teams-owned-multi-member-team
|
||||
(let [default-id (uuid/random)
|
||||
team-id (uuid/random)
|
||||
default (make-team default-id)
|
||||
;; owner of a team with 3 members — must be transferred
|
||||
multi-team (make-team team-id :is-owner true :num-members 3)
|
||||
result (calculate-valid-teams [default multi-team] default-id)]
|
||||
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||
(t/is (= [team-id] (map :id (:valid-teams-to-transfer result))))
|
||||
(t/is (empty? (:valid-teams-to-exit result)))))
|
||||
|
||||
(t/deftest calculate-valid-teams-non-owner-multi-member-team
|
||||
(let [default-id (uuid/random)
|
||||
team-id (uuid/random)
|
||||
default (make-team default-id)
|
||||
;; non-owner member of a team with 2 members — can just exit
|
||||
exit-team (make-team team-id :is-owner false :num-members 2)
|
||||
result (calculate-valid-teams [default exit-team] default-id)]
|
||||
(t/is (empty? (:valid-teams-to-delete-ids result)))
|
||||
(t/is (empty? (:valid-teams-to-transfer result)))
|
||||
(t/is (= [team-id] (map :id (:valid-teams-to-exit result))))))
|
||||
|
||||
(t/deftest calculate-valid-teams-mixed
|
||||
(let [default-id (uuid/random)
|
||||
solo-id (uuid/random)
|
||||
transfer-id (uuid/random)
|
||||
exit-id (uuid/random)
|
||||
default (make-team default-id)
|
||||
solo-team (make-team solo-id :is-owner true :num-members 1)
|
||||
transfer-team (make-team transfer-id :is-owner true :num-members 2)
|
||||
exit-team (make-team exit-id :is-owner false :num-members 3)
|
||||
result (calculate-valid-teams [default solo-team transfer-team exit-team] default-id)]
|
||||
(t/is (= #{solo-id} (:valid-teams-to-delete-ids result)))
|
||||
(t/is (= [transfer-id] (map :id (:valid-teams-to-transfer result))))
|
||||
(t/is (= [exit-id] (map :id (:valid-teams-to-exit result))))
|
||||
(t/is (= default-id (:id (:valid-default-team result))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Integration: combined delete + leave
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(t/deftest leave-org-combined-delete-and-leave
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; team1: profile-user is sole owner — must delete
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
;; team2: profile-user owns it, profile-owner is also member — must transfer
|
||||
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team2)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
;; team3: profile-owner owns it, profile-user is non-owner member — can exit
|
||||
team3 (th/create-team* 3 {:profile-id (:id profile-owner)})
|
||||
_ (th/create-team-role* {:team-id (:id team3)
|
||||
:profile-id (:id profile-user)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1) (:id team2) (:id team3)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete [(:id team1)]
|
||||
:teams-to-leave [{:id (:id team2) :reassign-to (:id profile-owner)}
|
||||
{:id (:id team3)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
|
||||
;; team1 should be soft-deleted
|
||||
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
|
||||
(t/is (some? (:deleted-at team))))
|
||||
|
||||
;; profile-user should no longer be a member of team2
|
||||
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-user)})]
|
||||
(t/is (nil? rel)))
|
||||
|
||||
;; profile-owner should now own team2
|
||||
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-owner)})]
|
||||
(t/is (true? (:is-owner rel))))
|
||||
|
||||
;; profile-user should no longer be a member of team3
|
||||
(let [rel (th/db-get :team-profile-rel {:team-id (:id team3) :profile-id (:id profile-user)})]
|
||||
(t/is (nil? rel)))
|
||||
|
||||
;; team3 itself should still exist (profile-owner is still there)
|
||||
(let [team (th/db-get :team {:id (:id team3)})]
|
||||
(t/is (some? team)))))))
|
||||
(t/deftest leave-org-error-teams-to-delete-incomplete
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-user is the sole owner/member of both team1 and team2
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1) (:id team2)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; Only team1 is listed; team2 is also a sole-owner team and must be included
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete [(:id team1)]
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-cannot-delete-multi-member-team
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; team1 has two members: profile-user (owner) and profile-owner (editor)
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; team1 has 2 members so it is not a valid deletion candidate
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete [(:id team1)]
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-teams-to-leave-incomplete
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-user owns team1, which also has profile-owner as editor
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; team1 must be transferred (owner + multiple members) but is absent
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave []}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-reassign-to-self
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; reassign-to points to the profile that is leaving — not allowed
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-reassign-to-non-member
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
profile-other (th/create-profile* 3 {:is-active true})
|
||||
;; team1 has profile-user (owner) and profile-owner (editor) — NOT profile-other
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-owner)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; profile-other is not a member of team1
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(t/deftest leave-org-error-reassign-on-non-owned-team
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
;; profile-owner owns team1; profile-user is just a non-owner member
|
||||
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
|
||||
_ (th/create-team-role* {:team-id (:id team1)
|
||||
:profile-id (:id profile-user)
|
||||
:role :editor})
|
||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [(:id team1)])]
|
||||
|
||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
||||
;; profile-user is not the owner so providing reassign-to is invalid
|
||||
(let [data {::th/type :leave-org
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
:name "Test Org"
|
||||
:default-team-id your-penpot-id
|
||||
:teams-to-delete []
|
||||
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
@ -125,7 +125,20 @@
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))))))
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "delete photo clears photo-id"
|
||||
(let [data {::th/type :delete-profile-photo
|
||||
::rpc/profile-id (:id profile)}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
(let [data {::th/type :get-profile
|
||||
::rpc/profile-id (:id profile)}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:photo-id (:result out))))))))
|
||||
|
||||
(t/deftest profile-deletion-1
|
||||
(let [prof (th/create-profile* 1)
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.http :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.storage :as sto]
|
||||
@ -102,6 +103,46 @@
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :member-is-muted (:code edata))))))))
|
||||
|
||||
(t/deftest create-team-invitations-blacklisted-domain
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id profile1)
|
||||
:team-id (:id team)
|
||||
:role :editor}]
|
||||
|
||||
;; invite from a directly blacklisted domain should fail
|
||||
(with-redefs [email.blacklist/enabled? (constantly true)
|
||||
email.blacklist/contains? (fn [_ email]
|
||||
(clojure.string/ends-with? email "@blacklisted.com"))]
|
||||
(let [out (th/command! (assoc data :emails ["user@blacklisted.com"]))]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= 0 (:call-count @mock)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :restriction (:type edata)))
|
||||
(t/is (= :email-domain-is-not-allowed (:code edata))))))
|
||||
|
||||
;; invite from a subdomain of a blacklisted domain should also fail
|
||||
(th/reset-mock! mock)
|
||||
(with-redefs [email.blacklist/enabled? (constantly true)
|
||||
email.blacklist/contains? (fn [_ email]
|
||||
(clojure.string/ends-with? email "@sub.blacklisted.com"))]
|
||||
(let [out (th/command! (assoc data :emails ["user@sub.blacklisted.com"]))]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= 0 (:call-count @mock)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :restriction (:type edata)))
|
||||
(t/is (= :email-domain-is-not-allowed (:code edata))))))
|
||||
|
||||
;; invite from a non-blacklisted domain should succeed
|
||||
(th/reset-mock! mock)
|
||||
(with-redefs [email.blacklist/enabled? (constantly true)
|
||||
email.blacklist/contains? (constantly false)]
|
||||
(let [out (th/command! (assoc data :emails ["user@allowed.com"]))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (:call-count @mock))))))))
|
||||
|
||||
(t/deftest create-team-invitations-with-request-access
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
@ -726,3 +767,82 @@
|
||||
(t/is (th/success? (th/command! data)))
|
||||
(t/is (= 1 (:call-count @mock))))))
|
||||
|
||||
(t/deftest create-team-with-invalid-name
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
|
||||
;; name with a dot should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo.bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a colon should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo:bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a slash should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo/bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; valid name should succeed
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
(t/deftest update-team-with-invalid-name
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})]
|
||||
|
||||
;; name with a dot should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo.bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a colon should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo:bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a slash should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo/bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; valid name should succeed
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.viewer :as viewer]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
@ -16,6 +17,28 @@
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest obfuscate-email-happy-path
|
||||
(t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com")))
|
||||
(t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com")))
|
||||
(t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com"))))
|
||||
|
||||
(t/deftest obfuscate-email-handles-domain-without-dot
|
||||
;; `localhost`-style domains have no `.`; the previous implementation produced
|
||||
;; a dangling-dot output like "a****@****." — now the trailing `.` is only
|
||||
;; emitted when there actually is a TLD segment to append.
|
||||
(t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost")))
|
||||
(t/is (= "****@****" (viewer/obfuscate-email "x@y"))))
|
||||
|
||||
(t/deftest obfuscate-email-handles-malformed-input
|
||||
;; These shapes must not throw — `obfuscate-email` runs while building the
|
||||
;; view-only bundle for share-link viewers and an NPE here aborts the whole
|
||||
;; RPC response. The previous implementation called `clojure.string/split`
|
||||
;; on `nil` for the `no-@` case, raising NullPointerException.
|
||||
(t/is (= "****@****" (viewer/obfuscate-email nil)))
|
||||
(t/is (= "****@****" (viewer/obfuscate-email "")))
|
||||
(t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3
|
||||
(t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3
|
||||
|
||||
(t/deftest retrieve-bundle
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
prof2 (th/create-profile* 2 {:is-active true})
|
||||
|
||||
BIN
backend/test/backend_tests/test_files/font-1.woff2
Normal file
BIN
backend/test/backend_tests/test_files/font-1.woff2
Normal file
Binary file not shown.
@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@ -1120,6 +1120,71 @@
|
||||
(when (num? value)
|
||||
(format-precision value precision)))))
|
||||
|
||||
(defn- natural-sort-key
|
||||
"Splits a string into a sequence of alternating string and number segments,
|
||||
converting numeric segments to longs/ints so they compare by value rather
|
||||
than lexicographically. e.g. \"size10b\" => (\"size\" 10 \"b\")"
|
||||
[s]
|
||||
(map (fn [part]
|
||||
(if (re-matches #"\d+" part)
|
||||
#?(:clj (Long/parseLong part)
|
||||
:cljs (js/parseInt part))
|
||||
part))
|
||||
(re-seq #"\d+|\D+" s)))
|
||||
|
||||
(defn- natural-compare
|
||||
"Comparator that orders strings naturally, sorting numeric segments by value
|
||||
rather than lexicographically. Returns a negative number, zero, or positive
|
||||
number when a is before, equal to, or after b respectively.
|
||||
e.g. \"size2\" < \"size10\" instead of \"size10\" < \"size2\"."
|
||||
[a b]
|
||||
(loop [ka (natural-sort-key a)
|
||||
kb (natural-sort-key b)]
|
||||
(cond
|
||||
(and (empty? ka) (empty? kb)) 0
|
||||
(empty? ka) -1
|
||||
(empty? kb) 1
|
||||
:else
|
||||
(let [pa (first ka)
|
||||
pb (first kb)
|
||||
result (cond
|
||||
(and (number? pa) (number? pb)) (compare pa pb)
|
||||
(and (string? pa) (string? pb)) (compare pa pb)
|
||||
(number? pa) -1
|
||||
:else 1)]
|
||||
(if (zero? result)
|
||||
(recur (rest ka) (rest kb))
|
||||
result)))))
|
||||
|
||||
(defn natural-sort-by
|
||||
"Sorts coll by extracting a string key with keyfn and ordering elements
|
||||
using natural sort order, where embedded numbers are compared by value
|
||||
rather than lexicographically.
|
||||
e.g. (natural-sort-by :name [{:name \"size10\"} {:name \"size2\"}])
|
||||
=> [{:name \"size2\"} {:name \"size10\"}]"
|
||||
[key coll]
|
||||
(sort-by key natural-compare coll))
|
||||
|
||||
(defn sanitize-string [s]
|
||||
(if s
|
||||
(-> s
|
||||
str
|
||||
str/trim
|
||||
(str/replace #"[^\w\s\-_()]+" "")
|
||||
(str/replace #"\s+" " ")
|
||||
str/trim)
|
||||
""))
|
||||
|
||||
(defn get-initials
|
||||
"Returns up to two uppercase initials extracted from a string.
|
||||
Non-letter prefixes in each token are ignored."
|
||||
[name]
|
||||
(->> (str/split (str/trim (or name "")) #"\s+")
|
||||
(keep #(first (re-seq #"[a-zA-Z]" %)))
|
||||
(take 2)
|
||||
(map str/upper)
|
||||
(apply str)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Util protocols
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -1160,3 +1225,39 @@
|
||||
(if (seq class)
|
||||
(str class " " current-class)
|
||||
current-class))
|
||||
|
||||
(defn nth-index-of*
|
||||
"Finds the nth occurrence of `char` in `string`, searching either forward or backward.
|
||||
`dir` must be :forward (left to right) or :backward (right to left).
|
||||
Returns the absolute index of the match, or nil if fewer than n occurrences exist."
|
||||
[string char n dir]
|
||||
(loop [s string
|
||||
offset 0
|
||||
cnt 1]
|
||||
(let [index (case dir
|
||||
:forward (str/index-of s char)
|
||||
:backward (str/last-index-of s char))]
|
||||
(cond
|
||||
(nil? index) nil
|
||||
(= cnt n) (case dir
|
||||
:forward (+ index offset)
|
||||
:backward index)
|
||||
:else (case dir
|
||||
:forward (recur (str/slice s (inc index))
|
||||
(+ offset index 1)
|
||||
(inc cnt))
|
||||
:backward (recur (str/slice s 0 index)
|
||||
offset
|
||||
(inc cnt)))))))
|
||||
|
||||
(defn nth-index-of
|
||||
"Returns the index of the nth occurrence of `char` in `string`, searching left to right.
|
||||
Returns nil if fewer than n occurrences exist."
|
||||
[string char n]
|
||||
(nth-index-of* string char n :forward))
|
||||
|
||||
(defn nth-last-index-of
|
||||
"Returns the index of the nth occurrence of `char` in `string`, searching right to left.
|
||||
Returns nil if fewer than n occurrences exist."
|
||||
[string char n]
|
||||
(nth-index-of* string char n :backward))
|
||||
|
||||
@ -40,6 +40,13 @@
|
||||
(list `c/get key)))
|
||||
keys)))))
|
||||
|
||||
(defmacro number
|
||||
"Coerce number to number in a multiplatform way"
|
||||
[o]
|
||||
(if (:ns &env)
|
||||
(with-meta o {:tag 'number})
|
||||
`(double ~o)))
|
||||
|
||||
(defmacro str
|
||||
[& params]
|
||||
`(str/concat ~@params))
|
||||
|
||||
@ -261,7 +261,11 @@
|
||||
;; All props are optional, background can be nil because is the
|
||||
;; way to remove already set background
|
||||
[:background {:optional true} [:maybe ctc/schema:hex-color]]
|
||||
[:name {:optional true} :string]]]
|
||||
[:name {:optional true} :string]
|
||||
;; Pixel grid display controls — nil removes the per-page override
|
||||
;; and falls back to the default hardcoded grid color/opacity.
|
||||
[:pixel-grid-color {:optional true} [:maybe ctc/schema:hex-color]]
|
||||
[:pixel-grid-opacity {:optional true} [:maybe ::sm/safe-number]]]]
|
||||
|
||||
[:set-plugin-data schema:set-plugin-data-change]
|
||||
|
||||
@ -853,8 +857,10 @@
|
||||
[data {:keys [id] :as params}]
|
||||
(d/update-in-when data [:pages-index id]
|
||||
(fn [page]
|
||||
(let [name (get params :name)
|
||||
bg (get params :background :not-found)]
|
||||
(let [name (get params :name)
|
||||
bg (get params :background :not-found)
|
||||
grid-color (get params :pixel-grid-color :not-found)
|
||||
grid-op (get params :pixel-grid-opacity :not-found)]
|
||||
(cond-> page
|
||||
(string? name)
|
||||
(assoc :name name)
|
||||
@ -863,7 +869,19 @@
|
||||
(assoc :background bg)
|
||||
|
||||
(nil? bg)
|
||||
(dissoc :background))))))
|
||||
(dissoc :background)
|
||||
|
||||
(string? grid-color)
|
||||
(assoc :pixel-grid-color grid-color)
|
||||
|
||||
(and (not= grid-color :not-found) (nil? grid-color))
|
||||
(dissoc :pixel-grid-color)
|
||||
|
||||
(number? grid-op)
|
||||
(assoc :pixel-grid-opacity grid-op)
|
||||
|
||||
(and (not= grid-op :not-found) (nil? grid-op))
|
||||
(dissoc :pixel-grid-opacity))))))
|
||||
|
||||
(defmethod process-change :set-plugin-data
|
||||
[data {:keys [object-type object-id page-id namespace key value]}]
|
||||
@ -1191,9 +1209,9 @@
|
||||
; Check if the shape has changed any
|
||||
; attribute that participates in components synchronization.
|
||||
(and (= (:type operation) :set)
|
||||
(get ctk/sync-attrs (:attr operation))))
|
||||
any-sync? (some need-sync? operations)]
|
||||
(when any-sync?
|
||||
(contains? ctk/sync-attrs (:attr operation))))]
|
||||
|
||||
(when (some need-sync? operations)
|
||||
(parents-frames id (:objects page))))))
|
||||
|
||||
(defmethod frames-changed :mov-objects
|
||||
|
||||
@ -213,21 +213,33 @@
|
||||
(let [page (::page (meta changes))]
|
||||
(mod-page changes page options)))
|
||||
|
||||
([changes page {:keys [name background]}]
|
||||
([changes page {:keys [name background pixel-grid-color pixel-grid-opacity]}]
|
||||
(let [change {:type :mod-page :id (:id page)}
|
||||
redo (cond-> change
|
||||
(some? name)
|
||||
(assoc :name name)
|
||||
|
||||
(some? background)
|
||||
(assoc :background background))
|
||||
(assoc :background background)
|
||||
|
||||
(some? pixel-grid-color)
|
||||
(assoc :pixel-grid-color pixel-grid-color)
|
||||
|
||||
(some? pixel-grid-opacity)
|
||||
(assoc :pixel-grid-opacity pixel-grid-opacity))
|
||||
|
||||
undo (cond-> change
|
||||
(some? name)
|
||||
(assoc :name (:name page))
|
||||
|
||||
(some? background)
|
||||
(assoc :background (:background page)))]
|
||||
(assoc :background (:background page))
|
||||
|
||||
(some? pixel-grid-color)
|
||||
(assoc :pixel-grid-color (:pixel-grid-color page))
|
||||
|
||||
(some? pixel-grid-opacity)
|
||||
(assoc :pixel-grid-opacity (:pixel-grid-opacity page)))]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes conj redo)
|
||||
@ -605,31 +617,31 @@
|
||||
add-undo-change-shape
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)]
|
||||
(conj
|
||||
change-set
|
||||
{:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))})))
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))}))))
|
||||
|
||||
add-undo-change-parent
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)
|
||||
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
|
||||
(conj
|
||||
change-set
|
||||
{:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true})))]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true}))))]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes #(reduce add-redo-change % ids))
|
||||
@ -1150,3 +1162,24 @@
|
||||
[changes]
|
||||
(::page-id (meta changes)))
|
||||
|
||||
|
||||
(defn set-text-content
|
||||
[changes id content prev-content]
|
||||
(assert-page-id! changes)
|
||||
(let [page-id (::page-id (meta changes))
|
||||
|
||||
redo-change
|
||||
{:type :mod-obj
|
||||
:page-id page-id
|
||||
:id id
|
||||
:operations [{:type :set :attr :content :val content}]}
|
||||
|
||||
undo-change
|
||||
{:type :mod-obj
|
||||
:page-id page-id
|
||||
:id id
|
||||
:operations [{:type :set :attr :content :val prev-content}]}]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes conj redo-change)
|
||||
(update :undo-changes conj undo-change))))
|
||||
|
||||
115
common/src/app/common/files/comp_processors.cljc
Normal file
115
common/src/app/common/files/comp_processors.cljc
Normal file
@ -0,0 +1,115 @@
|
||||
;; 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.common.files.comp-processors
|
||||
"Repair, migration or transformation utilities for components."
|
||||
(:require
|
||||
[app.common.logging :as log]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.file :as ctf]))
|
||||
|
||||
(log/set-level! :warn)
|
||||
|
||||
(defn remove-unneeded-objects-in-components
|
||||
"Some components have an :objects attribute, despite not being deleted. This removes it.
|
||||
It also adds an empty :objects if it's deleted and does not have it."
|
||||
[file-data]
|
||||
(ctf/update-components
|
||||
file-data
|
||||
(fn [component]
|
||||
(if (:deleted component)
|
||||
(if (nil? (:objects component))
|
||||
(do
|
||||
(log/warn :msg "Adding empty :objects to deleted component"
|
||||
:component-id (:id component)
|
||||
:component-name (:name component)
|
||||
:file-id (:id file-data))
|
||||
(assoc component :objects {}))
|
||||
component)
|
||||
(if (contains? component :objects)
|
||||
(do
|
||||
(log/warn :msg "Removing :objects from non-deleted component"
|
||||
:component-id (:id component)
|
||||
:component-name (:name component)
|
||||
:file-id (:id file-data))
|
||||
(dissoc component :objects))
|
||||
component)))))
|
||||
|
||||
(defn fix-missing-swap-slots
|
||||
"Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but
|
||||
they don't have a swap slot. In this case, add one pointing to the near match."
|
||||
[file-data libraries]
|
||||
(ctf/update-all-shapes
|
||||
file-data
|
||||
(fn [shape]
|
||||
(if (ctk/subcopy-head? shape)
|
||||
(let [container (:container (meta shape))
|
||||
file {:id (:id file-data) :data file-data}
|
||||
near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)]
|
||||
(if (and (some? near-match)
|
||||
(not= (:shape-ref shape) (:id near-match))
|
||||
(nil? (ctk/get-swap-slot shape)))
|
||||
(let [updated-shape (ctk/set-swap-slot shape (:id near-match))]
|
||||
(log/warn :msg "Adding missing swap slot to shape"
|
||||
:shape-id (:id shape)
|
||||
:shape-name (:name shape)
|
||||
:swap-slot (:id near-match)
|
||||
:file-id (:id file)
|
||||
:container-id (:id container)
|
||||
:container-type (:type container))
|
||||
{:result :update :updated-shape updated-shape})
|
||||
{:result :keep}))
|
||||
{:result :keep}))))
|
||||
|
||||
(defn sync-component-id-with-ref-shape
|
||||
"Ensure that all copies heads have the same component id and file as the referenced shape.
|
||||
There may be bugs that cause them to get out of sync."
|
||||
[file-data libraries]
|
||||
(letfn [(sync-one-iteration
|
||||
[file-data libraries]
|
||||
(ctf/update-all-shapes
|
||||
file-data
|
||||
(fn [shape]
|
||||
(if (and (ctk/subcopy-head? shape) (nil? (ctk/get-swap-slot shape)))
|
||||
(let [container (:container (meta shape))
|
||||
file {:id (:id file-data) :data file-data}
|
||||
ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true :with-context? true})]
|
||||
(if (and (some? ref-shape)
|
||||
(or (not= (:component-id shape) (:component-id ref-shape))
|
||||
(not= (:component-file shape) (:component-file ref-shape))))
|
||||
(let [shape' (cond-> shape
|
||||
(some? (:component-id ref-shape))
|
||||
(assoc :component-id (:component-id ref-shape))
|
||||
|
||||
(nil? (:component-id ref-shape))
|
||||
(dissoc :component-id)
|
||||
|
||||
(some? (:component-file ref-shape))
|
||||
(assoc :component-file (:component-file ref-shape))
|
||||
|
||||
(nil? (:component-file ref-shape))
|
||||
(dissoc :component-file))]
|
||||
(log/warn :msg "Syncing component id and file with ref shape"
|
||||
:shape-id (:id shape)
|
||||
:shape-name (:name shape)
|
||||
:component-id (:component-id shape')
|
||||
:component-file (:component-file shape')
|
||||
:ref-shape-id (:id ref-shape)
|
||||
:file-id (:id file)
|
||||
:container-id (:id container)
|
||||
:container-type (:type container))
|
||||
{:result :update :updated-shape shape'})
|
||||
{:result :keep}))
|
||||
{:result :keep}))))]
|
||||
;; If a copy inside a main is updated, we need to repeat the process for the change to be
|
||||
;; propagated to all copies.
|
||||
(loop [current-data file-data
|
||||
iteration 0]
|
||||
(let [next-data (sync-one-iteration current-data libraries)]
|
||||
(if (or (= current-data next-data)
|
||||
(> iteration 20)) ;; safety bound
|
||||
next-data
|
||||
(recur next-data (inc iteration)))))))
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.changes :as cpc]
|
||||
[app.common.files.comp-processors :as cfcp]
|
||||
[app.common.files.defaults :as cfd]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
@ -1786,6 +1787,24 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0018-remove-unneeded-objects-from-components"
|
||||
[data _]
|
||||
(cfcp/remove-unneeded-objects-in-components data))
|
||||
|
||||
(defmethod migrate-data "0019-fix-missing-swap-slots"
|
||||
[data _]
|
||||
(let [libraries (if (:libs data)
|
||||
(deref (:libs data))
|
||||
{})]
|
||||
(cfcp/fix-missing-swap-slots data libraries)))
|
||||
|
||||
(defmethod migrate-data "0020-sync-component-id-with-near-main"
|
||||
[data _]
|
||||
(let [libraries (if (:libs data)
|
||||
(deref (:libs data))
|
||||
{})]
|
||||
(cfcp/sync-component-id-with-ref-shape data libraries)))
|
||||
|
||||
(def available-migrations
|
||||
(into (d/ordered-set)
|
||||
["legacy-2"
|
||||
@ -1860,4 +1879,7 @@
|
||||
"0015-fix-text-attrs-blank-strings"
|
||||
"0015-clean-shadow-color"
|
||||
"0016-copy-fills-from-position-data-to-text-node"
|
||||
"0017-fix-layout-flex-dir"]))
|
||||
"0017-fix-layout-flex-dir"
|
||||
"0018-remove-unneeded-objects-from-components"
|
||||
"0019-fix-missing-swap-slots"
|
||||
"0020-sync-component-id-with-near-main"]))
|
||||
|
||||
@ -334,6 +334,31 @@
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :component-id-mismatch
|
||||
[_ {:keys [shape page-id args] :as error} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
; Set the component-id and component-file to the ones of the near main
|
||||
(log/debug :hint (str " -> set component-id to " (:component-id args)))
|
||||
(log/debug :hint (str " -> set component-file to " (:component-file args)))
|
||||
(cond-> shape
|
||||
(some? (:component-id args))
|
||||
(assoc :component-id (:component-id args))
|
||||
|
||||
(nil? (:component-id args))
|
||||
(dissoc :component-id)
|
||||
|
||||
(some? (:component-file args))
|
||||
(assoc :component-file (:component-file args))
|
||||
|
||||
(nil? (:component-file args))
|
||||
(dissoc :component-file)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :ref-shape-is-head
|
||||
[_ {:keys [shape page-id args] :as error} file-data _]
|
||||
(let [repair-shape
|
||||
@ -501,7 +526,7 @@
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :component-nil-objects-not-allowed
|
||||
[_ {:keys [shape] :as error} file-data _]
|
||||
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
|
||||
(let [repair-component
|
||||
(fn [component]
|
||||
;; Remove the objects key, or set it to {} if the component is deleted
|
||||
@ -513,10 +538,26 @@
|
||||
(log/debug :hint " -> remove :objects")
|
||||
(dissoc component :objects))))]
|
||||
|
||||
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id shape) :name (:name shape))
|
||||
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
|
||||
(defmethod repair-error :non-deleted-component-cannot-have-objects
|
||||
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
|
||||
(let [repair-component
|
||||
(fn [component]
|
||||
; Remove the :objects field
|
||||
(if-not (:deleted component)
|
||||
(do
|
||||
(log/debug :hint " -> remove :objects")
|
||||
(dissoc component :objects))
|
||||
component))]
|
||||
|
||||
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
|
||||
(defmethod repair-error :invalid-text-touched
|
||||
[_ {:keys [shape page-id] :as error} file-data _]
|
||||
|
||||
@ -340,12 +340,26 @@
|
||||
:svg-viewbox vbox
|
||||
:svg-defs defs})))
|
||||
|
||||
(defn- stroke-only-svg-path?
|
||||
"Returns true when the SVG element renders only a stroke (fill=none).
|
||||
Stroke-only paths can have their consecutive touching subpaths safely
|
||||
merged into a continuous polyline so that `stroke-linejoin` applies at
|
||||
shared endpoints, without affecting any fill-rule semantics."
|
||||
[attrs]
|
||||
(let [attr-fill (some-> (:fill attrs) str/trim)
|
||||
style-fill (some-> (get-in attrs [:style :fill]) str/trim)]
|
||||
(= "none" (or attr-fill style-fill))))
|
||||
|
||||
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
|
||||
(when (and (contains? attrs :d) (seq (:d attrs)))
|
||||
(let [transform (csvg/parse-transform (:transform attrs))
|
||||
content (cond-> (path/from-string (:d attrs))
|
||||
(some? transform)
|
||||
(path.segm/transform-content transform))
|
||||
(let [transform (csvg/parse-transform (:transform attrs))
|
||||
stroke-only? (stroke-only-svg-path? attrs)
|
||||
content (cond-> (path/from-string (:d attrs))
|
||||
stroke-only?
|
||||
(path/merge-touching-subpaths)
|
||||
|
||||
(some? transform)
|
||||
(path.segm/transform-content transform))
|
||||
|
||||
selrect (path.segm/content->selrect content)
|
||||
points (grc/rect->points selrect)
|
||||
@ -663,6 +677,22 @@
|
||||
(remove is-style-fragment?) ;; Filter style fragments and hex colors
|
||||
(filter #(contains? defs %))))) ;; Only existing defs
|
||||
|
||||
(defn resolve-element-name
|
||||
"Pick the most user-meaningful name for an SVG element.
|
||||
|
||||
Inkscape (and editors following the same convention) write the
|
||||
operator-given label to ``inkscape:label``/``sodipodi:label`` while
|
||||
``id`` holds an auto-generated technical id like ``path1234``.
|
||||
Preferring the namespaced label keeps the layer/group/element names
|
||||
the operator sees in their source editor across a paste/import
|
||||
(#7869); the existing ``id`` and ``(tag->name tag)`` fallbacks keep
|
||||
legacy SVGs that don't carry a label working unchanged."
|
||||
[tag attrs]
|
||||
(or (:inkscape:label attrs)
|
||||
(:sodipodi:label attrs)
|
||||
(:id attrs)
|
||||
(tag->name tag)))
|
||||
|
||||
(defn parse-svg-element
|
||||
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
|
||||
|
||||
@ -670,7 +700,7 @@
|
||||
;; think we should handle this case early and avoid some code
|
||||
;; execution
|
||||
|
||||
(let [name (or (:id attrs) (tag->name tag))
|
||||
(let [name (resolve-element-name tag attrs)
|
||||
att-refs (csvg/find-attr-references attrs)
|
||||
defs (get svg-data :defs)
|
||||
valid-refs (filter-valid-def-references att-refs defs)
|
||||
|
||||
74
common/src/app/common/files/stats.cljc
Normal file
74
common/src/app/common/files/stats.cljc
Normal file
@ -0,0 +1,74 @@
|
||||
;; 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.common.files.stats
|
||||
"Pure helpers that compute aggregate statistics for a file data map.
|
||||
|
||||
Given a decoded file data structure (the value stored under `:data`
|
||||
on a file row), produces a small map with page/shape/library counts.
|
||||
Intended to be cheap — a single pass over each page's `:objects`
|
||||
map, no database access, no side effects."
|
||||
(:require
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(def empty-shape-counts
|
||||
{:total 0 :by-type {}})
|
||||
|
||||
(defn- inc-type
|
||||
[by-type shape-type]
|
||||
(if (nil? shape-type)
|
||||
by-type
|
||||
(update by-type shape-type (fnil inc 0))))
|
||||
|
||||
(defn count-shapes-by-type
|
||||
"Walk an `:objects` map of a single page and return
|
||||
`{:total N :by-type {:rect N :frame N ...}}`. The synthetic root
|
||||
shape at `uuid/zero` is skipped so it never contributes to totals."
|
||||
[objects]
|
||||
(if (empty? objects)
|
||||
empty-shape-counts
|
||||
(reduce-kv
|
||||
(fn [acc id shape]
|
||||
(if (= id uuid/zero)
|
||||
acc
|
||||
(-> acc
|
||||
(update :total inc)
|
||||
(update :by-type inc-type (:type shape)))))
|
||||
empty-shape-counts
|
||||
objects)))
|
||||
|
||||
(defn- merge-shape-counts
|
||||
[a b]
|
||||
{:total (+ (:total a) (:total b))
|
||||
:by-type (merge-with + (:by-type a) (:by-type b))})
|
||||
|
||||
(defn- aggregate-shape-counts
|
||||
[pages-index]
|
||||
(transduce
|
||||
(map (comp count-shapes-by-type :objects))
|
||||
(completing merge-shape-counts)
|
||||
empty-shape-counts
|
||||
(vals pages-index)))
|
||||
|
||||
(defn calc-file-stats
|
||||
"Given a decoded file data map with the standard keys
|
||||
`:pages-index`, `:components`, `:deleted-components`, `:colors`
|
||||
and `:typographies`, return per-file aggregates.
|
||||
|
||||
The result is a plain map suitable for serialization; it never
|
||||
contains any pointer-map or objects-map instances."
|
||||
[fdata]
|
||||
(let [pages-index (get fdata :pages-index)
|
||||
components (get fdata :components)
|
||||
deleted-components (get fdata :deleted-components)
|
||||
colors (get fdata :colors)
|
||||
typographies (get fdata :typographies)]
|
||||
{:page-count (count pages-index)
|
||||
:shape-counts (aggregate-shape-counts pages-index)
|
||||
:component-count (count components)
|
||||
:deleted-component-count (count deleted-components)
|
||||
:color-count (count colors)
|
||||
:typography-count (count typographies)}))
|
||||
@ -26,7 +26,7 @@
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
(tr "errors.tokens.empty-input")))
|
||||
|
||||
(def schema:token-value-generic
|
||||
[::sm/text {:error/fn token-value-empty-fn}])
|
||||
@ -34,7 +34,7 @@
|
||||
(def schema:token-value-numeric
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.invalid-value" (:value %))}
|
||||
[:fn {:error/fn #(tr "errors.tokens.invalid-value" (:value %))}
|
||||
(fn [value]
|
||||
(if (str/numeric? value)
|
||||
(let [n (d/parse-double value)]
|
||||
@ -44,7 +44,7 @@
|
||||
(def schema:token-value-percent
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.value-with-percent" (:value %))}
|
||||
[:fn {:error/fn #(tr "errors.tokens.value-with-percent" (:value %))}
|
||||
(fn [value]
|
||||
(if (d/percent? value)
|
||||
(let [v (d/parse-percent value)]
|
||||
@ -57,7 +57,7 @@
|
||||
(def schema:token-value-opacity
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.opacity-range")}
|
||||
[:fn {:error/fn #(tr "errors.tokens.opacity-range")}
|
||||
(fn [opacity]
|
||||
(if (str/numeric? opacity)
|
||||
(let [n (d/parse-percent opacity)]
|
||||
@ -71,7 +71,7 @@
|
||||
|
||||
(def schema:token-value-font-weight
|
||||
[:or
|
||||
[:fn {:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value")}
|
||||
[:fn {:error/fn #(tr "errors.tokens.invalid-font-weight-token-value")}
|
||||
cto/valid-font-weight-variant]
|
||||
::sm/text]) ;; Leave references or formulas to be checked by the resolver
|
||||
|
||||
@ -147,6 +147,27 @@
|
||||
#(and (some? tokens-tree)
|
||||
(not (ctob/token-name-path-exists? % tokens-tree)))]])
|
||||
|
||||
(defn make-node-token-name-schema
|
||||
"Dynamically generates a schema to check a token node name, adding translated error messages
|
||||
and two additional validations:
|
||||
- Min and max length.
|
||||
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
|
||||
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
|
||||
[active-tokens tokens-tree node]
|
||||
[:and
|
||||
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
|
||||
(-> cto/schema:token-node-name
|
||||
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
(fn [name]
|
||||
(let [current-path (:path node)
|
||||
current-name (:name node)
|
||||
new-tokens (ctob/update-tokens-group active-tokens current-path current-name name)]
|
||||
(and (some? new-tokens)
|
||||
(some (fn [[token-name _]]
|
||||
(not (ctob/token-name-path-exists? token-name tokens-tree)))
|
||||
new-tokens))))]])
|
||||
|
||||
(def schema:token-description
|
||||
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
|
||||
|
||||
@ -160,11 +181,16 @@
|
||||
[:value (make-token-value-schema token-type)]
|
||||
[:description {:optional true} schema:token-description]])
|
||||
[:fn {:error/field :value
|
||||
:error/fn #(tr "workspace.tokens.self-reference")}
|
||||
:error/fn #(tr "errors.tokens.self-reference")}
|
||||
(fn [{:keys [name value]}]
|
||||
(when (and name value)
|
||||
(not (cto/token-value-self-reference? name value))))]])
|
||||
|
||||
(defn make-node-token-schema
|
||||
[active-tokens tokens-tree node]
|
||||
[:map
|
||||
[:name (make-node-token-name-schema active-tokens tokens-tree node)]])
|
||||
|
||||
(defn convert-dtcg-token
|
||||
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
|
||||
Eg. From this:
|
||||
@ -288,16 +314,12 @@
|
||||
{:value parsed-value
|
||||
:unit unit}))))
|
||||
|
||||
;; FIXME: looks very redundant function
|
||||
(defn token-identifier
|
||||
[{:keys [name] :as _token}]
|
||||
name)
|
||||
|
||||
(defn attributes-map
|
||||
"Creats an attributes map using collection of `attributes` for `id`."
|
||||
"Creates an attributes map using collection of `attributes` for `id`."
|
||||
[attributes token]
|
||||
(->> (map (fn [attr] [attr (token-identifier token)]) attributes)
|
||||
(into {})))
|
||||
(into {}
|
||||
(map (fn [attr] [attr (:name token)]))
|
||||
attributes))
|
||||
|
||||
(defn remove-attributes-for-token
|
||||
"Removes applied tokens with `token-name` for the given `attributes` set from `applied-tokens`."
|
||||
@ -313,7 +335,7 @@
|
||||
"Test if `token` is applied to a `shape` on single `token-attribute`."
|
||||
[token shape token-attribute]
|
||||
(when-let [id (dm/get-in shape [:applied-tokens token-attribute])]
|
||||
(= (token-identifier token) id)))
|
||||
(= (:name token) id)))
|
||||
|
||||
(defn token-applied?
|
||||
"Test if `token` is applied to a `shape` with at least one of the given `token-attributes`."
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
:ref-shape-is-head
|
||||
:ref-shape-is-not-head
|
||||
:shape-ref-in-main
|
||||
:component-id-mismatch
|
||||
:root-main-not-allowed
|
||||
:nested-main-not-allowed
|
||||
:root-copy-not-allowed
|
||||
@ -59,6 +60,7 @@
|
||||
:not-head-copy-not-allowed
|
||||
:not-component-not-allowed
|
||||
:component-nil-objects-not-allowed
|
||||
:non-deleted-component-cannot-have-objects
|
||||
:instance-head-not-frame
|
||||
:invalid-text-touched
|
||||
:misplaced-slot
|
||||
@ -326,6 +328,20 @@
|
||||
:component-file (:component-file ref-shape)
|
||||
:component-id (:component-id ref-shape)))))
|
||||
|
||||
(defn- check-ref-component-id
|
||||
"Validate that if the copy has not been swapped, the component-id and component-file are
|
||||
the same as in the referenced shape in the near main."
|
||||
[shape file page libraries]
|
||||
(when (nil? (ctk/get-swap-slot shape))
|
||||
(when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
|
||||
(when (or (not= (:component-id shape) (:component-id ref-shape))
|
||||
(not= (:component-file shape) (:component-file ref-shape)))
|
||||
(report-error :component-id-mismatch
|
||||
"Nested copy component-id and component-file must be the same as the near main"
|
||||
shape file page
|
||||
:component-id (:component-id ref-shape)
|
||||
:component-file (:component-file ref-shape))))))
|
||||
|
||||
(defn- check-empty-swap-slot
|
||||
"Validate that this shape does not have any swap slot."
|
||||
[shape file page]
|
||||
@ -350,6 +366,19 @@
|
||||
"This shape has children with the same swap slot"
|
||||
shape file page)))
|
||||
|
||||
(defn- check-required-swap-slot
|
||||
"Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the
|
||||
matching shape by position in the near main."
|
||||
[shape file page libraries]
|
||||
(let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
|
||||
(when (and (some? near-match)
|
||||
(not= (:shape-ref shape) (:id near-match))
|
||||
(nil? (ctk/get-swap-slot shape)))
|
||||
(report-error :missing-slot
|
||||
"Shape has been swapped, should have swap slot"
|
||||
shape file page
|
||||
:swap-slot (or (ctk/get-swap-slot near-match) (:id near-match))))))
|
||||
|
||||
(defn- check-valid-touched
|
||||
"Validate that the text touched flags are coherent."
|
||||
[shape file page]
|
||||
@ -418,6 +447,8 @@
|
||||
(check-component-not-main-head shape file page libraries)
|
||||
(check-component-not-root shape file page)
|
||||
(check-valid-touched shape file page)
|
||||
(check-ref-component-id shape file page libraries)
|
||||
(check-required-swap-slot shape file page libraries)
|
||||
;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached
|
||||
;; so we only validate the shape-ref if the ancestor is from a valid library
|
||||
(when library-exists
|
||||
@ -458,8 +489,7 @@
|
||||
(defn- check-variant-container
|
||||
"Shape is a variant container, so:
|
||||
-all its children should be variants with variant-id equals to the shape-id
|
||||
-all the components should have the same properties
|
||||
"
|
||||
-all the components should have the same properties"
|
||||
[shape file page]
|
||||
(let [shape-id (:id shape)
|
||||
shapes (:shapes shape)
|
||||
@ -648,6 +678,13 @@
|
||||
"Component main not allowed inside other component"
|
||||
main-instance file component-page))))
|
||||
|
||||
(defn- check-not-objects
|
||||
[component file]
|
||||
(when (d/not-empty? (:objects component))
|
||||
(report-error :non-deleted-component-cannot-have-objects
|
||||
"A non-deleted component cannot have shapes inside"
|
||||
component file nil)))
|
||||
|
||||
(defn- check-component
|
||||
"Validate semantic coherence of a component. Report all errors found."
|
||||
[component file]
|
||||
@ -656,7 +693,8 @@
|
||||
"Objects list cannot be nil"
|
||||
component file nil))
|
||||
(when-not (:deleted component)
|
||||
(check-main-inside-main component file))
|
||||
(check-main-inside-main component file)
|
||||
(check-not-objects component file))
|
||||
(when (:deleted component)
|
||||
(check-component-duplicate-swap-slot component file)
|
||||
(check-ref-cycles component file))
|
||||
@ -674,8 +712,6 @@
|
||||
;; PUBLIC API: VALIDATION FUNCTIONS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare check-swap-slots)
|
||||
|
||||
(defn validate-file
|
||||
"Validate full referential integrity and semantic coherence on file data.
|
||||
|
||||
@ -686,8 +722,6 @@
|
||||
|
||||
(doseq [page (filter :id (ctpl/pages-seq data))]
|
||||
(check-shape uuid/zero file page libraries)
|
||||
(when (str/includes? (:name file) "check-swap-slot")
|
||||
(check-swap-slots uuid/zero file page libraries))
|
||||
(->> (get-orphan-shapes page)
|
||||
(run! #(check-shape % file page libraries))))
|
||||
|
||||
@ -728,40 +762,3 @@
|
||||
:hint "error on validating file referential integrity"
|
||||
:file-id (:id file)
|
||||
:details errors)))
|
||||
|
||||
(declare compare-slots)
|
||||
|
||||
;; Optional check to look for missing swap slots.
|
||||
;; Search for copies that do not point the shape-ref to the near component but don't have swap slot
|
||||
;; (looking for position relative to the parent, in the copy and the main).
|
||||
;;
|
||||
;; This check cannot be generally enabled, because files that have been migrated from components v1
|
||||
;; may have copies with shapes that do not match by position, but have not been swapped. So we enable
|
||||
;; it for specific files only. To activate the check, you need to add the string "check-swap-slot" to
|
||||
;; the name of the file.
|
||||
(defn- check-swap-slots
|
||||
[shape-id file page libraries]
|
||||
(let [shape (ctst/get-shape page shape-id)]
|
||||
(if (and (ctk/instance-root? shape) (ctk/in-component-copy? shape))
|
||||
(let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true :with-context? true)
|
||||
container (:container (meta ref-shape))]
|
||||
(when (some? ref-shape)
|
||||
(compare-slots shape ref-shape file page container)))
|
||||
(doall (for [child-id (:shapes shape)]
|
||||
(check-swap-slots child-id file page libraries))))))
|
||||
|
||||
(defn- compare-slots
|
||||
[shape-copy shape-main file container-copy container-main]
|
||||
(if (and (not= (:shape-ref shape-copy) (:id shape-main))
|
||||
(nil? (ctk/get-swap-slot shape-copy)))
|
||||
(report-error :missing-slot
|
||||
"Shape has been swapped, should have swap slot"
|
||||
shape-copy file container-copy
|
||||
:swap-slot (or (ctk/get-swap-slot shape-main) (:id shape-main)))
|
||||
(when (nil? (ctk/get-swap-slot shape-copy))
|
||||
(let [children-id-pairs (d/zip-all (:shapes shape-copy) (:shapes shape-main))]
|
||||
(doall (for [[child-copy-id child-main-id] children-id-pairs]
|
||||
(let [child-copy (ctst/get-shape container-copy child-copy-id)
|
||||
child-main (ctst/get-shape container-main child-main-id)]
|
||||
(when (and (some? child-copy) (some? child-main))
|
||||
(compare-slots child-copy child-main file container-copy container-main)))))))))
|
||||
|
||||
@ -119,12 +119,18 @@
|
||||
:strict-session-cookies
|
||||
:telemetry
|
||||
:terms-and-privacy-checkbox
|
||||
;; Only for developtment.
|
||||
:tiered-file-data-storage
|
||||
|
||||
;; Tokens
|
||||
:token-base-font-size
|
||||
:token-combobox
|
||||
:token-color
|
||||
:token-shadow
|
||||
:token-tokenscript
|
||||
:token-import-from-library
|
||||
:token-typography-row
|
||||
|
||||
;; Only for developtment.
|
||||
:transit-readable-response
|
||||
:user-feedback
|
||||
;; TODO: remove this flag.
|
||||
@ -132,6 +138,9 @@
|
||||
:webhooks
|
||||
;; TODO: deprecate this flag and consolidate the code
|
||||
:render-wasm-dpr
|
||||
;; Show WASM renderer info label (hidden by default).
|
||||
:render-wasm-info
|
||||
:render-switch
|
||||
:hide-release-modal
|
||||
:subscriptions
|
||||
:subscriptions-old
|
||||
@ -139,6 +148,10 @@
|
||||
;; Enable performance logs in devconsole (disabled by default)
|
||||
:perf-logs
|
||||
|
||||
;; Used for designate features that will be available in the next
|
||||
;; release
|
||||
:canary
|
||||
|
||||
;; Security layer middleware that filters request by fetch
|
||||
;; metadata headers
|
||||
:sec-fetch-metadata-middleware
|
||||
@ -154,7 +167,9 @@
|
||||
;; Activates the nitrate module
|
||||
:nitrate
|
||||
|
||||
:mcp})
|
||||
:mcp
|
||||
:background-blur
|
||||
:stroke-path})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
@ -180,7 +195,9 @@
|
||||
:enable-token-color
|
||||
:enable-token-shadow
|
||||
:enable-inspect-styles
|
||||
:enable-feature-fdata-objects-map])
|
||||
:enable-feature-fdata-objects-map
|
||||
:enable-feature-render-wasm
|
||||
:enable-token-import-from-library])
|
||||
|
||||
(defn parse
|
||||
[& flags]
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
java.util.List
|
||||
linked.map.LinkedMap
|
||||
linked.set.LinkedSet
|
||||
org.fressian.handlers.ReadHandler
|
||||
org.fressian.handlers.WriteHandler
|
||||
org.fressian.Reader
|
||||
org.fressian.StreamingWriter
|
||||
org.fressian.Writer
|
||||
org.fressian.handlers.ReadHandler
|
||||
org.fressian.handlers.WriteHandler))
|
||||
org.fressian.Writer))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@ -118,6 +118,36 @@
|
||||
(d/ordered-map)
|
||||
(partition-all 2 (seq kvs)))))
|
||||
|
||||
|
||||
(defn- adapt-write-handler
|
||||
[{:keys [name class wfn]}]
|
||||
[class {name (reify WriteHandler
|
||||
(write [_ w o]
|
||||
(wfn name w o)))}])
|
||||
|
||||
(defn- adapt-read-handler
|
||||
[{:keys [name rfn]}]
|
||||
[name (reify ReadHandler
|
||||
(read [_ rdr _ _]
|
||||
(rfn rdr)))])
|
||||
|
||||
(defn- merge-handlers
|
||||
[m1 m2]
|
||||
(-> (merge m1 m2)
|
||||
(d/without-nils)))
|
||||
|
||||
(def ^:private
|
||||
xf:adapt-write-handler
|
||||
(comp
|
||||
(filter :wfn)
|
||||
(map adapt-write-handler)))
|
||||
|
||||
(def ^:private
|
||||
xf:adapt-read-handler
|
||||
(comp
|
||||
(filter :rfn)
|
||||
(map adapt-read-handler)))
|
||||
|
||||
(def ^:dynamic *write-handler-lookup* nil)
|
||||
(def ^:dynamic *read-handler-lookup* nil)
|
||||
|
||||
@ -126,36 +156,39 @@
|
||||
|
||||
(defn add-handlers!
|
||||
[& handlers]
|
||||
(letfn [(adapt-write-handler [{:keys [name class wfn]}]
|
||||
[class {name (reify WriteHandler
|
||||
(write [_ w o]
|
||||
(wfn name w o)))}])
|
||||
(let [write-handlers'
|
||||
(into {} xf:adapt-write-handler handlers)
|
||||
|
||||
(adapt-read-handler [{:keys [name rfn]}]
|
||||
[name (reify ReadHandler
|
||||
(read [_ rdr _ _]
|
||||
(rfn rdr)))])
|
||||
read-handlers'
|
||||
(into {} xf:adapt-read-handler handlers)
|
||||
|
||||
(merge-and-clean [m1 m2]
|
||||
(-> (merge m1 m2)
|
||||
(d/without-nils)))]
|
||||
write-handlers'
|
||||
(swap! write-handlers merge-handlers write-handlers')
|
||||
|
||||
(let [whs (into {}
|
||||
(comp
|
||||
(filter :wfn)
|
||||
(map adapt-write-handler))
|
||||
handlers)
|
||||
rhs (into {}
|
||||
(comp
|
||||
(filter :rfn)
|
||||
(map adapt-read-handler))
|
||||
handlers)
|
||||
cwh (swap! write-handlers merge-and-clean whs)
|
||||
crh (swap! read-handlers merge-and-clean rhs)]
|
||||
read-handlers'
|
||||
(swap! read-handlers merge-handlers read-handlers')]
|
||||
|
||||
(alter-var-root #'*write-handler-lookup* (constantly (-> cwh fres/associative-lookup fres/inheritance-lookup)))
|
||||
(alter-var-root #'*read-handler-lookup* (constantly (-> crh fres/associative-lookup)))
|
||||
nil)))
|
||||
(alter-var-root #'*write-handler-lookup*
|
||||
(constantly
|
||||
(-> write-handlers' fres/associative-lookup fres/inheritance-lookup)))
|
||||
|
||||
(alter-var-root #'*read-handler-lookup*
|
||||
(constantly (-> read-handlers' fres/associative-lookup)))
|
||||
|
||||
nil))
|
||||
|
||||
(defn overwrite-read-handlers
|
||||
[& handlers]
|
||||
(->> (into {} xf:adapt-read-handler handlers)
|
||||
(merge-handlers @read-handlers)
|
||||
(fres/associative-lookup)))
|
||||
|
||||
(defn overwrite-write-handlers
|
||||
[& handlers]
|
||||
(->> (into {} xf:adapt-write-handler handlers)
|
||||
(merge-handlers @write-handlers)
|
||||
(fres/associative-lookup)
|
||||
(fres/inheritance-lookup)))
|
||||
|
||||
(defn write-char
|
||||
[n w o]
|
||||
|
||||
@ -8,11 +8,11 @@
|
||||
(:refer-clojure :exclude [get])
|
||||
(:import
|
||||
java.lang.AutoCloseable
|
||||
org.apache.commons.pool2.impl.DefaultPooledObject
|
||||
org.apache.commons.pool2.impl.SoftReferenceObjectPool
|
||||
org.apache.commons.pool2.ObjectPool
|
||||
org.apache.commons.pool2.PooledObject
|
||||
org.apache.commons.pool2.PooledObjectFactory
|
||||
org.apache.commons.pool2.impl.DefaultPooledObject
|
||||
org.apache.commons.pool2.impl.SoftReferenceObjectPool))
|
||||
org.apache.commons.pool2.PooledObjectFactory))
|
||||
|
||||
(defn pool?
|
||||
[o]
|
||||
|
||||
@ -79,10 +79,10 @@
|
||||
(loop [new-ids
|
||||
(->> (cfh/get-parent-seq objects cid)
|
||||
(take-while #(and (cfh/group-like-shape? %)
|
||||
(not (.has ids %))))
|
||||
(not (.has ids (:id %)))))
|
||||
(seq))]
|
||||
(when (some? new-ids)
|
||||
(.add ids (first new-ids))
|
||||
(.add ids (:id (first new-ids)))
|
||||
(recur (next new-ids))))
|
||||
(recur (next base-ids)))))
|
||||
ids)))
|
||||
|
||||
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