mirror of
https://github.com/penpot/penpot.git
synced 2026-01-05 04:48:47 -05:00
Compare commits
556 Commits
hiru-trans
...
1.17.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fa4f71ad | ||
|
|
fa21dc4cf9 | ||
|
|
2460f36bab | ||
|
|
4d627f8993 | ||
|
|
7771467aa0 | ||
|
|
0e97182ef0 | ||
|
|
f0c0e5e43a | ||
|
|
475b6ff6e0 | ||
|
|
a1f41c80a2 | ||
|
|
4297b6fda8 | ||
|
|
28dce3cc8b | ||
|
|
3c650ae47e | ||
|
|
1806200613 | ||
|
|
ed22e2c6d1 | ||
|
|
0487539b23 | ||
|
|
fd15ff940f | ||
|
|
ece6193260 | ||
|
|
813a188e24 | ||
|
|
0f07def536 | ||
|
|
490f5f19f1 | ||
|
|
b3216000fd | ||
|
|
2ef3e4b325 | ||
|
|
70edd2c290 | ||
|
|
02543b1a4f | ||
|
|
094556926e | ||
|
|
1ed3b3cf75 | ||
|
|
1637e82018 | ||
|
|
c467d04d50 | ||
|
|
8d19c067e8 | ||
|
|
a99fb7ada3 | ||
|
|
2f1d1a6c41 | ||
|
|
7f963edf9e | ||
|
|
9c99d86e08 | ||
|
|
6a5bfdd7fb | ||
|
|
a98ba72c12 | ||
|
|
ee42dd8b01 | ||
|
|
da209b7507 | ||
|
|
d49e1f1641 | ||
|
|
8e35ad0f7f | ||
|
|
be3a973d09 | ||
|
|
78aea0f24e | ||
|
|
6e1ce62aad | ||
|
|
070ea135e5 | ||
|
|
5ae1fe5867 | ||
|
|
eef2cba976 | ||
|
|
1c4dcf1574 | ||
|
|
220b80799d | ||
|
|
22b6d4241d | ||
|
|
fa02df7106 | ||
|
|
5d6462b2a7 | ||
|
|
3464842c1e | ||
|
|
d74af6ddc1 | ||
|
|
8cb33dc19c | ||
|
|
4912107fcc | ||
|
|
d5c7a6e547 | ||
|
|
f1085aadd1 | ||
|
|
ca5b59f102 | ||
|
|
a0898fbabd | ||
|
|
aaf332ed18 | ||
|
|
b05ca4bb82 | ||
|
|
b46b23b027 | ||
|
|
29c0190b7a | ||
|
|
f1b09e763e | ||
|
|
2e5e772392 | ||
|
|
ecd4bb54c9 | ||
|
|
3cfc432c23 | ||
|
|
e426425cb5 | ||
|
|
3a0cc63fa7 | ||
|
|
88a8370e8d | ||
|
|
e8972dd802 | ||
|
|
3e52bef6d4 | ||
|
|
7c215dc11b | ||
|
|
48c3e3e00b | ||
|
|
412dcae01a | ||
|
|
cc5f245209 | ||
|
|
dc4aabe263 | ||
|
|
708a8ce27b | ||
|
|
7c1d9ce06f | ||
|
|
b0cbf09950 | ||
|
|
f31bc7457f | ||
|
|
e47ce3235e | ||
|
|
fe76e0fab6 | ||
|
|
297ba10e9d | ||
|
|
dd2321a37b | ||
|
|
f98630a46b | ||
|
|
82d6ba790c | ||
|
|
575aec209c | ||
|
|
00e265695c | ||
|
|
071ac0366c | ||
|
|
1a2a90f829 | ||
|
|
028c084b22 | ||
|
|
e7e80e99bd | ||
|
|
70fa169d0d | ||
|
|
6be83fc6d6 | ||
|
|
1e9ece43d0 | ||
|
|
965c0d6fa2 | ||
|
|
950d5dcc2f | ||
|
|
43d034798c | ||
|
|
86712f977d | ||
|
|
707e6c2a33 | ||
|
|
3dfd87eee1 | ||
|
|
037ba19e87 | ||
|
|
cdbab2c098 | ||
|
|
e8ea61ee78 | ||
|
|
7ab91f68af | ||
|
|
91ececa59e | ||
|
|
8758723200 | ||
|
|
8a968dc081 | ||
|
|
f8cb505196 | ||
|
|
14e3439cae | ||
|
|
7dd55c7f9d | ||
|
|
e8e3398a74 | ||
|
|
95cad24c18 | ||
|
|
d31138db72 | ||
|
|
2c5f35e192 | ||
|
|
5a8f8ba349 | ||
|
|
3fe5cd3752 | ||
|
|
da60911d81 | ||
|
|
f4f1f80050 | ||
|
|
18445ea5f4 | ||
|
|
2d28e02742 | ||
|
|
b0b963fb7c | ||
|
|
5cfee13956 | ||
|
|
7271e98df3 | ||
|
|
f0386ef7b0 | ||
|
|
185cabb2fa | ||
|
|
3a19223264 | ||
|
|
2c38f31aa9 | ||
|
|
a1dcb11261 | ||
|
|
9f8d86a80e | ||
|
|
c59fc87fc4 | ||
|
|
3421e6ef57 | ||
|
|
40349c8ece | ||
|
|
5a53376b01 | ||
|
|
d4dfdaff57 | ||
|
|
c7f87d0f26 | ||
|
|
c7954990f0 | ||
|
|
fe118819ce | ||
|
|
073ec9ea2b | ||
|
|
f85a731969 | ||
|
|
a3a88d7a0a | ||
|
|
1660dd634e | ||
|
|
6e698110d6 | ||
|
|
951c67a2d5 | ||
|
|
50b7337b8c | ||
|
|
15e62ff649 | ||
|
|
e7ddd6055f | ||
|
|
aa3438f800 | ||
|
|
a45380a91c | ||
|
|
86b68aeca4 | ||
|
|
d69d392362 | ||
|
|
506c2b8d7b | ||
|
|
b463ebc17b | ||
|
|
f90fda2c90 | ||
|
|
87c5aa71a3 | ||
|
|
4f82f6bde4 | ||
|
|
545b3860b4 | ||
|
|
d4921c8eb9 | ||
|
|
18652d0b6f | ||
|
|
2dbeda1d8f | ||
|
|
9422d1e9e2 | ||
|
|
e0441bc16a | ||
|
|
d7d6166232 | ||
|
|
6fd6205634 | ||
|
|
7cd6f5ba70 | ||
|
|
9cc3cceb06 | ||
|
|
6f6bcd2f7e | ||
|
|
f9f3b3951f | ||
|
|
22ded62000 | ||
|
|
71d104f768 | ||
|
|
5a36cbceb7 | ||
|
|
f2033c46f3 | ||
|
|
6b225a10b5 | ||
|
|
38fe6e856a | ||
|
|
1984109436 | ||
|
|
9f9d9277a6 | ||
|
|
e041f93680 | ||
|
|
2d779a4414 | ||
|
|
21fc9289a6 | ||
|
|
b40ea3fb2a | ||
|
|
444e9a3081 | ||
|
|
f93d305545 | ||
|
|
09a91c87be | ||
|
|
e71d569cda | ||
|
|
a56a9868dc | ||
|
|
a09198b46e | ||
|
|
c7e9c658cd | ||
|
|
58d7bc5c14 | ||
|
|
e939db927e | ||
|
|
efe50479de | ||
|
|
ea1b3bd058 | ||
|
|
4751d7d385 | ||
|
|
bc88e30efa | ||
|
|
9623dbfbd6 | ||
|
|
f177de6661 | ||
|
|
43043e2dc1 | ||
|
|
05d21d7d07 | ||
|
|
02aab37ee7 | ||
|
|
d3aee1afa3 | ||
|
|
ac361cdb36 | ||
|
|
7ac6f49c08 | ||
|
|
d3e11433bf | ||
|
|
771d1d9194 | ||
|
|
4a3a53182b | ||
|
|
c25cf043fa | ||
|
|
7440d38c94 | ||
|
|
a8c0d437ce | ||
|
|
8d683beae4 | ||
|
|
4007d8713c | ||
|
|
ead64a1820 | ||
|
|
88e2a5c56e | ||
|
|
9782d9077f | ||
|
|
b4c4511d9d | ||
|
|
316b3d4539 | ||
|
|
1c54e9fa4d | ||
|
|
3d064b804b | ||
|
|
088a8af345 | ||
|
|
77cd645e25 | ||
|
|
8ee7915c1d | ||
|
|
ea8755ce24 | ||
|
|
381aae735d | ||
|
|
a4826eddcd | ||
|
|
31e2fff4d4 | ||
|
|
021c714867 | ||
|
|
231ac00934 | ||
|
|
578ff944a6 | ||
|
|
bf8a514871 | ||
|
|
8d60b3fc3e | ||
|
|
8468e7af24 | ||
|
|
50eee3f597 | ||
|
|
b9b3fcdb6a | ||
|
|
f0d74ab63e | ||
|
|
dad5d953ce | ||
|
|
f6058aa71e | ||
|
|
85d56e6057 | ||
|
|
c353d3703b | ||
|
|
9367788898 | ||
|
|
2b978777d7 | ||
|
|
2a30c23334 | ||
|
|
2f188e7fb4 | ||
|
|
0743b07667 | ||
|
|
f38197b227 | ||
|
|
bc9be7846a | ||
|
|
28114b166c | ||
|
|
be74cd2c7b | ||
|
|
b329de6487 | ||
|
|
9c66998530 | ||
|
|
8b377ac556 | ||
|
|
8c6f07ab65 | ||
|
|
dc89610d07 | ||
|
|
40195a4f52 | ||
|
|
6a257503ae | ||
|
|
a3e583d745 | ||
|
|
685a071e87 | ||
|
|
73658c47f3 | ||
|
|
d98fd76032 | ||
|
|
2fef3dc881 | ||
|
|
a1a0444cc7 | ||
|
|
792c17fe46 | ||
|
|
77d71abb5d | ||
|
|
75d6e21af8 | ||
|
|
0632111e96 | ||
|
|
fe77ef4438 | ||
|
|
e7ac7ff7fb | ||
|
|
d78ad30e23 | ||
|
|
4b5caf5fb9 | ||
|
|
4e1eb2d6e9 | ||
|
|
ab7683f1e3 | ||
|
|
89371e10d1 | ||
|
|
9fd6c65d93 | ||
|
|
1f9c89fb32 | ||
|
|
61e83d7e01 | ||
|
|
a1a3d09998 | ||
|
|
de7a1d34c0 | ||
|
|
f93d0e1c4d | ||
|
|
c5d8d77070 | ||
|
|
c18d3c66a8 | ||
|
|
0d96b5b798 | ||
|
|
24f45fafbf | ||
|
|
ca8df3a8d8 | ||
|
|
d14f4c5c4a | ||
|
|
f6ff80a3d4 | ||
|
|
b2d8f807f9 | ||
|
|
03b3b441b5 | ||
|
|
523539e403 | ||
|
|
3280a6853e | ||
|
|
fb060cb806 | ||
|
|
8892cebb6f | ||
|
|
6fb97e54a9 | ||
|
|
1c3470ca53 | ||
|
|
0ae42be851 | ||
|
|
ff6f0b2744 | ||
|
|
a3a2ab1ecd | ||
|
|
01ba68fd6f | ||
|
|
1ab669cc7b | ||
|
|
ab421ac3f9 | ||
|
|
0faa0b21a4 | ||
|
|
4ca6a89e6f | ||
|
|
ab5fd68689 | ||
|
|
275eb993ce | ||
|
|
88143cfb8b | ||
|
|
5f0f3abeae | ||
|
|
b203c87dbb | ||
|
|
7a796bc83f | ||
|
|
196e193281 | ||
|
|
d0a15cda96 | ||
|
|
c3733ed2e1 | ||
|
|
379623d629 | ||
|
|
cb2553a8ca | ||
|
|
1b7ea6ed53 | ||
|
|
57a569a07a | ||
|
|
a5006b1687 | ||
|
|
24dc40a1b0 | ||
|
|
b4fc39f73c | ||
|
|
095dc2ad11 | ||
|
|
fcbbe8e5c7 | ||
|
|
bafe3ec087 | ||
|
|
5d44d75465 | ||
|
|
44102050ee | ||
|
|
cae436f365 | ||
|
|
e6d80e34b9 | ||
|
|
fbec07bd48 | ||
|
|
a555028ee2 | ||
|
|
d91e8c349e | ||
|
|
abe26007d7 | ||
|
|
2da421bb7a | ||
|
|
7d48b86e46 | ||
|
|
28663b5ff6 | ||
|
|
651d4f794b | ||
|
|
58aa6b3666 | ||
|
|
131c2f331e | ||
|
|
8df861faaa | ||
|
|
4f81f9636a | ||
|
|
31dfdf51c9 | ||
|
|
acf51ea744 | ||
|
|
a54f5484e8 | ||
|
|
3a8486f4b0 | ||
|
|
43c3d67521 | ||
|
|
4b2d82e100 | ||
|
|
f2fd380979 | ||
|
|
984187037c | ||
|
|
173e5da98e | ||
|
|
2ab3ed9ab4 | ||
|
|
74e4273549 | ||
|
|
12392a4038 | ||
|
|
987b7f44f4 | ||
|
|
3480d6979b | ||
|
|
9ca1efc128 | ||
|
|
81a95d362c | ||
|
|
a7dfda515b | ||
|
|
b5c1199f4d | ||
|
|
4aa8baa129 | ||
|
|
553f2f5576 | ||
|
|
b132837432 | ||
|
|
36bc276d93 | ||
|
|
35aa391129 | ||
|
|
2c2755b35e | ||
|
|
bedaef961b | ||
|
|
fe7f4004f1 | ||
|
|
eef42acf79 | ||
|
|
937713311e | ||
|
|
94fc067286 | ||
|
|
ae6ea7744e | ||
|
|
f628955a15 | ||
|
|
6cdf696fc4 | ||
|
|
c42ef7c5b0 | ||
|
|
853be27780 | ||
|
|
b235d3f0f2 | ||
|
|
04dc9f7881 | ||
|
|
1fdf09a692 | ||
|
|
c2e0b18f26 | ||
|
|
672cfa4ecc | ||
|
|
c459c56f37 | ||
|
|
0863a96f93 | ||
|
|
97a884018f | ||
|
|
1718f49a90 | ||
|
|
2c1fb1424c | ||
|
|
5e1cabc857 | ||
|
|
6f72ea0530 | ||
|
|
c2d8c1994c | ||
|
|
985d5cc20c | ||
|
|
a0364e8835 | ||
|
|
b273bd44c5 | ||
|
|
ec2fff31a0 | ||
|
|
53a8718e8d | ||
|
|
216a43cc43 | ||
|
|
10439934d4 | ||
|
|
84e9f69213 | ||
|
|
837b52aea1 | ||
|
|
98698cf2db | ||
|
|
d5ab0eea1a | ||
|
|
333acacbbf | ||
|
|
598959cd3f | ||
|
|
05431cc757 | ||
|
|
f56b8be33d | ||
|
|
644854a651 | ||
|
|
e926b11fef | ||
|
|
40da1c302a | ||
|
|
b5e53b57d1 | ||
|
|
e8d561ac7f | ||
|
|
cf87c54ed4 | ||
|
|
3ce1540331 | ||
|
|
cda2dade95 | ||
|
|
baf4dfdecc | ||
|
|
ade13d3bca | ||
|
|
ff9b2090cf | ||
|
|
733b35dd53 | ||
|
|
466e018411 | ||
|
|
32d39c35e4 | ||
|
|
5f77df1996 | ||
|
|
24538add3f | ||
|
|
407831ffd1 | ||
|
|
379997f9db | ||
|
|
b1d99232a9 | ||
|
|
7e21d827c9 | ||
|
|
443d8b21c1 | ||
|
|
e372e8ba3e | ||
|
|
27451b9796 | ||
|
|
73a3e0c0ae | ||
|
|
d68be0869b | ||
|
|
7a8b0e710b | ||
|
|
3b61a7dd91 | ||
|
|
941aa6ad5d | ||
|
|
42b69df671 | ||
|
|
4442246e08 | ||
|
|
d1dbc3850d | ||
|
|
ed4a5f6c60 | ||
|
|
0144939f34 | ||
|
|
ede07e4f44 | ||
|
|
b2c55c79a4 | ||
|
|
0b2ffbe1fa | ||
|
|
ebfe651b7d | ||
|
|
dac11d1606 | ||
|
|
c8bd1e89d6 | ||
|
|
8111db1110 | ||
|
|
0a8dfde0a2 | ||
|
|
9f6a3cbc23 | ||
|
|
6592456085 | ||
|
|
3bbf632121 | ||
|
|
104059a7b1 | ||
|
|
f75af88877 | ||
|
|
d4360be96e | ||
|
|
dcf95a7502 | ||
|
|
4fc3f316e0 | ||
|
|
83c8e7f03a | ||
|
|
074864a6bf | ||
|
|
aed7f0ad43 | ||
|
|
cd2df41e87 | ||
|
|
00fbfd6e9e | ||
|
|
93726cf8fe | ||
|
|
1dc6464974 | ||
|
|
81cebb2aa8 | ||
|
|
6c8144a18a | ||
|
|
47bf758ad7 | ||
|
|
13cfe56301 | ||
|
|
33f7cec933 | ||
|
|
1f00d91dd7 | ||
|
|
c1a8437b6d | ||
|
|
5cb3aa5dbc | ||
|
|
de72dc5769 | ||
|
|
b827037f90 | ||
|
|
60fb3f3d0e | ||
|
|
84fd952471 | ||
|
|
e37fc00351 | ||
|
|
4164c8f012 | ||
|
|
c86af68349 | ||
|
|
4302ab05e4 | ||
|
|
777e2fb0a3 | ||
|
|
f7412ccbd7 | ||
|
|
fe11b37b8f | ||
|
|
c469bd5757 | ||
|
|
7d817eb080 | ||
|
|
2840cb893e | ||
|
|
7f5491f45b | ||
|
|
ef9dcf391d | ||
|
|
81ecb26f8b | ||
|
|
35fd3ce150 | ||
|
|
68d2afc75d | ||
|
|
d094eb3595 | ||
|
|
f0d4ad4b20 | ||
|
|
b929564fa7 | ||
|
|
53d9b547c3 | ||
|
|
50c17e1261 | ||
|
|
a113a64554 | ||
|
|
c13730dca7 | ||
|
|
498ec29e47 | ||
|
|
880d01368f | ||
|
|
1fe1a352c3 | ||
|
|
8ffe023d3e | ||
|
|
16f30316c0 | ||
|
|
ac7cb3c8c7 | ||
|
|
61c1b65072 | ||
|
|
ef994548c1 | ||
|
|
159085fd83 | ||
|
|
84bee9fb93 | ||
|
|
2dcb4a155e | ||
|
|
abf397fe5b | ||
|
|
0087447b01 | ||
|
|
f47c20e079 | ||
|
|
4b26b6fc02 | ||
|
|
abeec9f869 | ||
|
|
c9c070b5f4 | ||
|
|
d80a24b1e3 | ||
|
|
ae8000df26 | ||
|
|
f239c401e2 | ||
|
|
f2e2700c79 | ||
|
|
d38c495807 | ||
|
|
025cd44eae | ||
|
|
8ac96d09cd | ||
|
|
8f2a02ae72 | ||
|
|
710878a667 | ||
|
|
350e4a1d1b | ||
|
|
801d926946 | ||
|
|
e50ecd70c6 | ||
|
|
f11da06637 | ||
|
|
a6b26f0563 | ||
|
|
dbf743d58a | ||
|
|
d35e35acde | ||
|
|
36f2ca6bb2 | ||
|
|
c570557203 | ||
|
|
797ae22526 | ||
|
|
4e1e67fc3d | ||
|
|
76a83bece9 | ||
|
|
5605ac2769 | ||
|
|
e88d6d88a8 | ||
|
|
0cc6c76cdb | ||
|
|
fa7cf70cee | ||
|
|
e25cf13783 | ||
|
|
6b199bef89 | ||
|
|
74e6c01213 | ||
|
|
970dc04bc6 | ||
|
|
aefdbfa8ef | ||
|
|
1b3976da47 | ||
|
|
c52046d25b | ||
|
|
609fa87fe2 | ||
|
|
9ca2450813 | ||
|
|
408d33bdec | ||
|
|
226afe98e0 | ||
|
|
db7920435b | ||
|
|
bdd00be5e4 | ||
|
|
6eedb5315b | ||
|
|
7045496a39 | ||
|
|
02f29ed4d0 | ||
|
|
6ea0279c9e | ||
|
|
6a7a25121e | ||
|
|
a8f65ba69e | ||
|
|
096b5f096c | ||
|
|
842463ed1b | ||
|
|
7d2e3a0864 | ||
|
|
c2ced974b1 | ||
|
|
653b6bdb42 | ||
|
|
c820c49fc5 | ||
|
|
7a9172560d | ||
|
|
be5053ce22 | ||
|
|
44e87e75e6 | ||
|
|
84903ae1f2 |
@@ -45,6 +45,15 @@
|
||||
:redundant-do
|
||||
{:level :off}
|
||||
|
||||
:earmuffed-var-not-dynamic
|
||||
{:level :off}
|
||||
|
||||
:dynamic-var-not-earmuffed
|
||||
{:level :off}
|
||||
|
||||
:used-underscored-binding
|
||||
{:level :warning}
|
||||
|
||||
:unused-binding
|
||||
{:exclude-destructured-as true
|
||||
:exclude-destructured-keys-in-fn-args false
|
||||
|
||||
94
CHANGES.md
94
CHANGES.md
@@ -1,14 +1,57 @@
|
||||
# CHANGELOG
|
||||
## 1.17.2
|
||||
|
||||
## :rocket: Next (1.17)
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix invite members button text [Taiga #4794](https://tree.taiga.io/project/penpot/issue/4794)
|
||||
- Fix problem with opacity in frames [Taiga #4795](https://tree.taiga.io/project/penpot/issue/4795)
|
||||
- Fix correct behaviour for space-around and added space-evenly option
|
||||
- Fix duplicate with alt and undo only undo one step [Taiga #4746](https://tree.taiga.io/project/penpot/issue/4746)
|
||||
- Fix problem creating frames inside layout [Taiga #4844](https://tree.taiga.io/project/penpot/issue/4844)
|
||||
|
||||
## 1.17.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
- Fix paste board inside itself [Taiga #4775](https://tree.taiga.io/project/penpot/issue/4775)
|
||||
- Fix middle button panning can drag guides [Taiga #4266](https://tree.taiga.io/project/penpot/issue/4266)
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- To @ondrejkonec: for some code contributions on this release.
|
||||
|
||||
## 1.17.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
- Fix components groups items show the component name in list mode [Taiga #4770](https://tree.taiga.io/project/penpot/issue/4770)
|
||||
- Fix typing CMD+Z on MacOS turns the cursor into a Zoom cursor [Taiga #4778](https://tree.taiga.io/project/penpot/issue/4778)
|
||||
- Fix white space on small screens [Taiga #4774](https://tree.taiga.io/project/penpot/issue/4774)
|
||||
- Fix button spacing on delete acount modal [Taiga #4762](https://tree.taiga.io/project/penpot/issue/4762)
|
||||
- Fix invitations input on team management and onboarding modal [Taiga #4760](https://tree.taiga.io/project/penpot/issue/4760)
|
||||
- Fix weird numeration creating new elements in dashboard [Taiga #4755](https://tree.taiga.io/project/penpot/issue/4755)
|
||||
- Fix can move shape with lens zoom active [Taiga #4787](https://tree.taiga.io/project/penpot/issue/4787)
|
||||
- Fix social links broken [Taiga #4759](https://tree.taiga.io/project/penpot/issue/4759)
|
||||
- Fix tooltips on left toolbar [Taiga #4793](https://tree.taiga.io/project/penpot/issue/4793)
|
||||
|
||||
## 1.17.0
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
### :sparkles: New features
|
||||
|
||||
- Adds layout flex functionality for boards
|
||||
- Better overlays interactions on boards inside boards [Taiga #4386](https://tree.taiga.io/project/penpot/us/4386)
|
||||
- Show board miniature in manual overlay setting [Taiga #4475](https://tree.taiga.io/project/penpot/issue/4475)
|
||||
- Handoff visual improvements [Taiga #3124](https://tree.taiga.io/project/penpot/us/3124)
|
||||
- Dynamic alignment only in sight [Github 1971](https://github.com/penpot/penpot/issues/1971)
|
||||
- Add some accessibility to shortcut panel [Taiga #4713](https://tree.taiga.io/project/penpot/issue/4713)
|
||||
- Add shortcuts for text editing [Taiga #2052](https://tree.taiga.io/project/penpot/us/2052)
|
||||
- Second level boards treated as groups in terms of selection [Taiga #4269](https://tree.taiga.io/project/penpot/us/4269)
|
||||
- Performance improvements both for backend and frontend
|
||||
- Accessibility improvements for login area [Taiga #4353](https://tree.taiga.io/project/penpot/us/4353)
|
||||
- Outbound webhooks [Taiga #4577](https://tree.taiga.io/project/penpot/us/4577)
|
||||
- Add copy invitation link to the invitation options [Taiga #4213](https://tree.taiga.io/project/penpot/us/4213)
|
||||
- Dynamic alignment only in sight [Taiga #3537](https://tree.taiga.io/project/penpot/us/3537)
|
||||
- Improve naming of layers [Taiga #4036](https://tree.taiga.io/project/penpot/us/4036)
|
||||
- Add zoom lense [Taiga #4691](https://tree.taiga.io/project/penpot/us/4691)
|
||||
- Detect potential problems with custom font vertical metrics [Taiga #4697](https://tree.taiga.io/project/penpot/us/4697)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -19,11 +62,55 @@
|
||||
- Fix twitter support account link [Taiga #4279](https://tree.taiga.io/project/penpot/issue/4279)
|
||||
- Fix lang autodetect issue [Taiga #4277](https://tree.taiga.io/project/penpot/issue/4277)
|
||||
- Fix adding an extra page on import [Taiga #4543](https://tree.taiga.io/project/penpot/task/4543)
|
||||
- Fix unable to select text at assets inputs in firefox [Taiga #4572](https://tree.taiga.io/project/penpot/issue/4572)
|
||||
- Fix component sync when converting to path [Taiga #3642](https://tree.taiga.io/project/penpot/issue/3642)
|
||||
- Fix style for team invite in deutsch [Taiga #4614](https://tree.taiga.io/project/penpot/issue/4614)
|
||||
- Fix problem with text edition in Safari [Taiga #4046](https://tree.taiga.io/project/penpot/issue/4046)
|
||||
- Fix show outline with rounded corners on rects [Taiga #4053](https://tree.taiga.io/project/penpot/issue/4053)
|
||||
- Fix wrong interaction between comments and panning modes [Taiga #4297](https://tree.taiga.io/project/penpot/issue/4297)
|
||||
- Fix bad element positioning on interaction with fixed scroll [Github #2660](https://github.com/penpot/penpot/issues/2660)
|
||||
- Fix display type of component library not persistent [Taiga #4512](https://tree.taiga.io/project/penpot/issue/4512)
|
||||
- Fix problem when moving texts with keyboard [#2690](https://github.com/penpot/penpot/issues/2690)
|
||||
- Fix problem when drawing boxes won't detect mouse-up [Taiga #4618](https://tree.taiga.io/project/penpot/issue/4618)
|
||||
- Fix missing loading icon on shared libraries [Taiga #4148](https://tree.taiga.io/project/penpot/issue/4148)
|
||||
- Fix selection stroke missing in properties of multiple texts [Taiga #4048](https://tree.taiga.io/project/penpot/issue/4048)
|
||||
- Fix missing create component menu for frames [Github #2670](https://github.com/penpot/penpot/issues/2670)
|
||||
- Fix "currentColor" is not converted when importing SVG [Github 2276](https://github.com/penpot/penpot/issues/2276)
|
||||
- Fix incorrect color in properties of multiple bool shapes [Taiga #4355](https://tree.taiga.io/project/penpot/issue/4355)
|
||||
- Fix pressing the enter key gives you an internal error [Github 2675](https://github.com/penpot/penpot/issues/2675) [Github 2577](https://github.com/penpot/penpot/issues/2577)
|
||||
- Fix confirm group name with enter doesn't work in assets modal [Taiga #4506](https://tree.taiga.io/project/penpot/issue/4506)
|
||||
- Fix group/ungroup shapes inside a component [Taiga #4052](https://tree.taiga.io/project/penpot/issue/4052)
|
||||
- Fix wrong update of text in components [Taiga #4646](https://tree.taiga.io/project/penpot/issue/4646)
|
||||
- Fix problem with SVG imports with style [#2605](https://github.com/penpot/penpot/issues/2605)
|
||||
- Fix ghost shapes after sync groups in components [Taiga #4649](https://tree.taiga.io/project/penpot/issue/4649)
|
||||
- Fix layer orders messed up on move, group, reparent and undo [Github #2672](https://github.com/penpot/penpot/issues/2672)
|
||||
- Fix max height in library dialog [Github #2335](https://github.com/penpot/penpot/issues/2335)
|
||||
- Fix undo ungroup (shift+g) scrambles positions [Taiga #4674](https://tree.taiga.io/project/penpot/issue/4674)
|
||||
- Fix justified text is stretched [Github #2539](https://github.com/penpot/penpot/issues/2539)
|
||||
- Fix mousewheel on viewer inspector [Taiga #4221](https://tree.taiga.io/project/penpot/issue/4221)
|
||||
- Fix path edition activated on boards [Taiga #4105](https://tree.taiga.io/project/penpot/issue/4105)
|
||||
- Fix hidden layers inside groups become visible after the group visibility is changed[Taiga #4710](https://tree.taiga.io/project/penpot/issue/4710)
|
||||
- Fix format of HSLA color on viewer [Taiga #4393](https://tree.taiga.io/project/penpot/issue/4393)
|
||||
- Fix some typos [Taiga #4724](https://tree.taiga.io/project/penpot/issue/4724)
|
||||
- Fix ctrl+c for inspect code [Taiga #4739](https://tree.taiga.io/project/penpot/issue/4739)
|
||||
- Fix text in custom font is not at the expected position at export [Taiga #4394](https://tree.taiga.io/project/penpot/issue/4394)
|
||||
- Fix unneeded popup when updating local components [Taiga #4430](https://tree.taiga.io/project/penpot/issue/4430)
|
||||
- Fix multiuser - "Shadow" element is not updating immediately [Taiga #4709](https://tree.taiga.io/project/penpot/issue/4709)
|
||||
- Fix paths not flagged as modified when resized [Taiga #4742](https://tree.taiga.io/project/penpot/issue/4742)
|
||||
- Fix resend invitation doesn't reset the expiration date [Taiga #4741](https://tree.taiga.io/project/penpot/issue/4741)
|
||||
- Fix incorrect state after undo page creation [Taiga #4690](https://tree.taiga.io/project/penpot/issue/4690)
|
||||
- Fix copy paste texts with typography assets linked [Taiga #4750](https://tree.taiga.io/project/penpot/issue/4750)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- To @iprithvitharun: let's make UX Writing contributions in Open Source a trend!
|
||||
|
||||
## 1.16.2-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix strage cursor behaviour after clicking viewport with text pool [Github #2447](https://github.com/penpot/penpot/issues/2447)
|
||||
|
||||
## 1.16.1-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -91,7 +178,6 @@
|
||||
- Fix grid not syncing immediately in multiuser [Taiga #4339](https://tree.taiga.io/project/penpot/issue/4339)
|
||||
- Fix custom font upload fails silently for unsupported formats [Taiga #4279](https://tree.taiga.io/project/penpot/issue/4280)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- To @andrewzhurov for many code contributions on this release.
|
||||
|
||||
@@ -50,7 +50,7 @@ Being web based, Penpot is not dependent on operating systems or local installat
|
||||
Using SVG as no other design and prototyping tool does, Penpot files sport compatibility with most of the vectorial tools, are tech friendly and extremely easy to use on the web. We make sure you will always own your work.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://penpot.app/images/open-source.png" alt="Open Source">
|
||||
<img src="https://penpot.app/images/readme/open-source.png" alt="Open Source">
|
||||
</p>
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ You will find the following categories:
|
||||
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://penpot.app/images/cross-teams.webp" alt="Community">
|
||||
<img src="https://penpot.app/images/readme/cross-teams.webp" alt="Community">
|
||||
</p>
|
||||
|
||||
## Contributing ##
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.11.1"}
|
||||
org.clojure/core.async {:mvn/version "1.5.648"}
|
||||
org.clojure/core.async {:mvn/version "1.6.673"}
|
||||
|
||||
;; Logging
|
||||
org.zeromq/jeromq {:mvn/version "0.5.2"}
|
||||
org.zeromq/jeromq {:mvn/version "0.5.3"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.2-4"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.2-5"}
|
||||
org.clojure/data.fressian {:mvn/version "1.0.0"}
|
||||
|
||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||
@@ -18,18 +18,18 @@
|
||||
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "6.2.1.RELEASE"}
|
||||
io.lettuce/lettuce-core {:mvn/version "6.2.2.RELEASE"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v9.11"
|
||||
:git/sha "6f9197a"
|
||||
{:git/tag "v9.12"
|
||||
:git/sha "51646d8"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.834"}
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.847"}
|
||||
metosin/reitit-core {:mvn/version "0.5.18"}
|
||||
org.postgresql/postgresql {:mvn/version "42.5.0"}
|
||||
org.postgresql/postgresql {:mvn/version "42.5.1"}
|
||||
com.zaxxer/HikariCP {:mvn/version "5.0.1"}
|
||||
|
||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||
@@ -37,9 +37,9 @@
|
||||
buddy/buddy-hashers {:mvn/version "1.8.158"}
|
||||
buddy/buddy-sign {:mvn/version "3.4.333"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.1"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.2"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.15.1"}
|
||||
org.jsoup/jsoup {:mvn/version "1.15.3"}
|
||||
org.im4java/im4java
|
||||
{:git/tag "1.4.0-penpot-2"
|
||||
:git/sha "e2b3e16"
|
||||
@@ -51,11 +51,12 @@
|
||||
integrant/integrant {:mvn/version "0.8.0"}
|
||||
|
||||
dawran6/emoji {:mvn/version "0.1.5"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.11.3"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.11.4"}
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.17.278"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.19.8"}
|
||||
}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
@@ -69,8 +70,10 @@
|
||||
mockery/mockery {:mvn/version "RELEASE"}}
|
||||
:extra-paths ["test" "dev"]}
|
||||
|
||||
|
||||
:build
|
||||
{:extra-deps {io.github.clojure/tools.build {:git/tag "v0.8.3" :git/sha "0d20256"}}
|
||||
{:extra-deps
|
||||
{io.github.clojure/tools.build {:git/tag "v0.9.0" :git/sha "8c93e0c"}}
|
||||
:ns-default build}
|
||||
|
||||
:test
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -129,9 +129,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -143,7 +143,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -157,9 +157,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -211,9 +211,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -225,7 +225,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -239,9 +239,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:425px;"
|
||||
>
|
||||
@@ -257,9 +257,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -271,7 +271,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -285,9 +285,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -301,7 +301,7 @@
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
>
|
||||
<tr>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -321,7 +321,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -341,7 +341,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -361,7 +361,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -370,7 +370,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://instagram.com/penpotapp/" target="_blank">
|
||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -381,7 +381,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -390,7 +390,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
|
||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -401,7 +401,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
@@ -411,9 +411,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -425,7 +425,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -439,9 +439,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 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;"
|
||||
>
|
||||
@@ -457,9 +457,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -129,9 +129,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -143,7 +143,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -157,9 +157,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -201,9 +201,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -215,7 +215,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -229,9 +229,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:425px;"
|
||||
>
|
||||
@@ -247,9 +247,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -261,7 +261,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -275,9 +275,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -291,7 +291,7 @@
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
>
|
||||
<tr>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -311,7 +311,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -331,7 +331,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -351,7 +351,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -360,7 +360,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://instagram.com/penpotapp/" target="_blank">
|
||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -371,7 +371,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -380,7 +380,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
|
||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -391,7 +391,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
@@ -401,9 +401,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -415,7 +415,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -429,9 +429,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 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;"
|
||||
>
|
||||
@@ -447,9 +447,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -129,9 +129,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -143,7 +143,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -157,9 +157,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -206,9 +206,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -220,7 +220,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -234,9 +234,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:425px;"
|
||||
>
|
||||
@@ -252,9 +252,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -266,7 +266,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -280,9 +280,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -296,7 +296,7 @@
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
>
|
||||
<tr>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -316,7 +316,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -336,7 +336,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -356,7 +356,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -365,7 +365,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://instagram.com/penpotapp/" target="_blank">
|
||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -376,7 +376,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -385,7 +385,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
|
||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -396,7 +396,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
@@ -406,9 +406,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -420,7 +420,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -434,9 +434,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 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;"
|
||||
>
|
||||
@@ -452,9 +452,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -129,9 +129,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -143,7 +143,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -157,9 +157,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -201,9 +201,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -215,7 +215,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -229,9 +229,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:425px;"
|
||||
>
|
||||
@@ -247,9 +247,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -261,7 +261,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -275,9 +275,9 @@
|
||||
<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;"
|
||||
>
|
||||
@@ -291,7 +291,7 @@
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
>
|
||||
<tr>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -311,7 +311,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -331,7 +331,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -351,7 +351,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -360,7 +360,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://instagram.com/penpotapp/" target="_blank">
|
||||
<a href="https://www.instagram.com/penpot.app/" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -371,7 +371,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
@@ -380,7 +380,7 @@
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
|
||||
<a href="https://tree.taiga.io/project/penpot" target="_blank">
|
||||
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
|
||||
</a>
|
||||
</td>
|
||||
@@ -391,7 +391,7 @@
|
||||
</table>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
@@ -401,9 +401,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
@@ -415,7 +415,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
@@ -429,9 +429,9 @@
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 24px 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;"
|
||||
>
|
||||
@@ -447,9 +447,9 @@
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
|
||||
@@ -6,14 +6,21 @@
|
||||
<div class="tags">
|
||||
{% if item.deprecated %}
|
||||
<span class="tag">
|
||||
<span>Deprecated:</span>
|
||||
<span>since v{{item.deprecated}}</span>,
|
||||
<span>DEPRECATED</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if item.auth %}
|
||||
<span class="tag">
|
||||
<span>AUTH</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if item.webhook %}
|
||||
<span class="tag">
|
||||
<span>WEBHOOK</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="tag">
|
||||
<span>Auth:</span>
|
||||
<span>{% if item.auth %}YES{% else %}NO{% endif %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpc-row-detail hidden">
|
||||
|
||||
@@ -12,10 +12,11 @@ cp ../CHANGES.md target/classes/changelog.md;
|
||||
|
||||
clojure -T:build jar;
|
||||
mv target/penpot.jar target/dist/penpot.jar
|
||||
cp resources/log4j2.xml target/dist/log4j2.xml
|
||||
cp scripts/run.template.sh target/dist/run.sh;
|
||||
cp scripts/manage.template.sh target/dist/manage.sh;
|
||||
cp scripts/manage.py target/dist/manage.py
|
||||
chmod +x target/dist/run.sh;
|
||||
chmod +x target/dist/manage.sh;
|
||||
chmod +x target/dist/manage.py
|
||||
|
||||
# Prefetch
|
||||
bb ./scripts/prefetch-templates.clj resources/app/onboarding.edn builtin-templates/
|
||||
|
||||
167
backend/scripts/manage.py
Executable file
167
backend/scripts/manage.py
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from getpass import getpass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
PREPL_URI = "tcp://localhost:6063"
|
||||
|
||||
def get_prepl_conninfo():
|
||||
uri_data = urlparse(PREPL_URI)
|
||||
if uri_data.scheme != "tcp":
|
||||
raise RuntimeError(f"invalid PREPL_URI: {PREPL_URI}")
|
||||
|
||||
if not isinstance(uri_data.netloc, str):
|
||||
raise RuntimeError(f"invalid PREPL_URI: {PREPL_URI}")
|
||||
|
||||
host, port = uri_data.netloc.split(":", 2)
|
||||
|
||||
if port is None:
|
||||
port = 6063
|
||||
|
||||
if isinstance(port, str):
|
||||
port = int(port)
|
||||
|
||||
return host, port
|
||||
|
||||
def send_eval(expr):
|
||||
host, port = get_prepl_conninfo()
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
s.send(expr.encode("utf-8"))
|
||||
s.send(b":repl/quit\n\n")
|
||||
|
||||
with s.makefile() as f:
|
||||
result = json.load(f)
|
||||
tag = result.get("tag", None)
|
||||
if tag != "ret":
|
||||
raise RuntimeError("unexpected response from PREPL")
|
||||
return result.get("val", None), result.get("exception", None)
|
||||
|
||||
def encode(val):
|
||||
return json.dumps(json.dumps(val))
|
||||
|
||||
def print_error(res):
|
||||
for error in res["via"]:
|
||||
print("ERR:", error["message"])
|
||||
break
|
||||
|
||||
def run_cmd(params):
|
||||
expr = "(app.srepl.ext/run-json-cmd {})".format(encode(params))
|
||||
res, failed = send_eval(expr)
|
||||
if failed:
|
||||
print_error(res)
|
||||
sys.exit(-1)
|
||||
|
||||
return res
|
||||
|
||||
def create_profile(fullname, email, password):
|
||||
params = {
|
||||
"cmd": "create-profile",
|
||||
"params": {
|
||||
"fullname": fullname,
|
||||
"email": email,
|
||||
"password": password
|
||||
}
|
||||
}
|
||||
|
||||
res = run_cmd(params)
|
||||
print(f"Created: {res['email']} / {res['id']}")
|
||||
|
||||
def update_profile(email, fullname, password, is_active):
|
||||
params = {
|
||||
"cmd": "update-profile",
|
||||
"params": {
|
||||
"email": email,
|
||||
"fullname": fullname,
|
||||
"password": password,
|
||||
"is_active": is_active
|
||||
}
|
||||
}
|
||||
|
||||
res = run_cmd(params)
|
||||
if res is True:
|
||||
print(f"Updated")
|
||||
else:
|
||||
print(f"No profile found with email {email}")
|
||||
|
||||
def derive_password(password):
|
||||
params = {
|
||||
"cmd": "derive-password",
|
||||
"params": {
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
res = run_cmd(params)
|
||||
print(f"Derived password: \"{res}\"")
|
||||
|
||||
available_commands = [
|
||||
"create-profile",
|
||||
"update-profile",
|
||||
"derive-password"
|
||||
]
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Penpot Command Line Interface (CLI)"
|
||||
)
|
||||
)
|
||||
|
||||
parser.add_argument("-V", "--version", action="version", version="Penpot CLI %%develop%%")
|
||||
parser.add_argument("action", action="store", choices=available_commands)
|
||||
parser.add_argument("-n", "--fullname", help="Fullname", action="store")
|
||||
parser.add_argument("-e", "--email", help="Email", action="store")
|
||||
parser.add_argument("-p", "--password", help="Password", action="store")
|
||||
parser.add_argument("-c", "--connect", help="Connect to PREPL", action="store", default="tcp://localhost:6063")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
PREPL_URI = args.connect
|
||||
|
||||
if args.action == "create-profile":
|
||||
email = args.email
|
||||
password = args.password
|
||||
fullname = args.fullname
|
||||
|
||||
if email is None:
|
||||
email = input("Email: ")
|
||||
|
||||
if fullname is None:
|
||||
fullname = input("Fullname: ")
|
||||
|
||||
if password is None:
|
||||
password = getpass("Password: ")
|
||||
|
||||
create_profile(fullname, email, password)
|
||||
|
||||
elif args.action == "update-profile":
|
||||
email = args.email
|
||||
password = args.password
|
||||
|
||||
if email is None:
|
||||
email = input("Email: ")
|
||||
|
||||
if password is None:
|
||||
password = getpass("Password: ")
|
||||
|
||||
update_profile(email, None, password, None)
|
||||
|
||||
elif args.action == "derive-password":
|
||||
password = args.password
|
||||
|
||||
if password is None:
|
||||
password = getpass("Password: ")
|
||||
|
||||
derive_password(password)
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
JAVA_CMD=$(type -p java)
|
||||
|
||||
set -e
|
||||
if [[ ! -n "$JAVA_CMD" ]]; then
|
||||
if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
|
||||
JAVA_CMD="$JAVA_HOME/bin/java"
|
||||
else
|
||||
>&2 echo "Couldn't find 'java'. Please set JAVA_HOME."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f ./environ ]; then
|
||||
source ./environ
|
||||
fi
|
||||
|
||||
exec $JAVA_CMD $JVM_OPTS -jar penpot.jar -m app.cli.manage "$@"
|
||||
@@ -2,7 +2,21 @@
|
||||
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_TENANT=dev
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp"
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-backend-asserts \
|
||||
enable-audit-log \
|
||||
enable-transit-readable-response \
|
||||
enable-demo-users \
|
||||
disable-secure-session-cookies \
|
||||
enable-smtp \
|
||||
enable-prepl-server \
|
||||
enable-urepl-server \
|
||||
enable-rpc-climit \
|
||||
enable-rpc-rlimit \
|
||||
enable-soft-rpc-rlimit \
|
||||
enable-webhooks \
|
||||
enable-access-tokens";
|
||||
|
||||
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot"
|
||||
# export PENPOT_DATABASE_USERNAME="penpot"
|
||||
|
||||
@@ -18,5 +18,7 @@ if [ -f ./environ ]; then
|
||||
source ./environ
|
||||
fi
|
||||
|
||||
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow $JVM_OPTS"
|
||||
|
||||
set -x
|
||||
exec $JAVA_CMD $JVM_OPTS "$@" -jar penpot.jar -m app.main
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_TENANT=dev
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp"
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp enable-webhooks"
|
||||
|
||||
set -ex
|
||||
|
||||
|
||||
26
backend/src/app/auth.clj
Normal file
26
backend/src/app/auth.clj
Normal file
@@ -0,0 +1,26 @@
|
||||
;; 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.auth
|
||||
(:require
|
||||
[buddy.hashers :as hashers]))
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password
|
||||
{:alg :argon2id
|
||||
:memory 16384
|
||||
:iterations 20
|
||||
:parallelism 2}))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
(try
|
||||
(hashers/verify attempt password)
|
||||
(catch Throwable _
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
@@ -41,15 +41,18 @@
|
||||
(reduce-kv clojure.string/replace s replacements))
|
||||
|
||||
(defn- search-user
|
||||
[{:keys [conn attrs base-dn] :as cfg} email]
|
||||
(let [query (replace-several (:query cfg) ":username" email)
|
||||
[{:keys [::conn base-dn] :as cfg} email]
|
||||
(let [query (replace-several (:query cfg) ":username" email)
|
||||
attrs [(:attrs-username cfg)
|
||||
(:attrs-email cfg)
|
||||
(:attrs-fullname cfg)]
|
||||
params {:filter query
|
||||
:sizelimit 1
|
||||
:attributes attrs}]
|
||||
(first (ldap/search conn base-dn params))))
|
||||
|
||||
(defn- retrieve-user
|
||||
[{:keys [conn] :as cfg} {:keys [email password]}]
|
||||
[{:keys [::conn] :as cfg} {:keys [email password]}]
|
||||
(when-let [{:keys [dn] :as user} (search-user cfg email)]
|
||||
(when (ldap/bind? conn dn password)
|
||||
{:fullname (get user (-> cfg :attrs-fullname keyword))
|
||||
@@ -66,7 +69,7 @@
|
||||
(defn authenticate
|
||||
[cfg params]
|
||||
(with-open [conn (connect cfg)]
|
||||
(when-let [user (-> (assoc cfg :conn conn)
|
||||
(when-let [user (-> (assoc cfg ::conn conn)
|
||||
(retrieve-user params))]
|
||||
(when-not (s/valid? ::info-data user)
|
||||
(let [explain (s/explain-str ::info-data user)]
|
||||
@@ -100,17 +103,6 @@
|
||||
:host (:host cfg) :port (:port cfg) :cause cause)
|
||||
nil))))
|
||||
|
||||
(defn- prepare-attributes
|
||||
[cfg]
|
||||
(assoc cfg :attrs [(:attrs-username cfg)
|
||||
(:attrs-email cfg)
|
||||
(:attrs-fullname cfg)]))
|
||||
|
||||
(defmethod ig/init-key ::provider
|
||||
[_ cfg]
|
||||
(when (:enabled? cfg)
|
||||
(some-> cfg try-connectivity prepare-attributes)))
|
||||
|
||||
(s/def ::enabled? ::us/boolean)
|
||||
(s/def ::host ::cf/ldap-host)
|
||||
(s/def ::port ::cf/ldap-port)
|
||||
@@ -124,8 +116,7 @@
|
||||
(s/def ::attrs-fullname ::cf/ldap-attrs-fullname)
|
||||
(s/def ::attrs-username ::cf/ldap-attrs-username)
|
||||
|
||||
(defmethod ig/pre-init-spec ::provider
|
||||
[_]
|
||||
(s/def ::provider-params
|
||||
(s/keys :opt-un [::host ::port
|
||||
::ssl ::tls
|
||||
::enabled?
|
||||
@@ -135,3 +126,14 @@
|
||||
::attrs-email
|
||||
::attrs-username
|
||||
::attrs-fullname]))
|
||||
(s/def ::provider
|
||||
(s/nilable ::provider-params))
|
||||
|
||||
(defmethod ig/pre-init-spec ::provider
|
||||
[_]
|
||||
(s/spec ::provider))
|
||||
|
||||
(defmethod ig/init-key ::provider
|
||||
[_ cfg]
|
||||
(when (:enabled? cfg)
|
||||
(try-connectivity cfg)))
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [::base-uri] :as opts}]
|
||||
[cfg {:keys [base-uri] :as opts}]
|
||||
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
|
||||
response (ex/try! (http/req! cfg
|
||||
{:method :get :uri (str discovery-uri)}
|
||||
@@ -64,10 +64,17 @@
|
||||
nil)
|
||||
|
||||
(= 200 (:status response))
|
||||
(let [data (json/decode (:body response))]
|
||||
{:token-uri (get data :token_endpoint)
|
||||
:auth-uri (get data :authorization_endpoint)
|
||||
:user-uri (get data :userinfo_endpoint)})
|
||||
(let [data (json/decode (:body response))
|
||||
token-uri (get data :token_endpoint)
|
||||
auth-uri (get data :authorization_endpoint)
|
||||
user-uri (get data :userinfo_endpoint)]
|
||||
(l/debug :hint "oidc uris discovered"
|
||||
:token-uri token-uri
|
||||
:auth-uri auth-uri
|
||||
:user-uri user-uri)
|
||||
{:token-uri token-uri
|
||||
:auth-uri auth-uri
|
||||
:user-uri user-uri})
|
||||
|
||||
:else
|
||||
(do
|
||||
@@ -110,7 +117,7 @@
|
||||
(if-let [opts (prepare-oidc-opts cfg)]
|
||||
(do
|
||||
(l/info :hint "provider initialized"
|
||||
:provider :oidc
|
||||
:provider "oidc"
|
||||
:method (if (:discover? opts) "discover" "manual")
|
||||
:client-id (:client-id opts)
|
||||
:client-secret (obfuscate-string (:client-secret opts))
|
||||
@@ -122,7 +129,7 @@
|
||||
:roles (:roles opts))
|
||||
opts)
|
||||
(do
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :oidc)
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "oidc")
|
||||
nil))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -144,13 +151,13 @@
|
||||
(string? (:client-secret opts)))
|
||||
(do
|
||||
(l/info :hint "provider initialized"
|
||||
:provider :google
|
||||
:provider "google"
|
||||
:client-id (:client-id opts)
|
||||
:client-secret (obfuscate-string (:client-secret opts)))
|
||||
opts)
|
||||
|
||||
(do
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :google)
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "google")
|
||||
nil)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -196,13 +203,13 @@
|
||||
(string? (:client-secret opts)))
|
||||
(do
|
||||
(l/info :hint "provider initialized"
|
||||
:provider :github
|
||||
:provider "github"
|
||||
:client-id (:client-id opts)
|
||||
:client-secret (obfuscate-string (:client-secret opts)))
|
||||
opts)
|
||||
|
||||
(do
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :github)
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "github")
|
||||
nil)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -225,14 +232,14 @@
|
||||
(string? (:client-secret opts)))
|
||||
(do
|
||||
(l/info :hint "provider initialized"
|
||||
:provider :gitlab
|
||||
:provider "gitlab"
|
||||
:base-uri base
|
||||
:client-id (:client-id opts)
|
||||
:client-secret (obfuscate-string (:client-secret opts)))
|
||||
opts)
|
||||
|
||||
(do
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :gitlab)
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "gitlab")
|
||||
nil)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -275,8 +282,19 @@
|
||||
"accept" "application/json"}
|
||||
:uri (:token-uri provider)
|
||||
:body (u/map->query-string params)}]
|
||||
|
||||
(l/trace :hint "request access token"
|
||||
:provider (:name provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (obfuscate-string (:client-secret provider))
|
||||
:grant-type (:grant_type params)
|
||||
:redirect-uri (:redirect_uri params))
|
||||
|
||||
(->> (http/req! cfg req)
|
||||
(p/map (fn [{:keys [status body] :as res}]
|
||||
(l/trace :hint "access token response"
|
||||
:status status
|
||||
:body body)
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)]
|
||||
{:token (get data :access_token)
|
||||
@@ -289,12 +307,19 @@
|
||||
(defn- retrieve-user-info
|
||||
[{:keys [provider] :as cfg} tdata]
|
||||
(letfn [(retrieve []
|
||||
(l/trace :hint "request user info"
|
||||
:uri (:user-uri provider)
|
||||
:token (obfuscate-string (:token tdata))
|
||||
:token-type (:type tdata))
|
||||
(http/req! cfg
|
||||
{:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get}))
|
||||
(validate-response [response]
|
||||
(l/trace :hint "user info response"
|
||||
:status (:status response)
|
||||
:body (:body response))
|
||||
(when-not (s/int-in-range? 200 300 (:status response))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
@@ -309,7 +334,7 @@
|
||||
(if-let [get-email-fn (:get-email-fn provider)]
|
||||
(get-email-fn tdata info)
|
||||
(let [attr-kw (cf/get :oidc-email-attr :email)]
|
||||
(get info attr-kw))))
|
||||
(p/resolved (get info attr-kw)))))
|
||||
|
||||
(get-name [info]
|
||||
(let [attr-kw (cf/get :oidc-name-attr :name)]
|
||||
@@ -325,6 +350,7 @@
|
||||
(qualify-props provider))}))
|
||||
|
||||
(validate-info [info]
|
||||
(l/trace :hint "authentication info" :info info)
|
||||
(when-not (s/valid? ::info info)
|
||||
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
|
||||
:info (pr-str info))
|
||||
@@ -334,10 +360,10 @@
|
||||
:info info))
|
||||
info)]
|
||||
|
||||
(-> (retrieve)
|
||||
(p/then validate-response)
|
||||
(p/then process-response)
|
||||
(p/then validate-info))))
|
||||
(->> (retrieve)
|
||||
(p/fmap validate-response)
|
||||
(p/mcat process-response)
|
||||
(p/fmap validate-info))))
|
||||
|
||||
(s/def ::backend ::us/not-empty-string)
|
||||
(s/def ::email ::us/not-empty-string)
|
||||
@@ -434,12 +460,11 @@
|
||||
(ex/raise :type :restriction
|
||||
:code :profile-blocked))
|
||||
|
||||
(when-let [collector (::audit/collector cfg)]
|
||||
(audit/submit! collector {:type "command"
|
||||
:name "login"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (audit/parse-client-ip request)
|
||||
:props (audit/profile->props profile)}))
|
||||
(audit/submit! cfg {:type "command"
|
||||
:name "login-with-password"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (audit/parse-client-ip request)
|
||||
:props (audit/profile->props profile)})
|
||||
|
||||
(->> (redirect-response uri)
|
||||
(sxf request)))
|
||||
|
||||
@@ -61,11 +61,9 @@
|
||||
|
||||
:public-uri "http://localhost:3449"
|
||||
:host "localhost"
|
||||
:tenant "main"
|
||||
:tenant "default"
|
||||
|
||||
:redis-uri "redis://redis/0"
|
||||
:srepl-host "127.0.0.1"
|
||||
:srepl-port 6062
|
||||
|
||||
:assets-storage-backend :assets-fs
|
||||
:storage-assets-fs-directory "assets"
|
||||
@@ -102,7 +100,7 @@
|
||||
(s/def ::audit-log-archive-uri ::us/string)
|
||||
(s/def ::audit-log-http-handler-concurrency ::us/integer)
|
||||
|
||||
(s/def ::admins ::us/set-of-strings)
|
||||
(s/def ::admins ::us/set-of-valid-emails)
|
||||
(s/def ::file-change-snapshot-every ::us/integer)
|
||||
(s/def ::file-change-snapshot-timeout ::dt/duration)
|
||||
|
||||
@@ -127,6 +125,16 @@
|
||||
(s/def ::database-min-pool-size ::us/integer)
|
||||
(s/def ::database-max-pool-size ::us/integer)
|
||||
|
||||
(s/def ::quotes-teams-per-profile ::us/integer)
|
||||
(s/def ::quotes-projects-per-team ::us/integer)
|
||||
(s/def ::quotes-invitations-per-team ::us/integer)
|
||||
(s/def ::quotes-profiles-per-team ::us/integer)
|
||||
(s/def ::quotes-files-per-project ::us/integer)
|
||||
(s/def ::quotes-files-per-team ::us/integer)
|
||||
(s/def ::quotes-font-variants-per-team ::us/integer)
|
||||
(s/def ::quotes-comment-threads-per-file ::us/integer)
|
||||
(s/def ::quotes-comments-per-file ::us/integer)
|
||||
|
||||
(s/def ::default-blob-version ::us/integer)
|
||||
(s/def ::error-report-webhook ::us/string)
|
||||
(s/def ::user-feedback-destination ::us/string)
|
||||
@@ -186,18 +194,15 @@
|
||||
(s/def ::smtp-ssl ::us/boolean)
|
||||
(s/def ::smtp-tls ::us/boolean)
|
||||
(s/def ::smtp-username (s/nilable ::us/string))
|
||||
(s/def ::srepl-host ::us/string)
|
||||
(s/def ::srepl-port ::us/integer)
|
||||
(s/def ::urepl-host ::us/string)
|
||||
(s/def ::urepl-port ::us/integer)
|
||||
(s/def ::prepl-host ::us/string)
|
||||
(s/def ::prepl-port ::us/integer)
|
||||
(s/def ::assets-storage-backend ::us/keyword)
|
||||
(s/def ::fdata-storage-backend ::us/keyword)
|
||||
(s/def ::storage-assets-fs-directory ::us/string)
|
||||
(s/def ::storage-assets-s3-bucket ::us/string)
|
||||
(s/def ::storage-assets-s3-region ::us/keyword)
|
||||
(s/def ::storage-assets-s3-endpoint ::us/string)
|
||||
(s/def ::storage-fdata-s3-bucket ::us/string)
|
||||
(s/def ::storage-fdata-s3-region ::us/keyword)
|
||||
(s/def ::storage-fdata-s3-prefix ::us/string)
|
||||
(s/def ::storage-fdata-s3-endpoint ::us/string)
|
||||
(s/def ::telemetry-uri ::us/string)
|
||||
(s/def ::telemetry-with-taiga ::us/boolean)
|
||||
(s/def ::tenant ::us/string)
|
||||
@@ -274,6 +279,17 @@
|
||||
::profile-complaint-max-age
|
||||
::profile-complaint-threshold
|
||||
::public-uri
|
||||
|
||||
::quotes-teams-per-profile
|
||||
::quotes-projects-per-team
|
||||
::quotes-invitations-per-team
|
||||
::quotes-profiles-per-team
|
||||
::quotes-files-per-project
|
||||
::quotes-files-per-team
|
||||
::quotes-font-variants-per-team
|
||||
::quotes-comment-threads-per-file
|
||||
::quotes-comments-per-file
|
||||
|
||||
::redis-uri
|
||||
::registration-domain-whitelist
|
||||
::rpc-rlimit-config
|
||||
@@ -292,19 +308,16 @@
|
||||
::smtp-tls
|
||||
::smtp-username
|
||||
|
||||
::srepl-host
|
||||
::srepl-port
|
||||
::urepl-host
|
||||
::urepl-port
|
||||
::prepl-host
|
||||
::prepl-port
|
||||
|
||||
::assets-storage-backend
|
||||
::storage-assets-fs-directory
|
||||
::storage-assets-s3-bucket
|
||||
::storage-assets-s3-region
|
||||
::storage-assets-s3-endpoint
|
||||
::fdata-storage-backend
|
||||
::storage-fdata-s3-bucket
|
||||
::storage-fdata-s3-region
|
||||
::storage-fdata-s3-prefix
|
||||
::storage-fdata-s3-endpoint
|
||||
::telemetry-enabled
|
||||
::telemetry-uri
|
||||
::telemetry-referer
|
||||
|
||||
@@ -167,6 +167,11 @@
|
||||
(instance? javax.sql.DataSource v))
|
||||
|
||||
(s/def ::pool pool?)
|
||||
(s/def ::conn some?)
|
||||
|
||||
;; DEPRECATED: to be removed in 1.18
|
||||
(s/def ::conn-or-pool some?)
|
||||
(s/def ::pool-or-conn some?)
|
||||
|
||||
(defn closed?
|
||||
[pool]
|
||||
|
||||
@@ -257,15 +257,17 @@
|
||||
"Schedule an already defined email to be sent using asynchronously
|
||||
using worker task."
|
||||
[{:keys [::conn ::factory] :as context}]
|
||||
(us/verify fn? factory)
|
||||
(us/verify some? conn)
|
||||
(let [email (factory context)]
|
||||
(wrk/submit! (assoc email
|
||||
::wrk/task :sendmail
|
||||
::wrk/delay 0
|
||||
::wrk/max-retries 4
|
||||
::wrk/priority 200
|
||||
::wrk/conn conn))))
|
||||
(let [email (if factory
|
||||
(factory context)
|
||||
(dissoc context ::conn))]
|
||||
(wrk/submit! (merge
|
||||
{::wrk/task :sendmail
|
||||
::wrk/delay 0
|
||||
::wrk/max-retries 4
|
||||
::wrk/priority 200
|
||||
::wrk/conn conn}
|
||||
email))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SENDMAIL FN / TASK HANDLER
|
||||
|
||||
@@ -91,9 +91,7 @@
|
||||
(let [params (:path-params match)
|
||||
result (:result match)
|
||||
handler (or (:handler result) not-found-handler)
|
||||
request (-> request
|
||||
(assoc :path-params params)
|
||||
(update :params merge params))]
|
||||
request (assoc request :path-params params)]
|
||||
(handler request respond raise))
|
||||
(not-found-handler request respond raise)))
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http])
|
||||
[java-http-clj.core :as http]
|
||||
[promesa.core :as p])
|
||||
(:import
|
||||
java.net.http.HttpClient))
|
||||
|
||||
@@ -34,7 +35,10 @@
|
||||
(us/assert! ::client client)
|
||||
(if sync?
|
||||
(http/send req {:client client :as response-type})
|
||||
(http/send-async req {:client client :as response-type}))))
|
||||
(try
|
||||
(http/send-async req {:client client :as response-type})
|
||||
(catch Throwable cause
|
||||
(p/rejected cause))))))
|
||||
|
||||
(defn req!
|
||||
"A convencience toplevel function for gradual migration to a new API
|
||||
|
||||
@@ -78,13 +78,12 @@
|
||||
(raise cause)))]
|
||||
|
||||
(fn [request respond raise]
|
||||
(when-let [request (try
|
||||
(process-request request)
|
||||
(catch RuntimeException cause
|
||||
(handle-error raise (or (.getCause cause) cause)))
|
||||
(catch Throwable cause
|
||||
(handle-error raise cause)))]
|
||||
(handler request respond raise)))))
|
||||
(let [request (ex/try! (process-request request))]
|
||||
(if (ex/exception? request)
|
||||
(if (instance? RuntimeException request)
|
||||
(handle-error raise (or (ex/cause request) request))
|
||||
(handle-error raise request))
|
||||
(handler request respond raise))))))
|
||||
|
||||
(def parse-request
|
||||
{:name ::parse-request
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.main :as-alias main]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
@@ -56,13 +57,13 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- prepare-session-params
|
||||
[sprops data]
|
||||
[props data]
|
||||
(let [profile-id (:profile-id data)
|
||||
user-agent (:user-agent data)
|
||||
created-at (or (:created-at data) (dt/now))
|
||||
token (tokens/generate sprops {:iss "authentication"
|
||||
:iat created-at
|
||||
:uid profile-id})]
|
||||
token (tokens/generate props {:iss "authentication"
|
||||
:iat created-at
|
||||
:uid profile-id})]
|
||||
{:user-agent user-agent
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
@@ -70,7 +71,7 @@
|
||||
:id token}))
|
||||
|
||||
(defn- database-manager
|
||||
[{:keys [pool sprops executor]}]
|
||||
[{:keys [::db/pool ::wrk/executor ::main/props]}]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
(px/with-dispatch executor
|
||||
@@ -78,11 +79,11 @@
|
||||
|
||||
(decode [_ token]
|
||||
(px/with-dispatch executor
|
||||
(tokens/verify sprops {:token token :iss "authentication"})))
|
||||
(tokens/verify props {:token token :iss "authentication"})))
|
||||
|
||||
(write! [_ _ data]
|
||||
(px/with-dispatch executor
|
||||
(let [params (prepare-session-params sprops data)]
|
||||
(let [params (prepare-session-params props data)]
|
||||
(db/insert! pool :http-session params)
|
||||
params)))
|
||||
|
||||
@@ -100,7 +101,7 @@
|
||||
nil))))
|
||||
|
||||
(defn inmemory-manager
|
||||
[{:keys [sprops executor]}]
|
||||
[{:keys [::wrk/executor ::main/props]}]
|
||||
(let [cache (atom {})]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
@@ -108,11 +109,11 @@
|
||||
|
||||
(decode [_ token]
|
||||
(px/with-dispatch executor
|
||||
(tokens/verify sprops {:token token :iss "authentication"})))
|
||||
(tokens/verify props {:token token :iss "authentication"})))
|
||||
|
||||
(write! [_ _ data]
|
||||
(p/do
|
||||
(let [{:keys [token] :as params} (prepare-session-params sprops data)]
|
||||
(let [{:keys [token] :as params} (prepare-session-params props data)]
|
||||
(swap! cache assoc token params)
|
||||
params)))
|
||||
|
||||
@@ -127,12 +128,11 @@
|
||||
(swap! cache dissoc token)
|
||||
nil)))))
|
||||
|
||||
(s/def ::sprops map?)
|
||||
(defmethod ig/pre-init-spec ::manager [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor ::sprops]))
|
||||
(s/keys :req [::db/pool ::wrk/executor ::main/props]))
|
||||
|
||||
(defmethod ig/init-key ::manager
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
[_ {:keys [::db/pool] :as cfg}]
|
||||
(if (db/read-only? pool)
|
||||
(inmemory-manager cfg)
|
||||
(database-manager cfg)))
|
||||
@@ -178,18 +178,19 @@
|
||||
(clear-authenticated-cookie))))))
|
||||
|
||||
(def middleware-1
|
||||
(letfn [(wrap-handler [manager handler request respond raise]
|
||||
(try
|
||||
(let [claims (some->> (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
(yrq/get-cookie request)
|
||||
(decode manager))
|
||||
request (cond-> request
|
||||
(some? claims)
|
||||
(assoc :session-token-claims claims))]
|
||||
(handler request respond raise))
|
||||
(catch Throwable _
|
||||
(handler request respond raise))))]
|
||||
(letfn [(decode-cookie [manager cookie]
|
||||
(if-let [value (:value cookie)]
|
||||
(decode manager value)
|
||||
(p/resolved nil)))
|
||||
|
||||
(wrap-handler [manager handler request respond raise]
|
||||
(let [cookie (some->> (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
(yrq/get-cookie request))]
|
||||
(->> (decode-cookie manager cookie)
|
||||
(p/fnly (fn [claims _]
|
||||
(cond-> request
|
||||
(some? claims) (assoc :session-token-claims claims)
|
||||
:always (handler respond raise)))))))]
|
||||
{:name :session-1
|
||||
:compile (fn [& _]
|
||||
(fn [handler manager]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"Services related to the user activity (audit log)."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
@@ -19,15 +20,15 @@
|
||||
[app.loggers.audit.tasks :as-alias tasks]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.main :as-alias main]
|
||||
[app.metrics :as mtx]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.retry :as rtry]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[lambdaisland.uri :as u]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]))
|
||||
|
||||
@@ -74,28 +75,20 @@
|
||||
(merge (:props profile))
|
||||
(d/without-nils)))
|
||||
|
||||
(defn clean-props
|
||||
[{:keys [profile-id] :as event}]
|
||||
(let [invalid-keys #{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token}
|
||||
xform (comp
|
||||
(remove (fn [kv]
|
||||
(qualified-keyword? (first kv))))
|
||||
(remove (fn [kv]
|
||||
(contains? invalid-keys (first kv))))
|
||||
(remove (fn [[k v]]
|
||||
(and (= k :profile-id)
|
||||
(= v profile-id))))
|
||||
(filter (fn [[_ v]]
|
||||
(or (string? v)
|
||||
(keyword? v)
|
||||
(uuid? v)
|
||||
(boolean? v)
|
||||
(number? v)))))]
|
||||
(def reserved-props
|
||||
#{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token})
|
||||
|
||||
(update event :props #(into {} xform %))))
|
||||
(defn clean-props
|
||||
[props]
|
||||
(into {}
|
||||
(comp
|
||||
(d/without-nils)
|
||||
(d/without-qualified)
|
||||
(remove #(contains? reserved-props (key %))))
|
||||
props))
|
||||
|
||||
;; --- SPECS
|
||||
|
||||
@@ -129,7 +122,7 @@
|
||||
(s/keys :req [::wrk/executor ::db/pool]))
|
||||
|
||||
(defmethod ig/pre-init-spec ::collector [_]
|
||||
(s/keys :req [::db/pool ::wrk/executor ::mtx/metrics]))
|
||||
(s/keys :req [::db/pool ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::collector
|
||||
[_ {:keys [::db/pool] :as cfg}]
|
||||
@@ -140,51 +133,67 @@
|
||||
:else
|
||||
cfg))
|
||||
|
||||
(defn- persist-event!
|
||||
[pool event]
|
||||
(defn- handle-event!
|
||||
[conn-or-pool event]
|
||||
(us/verify! ::event event)
|
||||
(let [params {:id (uuid/next)
|
||||
:name (:name event)
|
||||
:type (:type event)
|
||||
:profile-id (:profile-id event)
|
||||
:tracked-at (dt/now)
|
||||
:ip-addr (:ip-addr event)
|
||||
:props (:props event)}]
|
||||
|
||||
(when (contains? cf/flags :audit-log)
|
||||
(db/insert! pool :audit-log
|
||||
(-> params
|
||||
(update :props db/tjson)
|
||||
(update :ip-addr db/inet)
|
||||
(assoc :source "backend"))))
|
||||
;; NOTE: this operation may cause primary key conflicts on inserts
|
||||
;; because of the timestamp precission (two concurrent requests), in
|
||||
;; this case we just retry the operation.
|
||||
(rtry/with-retry {::rtry/when rtry/conflict-exception?
|
||||
::rtry/max-retries 6
|
||||
::rtry/label "persist-audit-log-event"}
|
||||
(let [now (dt/now)]
|
||||
(db/insert! conn-or-pool :audit-log
|
||||
(-> params
|
||||
(update :props db/tjson)
|
||||
(update :ip-addr db/inet)
|
||||
(assoc :created-at now)
|
||||
(assoc :tracked-at now)
|
||||
(assoc :source "backend"))))))
|
||||
|
||||
(when (and (contains? cf/flags :webhooks)
|
||||
(::webhooks/event? event))
|
||||
(let [batch-key (::webhooks/batch-key event)
|
||||
batch-timeout (::webhooks/batch-timeout event)]
|
||||
(wrk/submit! ::wrk/conn pool
|
||||
batch-timeout (::webhooks/batch-timeout event)
|
||||
label (dm/str "rpc:" (:name params))
|
||||
label (cond
|
||||
(ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event)))
|
||||
(string? batch-key) (dm/str label ":" batch-key)
|
||||
:else label)
|
||||
dedupe? (boolean (and batch-key batch-timeout))]
|
||||
|
||||
(wrk/submit! ::wrk/conn conn-or-pool
|
||||
::wrk/task :process-webhook-event
|
||||
::wrk/queue :webhooks
|
||||
::wrk/max-retries 0
|
||||
::wrk/delay (or batch-timeout 0)
|
||||
::wrk/label (cond
|
||||
(fn? batch-key) (batch-key (:props event))
|
||||
(keyword? batch-key) (name batch-key)
|
||||
(string? batch-key) batch-key
|
||||
:else "default")
|
||||
::wrk/dedupe true
|
||||
::webhooks/event (-> params
|
||||
(dissoc :ip-addr)
|
||||
(dissoc :type)))))))
|
||||
::wrk/dedupe dedupe?
|
||||
::wrk/label label
|
||||
|
||||
::webhooks/event
|
||||
(-> params
|
||||
(dissoc :ip-addr)
|
||||
(dissoc :type)))))
|
||||
params))
|
||||
|
||||
(defn submit!
|
||||
"Submit audit event to the collector."
|
||||
[{:keys [::wrk/executor ::db/pool] :as collector} params]
|
||||
(us/assert! ::collector collector)
|
||||
(->> (px/submit! executor (partial persist-event! pool (d/without-nils params)))
|
||||
(p/merr (fn [cause]
|
||||
(l/error :hint "audit: unexpected error processing event" :cause cause)
|
||||
(p/resolved nil)))))
|
||||
[{:keys [::wrk/executor] :as cfg} params]
|
||||
(let [conn (or (::db/conn cfg) (::db/pool cfg))]
|
||||
(us/assert! ::wrk/executor executor)
|
||||
(us/assert! ::db/pool-or-conn conn)
|
||||
(try
|
||||
(handle-event! conn (d/without-nils params))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "audit: unexpected error processing event" :cause cause)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TASK: ARCHIVE
|
||||
@@ -231,7 +240,7 @@
|
||||
from audit_log
|
||||
where archived_at is null
|
||||
order by created_at asc
|
||||
limit 256
|
||||
limit 128
|
||||
for update skip locked;")
|
||||
|
||||
(defn archive-events
|
||||
@@ -307,7 +316,7 @@
|
||||
where archived_at is not null")
|
||||
|
||||
(defn- clean-archived
|
||||
[{:keys [pool]}]
|
||||
[{:keys [::db/pool]}]
|
||||
(let [result (db/exec-one! pool [sql:clean-archived])
|
||||
result (:next.jdbc/update-count result)]
|
||||
(l/debug :hint "delete archived audit log entries" :deleted result)
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.util.async :as aa]
|
||||
[app.worker :as wrk]
|
||||
[app.loggers.zmq :as lzmq]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Error Listener
|
||||
@@ -27,7 +27,7 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[{:keys [pool] :as cfg} {:keys [id] :as event}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [id] :as event}]
|
||||
(when-not (db/read-only? pool)
|
||||
(db/insert! pool :server-error-report {:id id :content (db/tjson event)})))
|
||||
|
||||
@@ -53,41 +53,49 @@
|
||||
(assoc :version (:full cf/version))
|
||||
(update :id #(or % (uuid/next)))))
|
||||
|
||||
(defn handle-event
|
||||
[{:keys [executor] :as cfg} event]
|
||||
(aa/with-thread executor
|
||||
(try
|
||||
(let [event (parse-event event)
|
||||
uri (cf/get :public-uri)]
|
||||
(defn- handle-event
|
||||
[cfg event]
|
||||
(try
|
||||
(let [event (parse-event event)
|
||||
uri (cf/get :public-uri)]
|
||||
|
||||
(l/debug :hint "registering error on database" :id (:id event)
|
||||
:uri (str uri "/dbg/error/" (:id event)))
|
||||
(l/debug :hint "registering error on database" :id (:id event)
|
||||
:uri (str uri "/dbg/error/" (:id event)))
|
||||
|
||||
(persist-on-database! cfg event))
|
||||
(catch Exception cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause)))))
|
||||
(persist-on-database! cfg event))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req-un [::wrk/executor ::db/pool ::receiver]))
|
||||
|
||||
(defn error-event?
|
||||
(defn- error-event?
|
||||
[event]
|
||||
(= "error" (:logger/level event)))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req [::db/pool ::lzmq/receiver]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ {:keys [receiver] :as cfg}]
|
||||
(l/info :msg "initializing database error persistence")
|
||||
(let [output (a/chan (a/sliding-buffer 5) (filter error-event?))]
|
||||
(receiver :sub output)
|
||||
(a/go-loop []
|
||||
(let [msg (a/<! output)]
|
||||
(if (nil? msg)
|
||||
(l/info :msg "stopping error reporting loop")
|
||||
(do
|
||||
(a/<! (handle-event cfg msg))
|
||||
(recur)))))
|
||||
output))
|
||||
[_ {:keys [::lzmq/receiver] :as cfg}]
|
||||
(px/thread
|
||||
{:name "penpot/database-reporter"}
|
||||
(l/info :hint "initializing database error persistence")
|
||||
|
||||
(let [input (a/chan (a/sliding-buffer 5)
|
||||
(filter error-event?))]
|
||||
(try
|
||||
(lzmq/sub! receiver input)
|
||||
(loop []
|
||||
(when-let [msg (a/<!! input)]
|
||||
(handle-event cfg msg))
|
||||
(recur))
|
||||
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(a/close! input)
|
||||
(l/info :hint "reporter terminated"))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ output]
|
||||
(a/close! output))
|
||||
[_ thread]
|
||||
(some-> thread px/interrupt!))
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
|
||||
(defn handle-event
|
||||
[cfg event]
|
||||
(try
|
||||
(let [event (ldb/parse-event event)]
|
||||
(when @enabled
|
||||
(send-mattermost-notification! cfg event)))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error"
|
||||
:cause cause))))
|
||||
(when @enabled
|
||||
(try
|
||||
(let [event (ldb/parse-event event)]
|
||||
(send-mattermost-notification! cfg event))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error"
|
||||
:cause cause)))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req [::http/client
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uri :as uri]
|
||||
@@ -21,15 +22,24 @@
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
(defn key-fn
|
||||
[k & keys]
|
||||
(fn [params]
|
||||
(reduce #(dm/str %1 ":" (get params %2))
|
||||
(dm/str (get params k))
|
||||
keys)))
|
||||
|
||||
;; --- PROC
|
||||
|
||||
(defn- lookup-webhooks-by-team
|
||||
[pool team-id]
|
||||
(db/exec! pool ["select * from webhook where team_id=? and is_active=true" team-id]))
|
||||
(db/exec! pool ["select w.* from webhook as w where team_id=? and is_active=true" team-id]))
|
||||
|
||||
(defn- lookup-webhooks-by-project
|
||||
[pool project-id]
|
||||
(let [sql [(str "select * from webhook as w"
|
||||
(let [sql [(str "select w.* from webhook as w"
|
||||
" join project as p on (p.team_id = w.team_id)"
|
||||
" where p.id = ? and w.is_active = true")
|
||||
project-id]]
|
||||
@@ -37,7 +47,7 @@
|
||||
|
||||
(defn- lookup-webhooks-by-file
|
||||
[pool file-id]
|
||||
(let [sql [(str "select * from webhook as w"
|
||||
(let [sql [(str "select w.* from webhook as w"
|
||||
" join project as p on (p.team_id = w.team_id)"
|
||||
" join file as f on (f.project_id = p.id)"
|
||||
" where f.id = ? and w.is_active = true")
|
||||
@@ -62,7 +72,6 @@
|
||||
:name (:name event))
|
||||
|
||||
(when-let [items (lookup-webhooks cfg event)]
|
||||
;; (app.common.pprint/pprint items)
|
||||
(l/trace :hint "webhooks found for event" :total (count items))
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -169,6 +178,9 @@
|
||||
(instance? java.net.ConnectException cause)
|
||||
"connection-error"
|
||||
|
||||
(instance? java.lang.IllegalArgumentException cause)
|
||||
"invalid-uri"
|
||||
|
||||
(instance? java.net.http.HttpConnectTimeoutException cause)
|
||||
"timeout"
|
||||
))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.main
|
||||
(:require
|
||||
[app.auth.ldap :as-alias ldap]
|
||||
[app.auth.oidc :as-alias oidc]
|
||||
[app.auth.oidc.providers :as-alias oidc.providers]
|
||||
[app.common.logging :as l]
|
||||
@@ -13,13 +14,13 @@
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.session :as-alias http.session]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.audit.tasks :as-alias audit.tasks]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.loggers.zmq :as-alias lzmq]
|
||||
[app.metrics :as-alias mtx]
|
||||
[app.metrics.definition :as-alias mdef]
|
||||
[app.redis :as-alias rds]
|
||||
[app.srepl :as-alias srepl]
|
||||
[app.storage :as-alias sto]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
@@ -207,9 +208,9 @@
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.http.session/manager
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:sprops (ig/ref :app.setup/props)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::props (ig/ref :app.setup/props)}
|
||||
|
||||
:app.http.session/gc-task
|
||||
{:pool (ig/ref ::db/pool)
|
||||
@@ -231,7 +232,7 @@
|
||||
:max-body-size (cf/get :http-server-max-body-size)
|
||||
:max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
|
||||
|
||||
:app.auth.ldap/provider
|
||||
::ldap/provider
|
||||
{:host (cf/get :ldap-host)
|
||||
:port (cf/get :ldap-port)
|
||||
:ssl (cf/get :ldap-ssl)
|
||||
@@ -266,10 +267,8 @@
|
||||
:github (ig/ref ::oidc.providers/github)
|
||||
:gitlab (ig/ref ::oidc.providers/gitlab)
|
||||
:oidc (ig/ref ::oidc.providers/generic)}
|
||||
::audit/collector (ig/ref ::audit/collector)
|
||||
::http.session/session (ig/ref :app.http.session/manager)}
|
||||
|
||||
|
||||
;; TODO: revisit the dependencies of this service, looks they are too much unused of them
|
||||
:app.http/router
|
||||
{:assets (ig/ref :app.http.assets/handlers)
|
||||
@@ -290,7 +289,11 @@
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:session (ig/ref :app.http.session/manager)}
|
||||
:session (ig/ref :app.http.session/manager)
|
||||
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
:app.http.websocket/handler
|
||||
{:pool (ig/ref ::db/pool)
|
||||
@@ -318,11 +321,11 @@
|
||||
:scheduled-executor (ig/ref ::wrk/scheduled-executor)}
|
||||
|
||||
:app.rpc/methods
|
||||
{::audit/collector (ig/ref ::audit/collector)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
|
||||
::props (ig/ref :app.setup/props)
|
||||
::ldap/provider (ig/ref ::ldap/provider)
|
||||
:pool (ig/ref ::db/pool)
|
||||
:session (ig/ref :app.http.session/manager)
|
||||
:sprops (ig/ref :app.setup/props)
|
||||
@@ -331,7 +334,6 @@
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:redis (ig/ref ::rds/redis)
|
||||
:ldap (ig/ref :app.auth.ldap/provider)
|
||||
:http-client (ig/ref ::http.client/client)
|
||||
:climit (ig/ref :app.rpc/climit)
|
||||
:rlimit (ig/ref :app.rpc/rlimit)
|
||||
@@ -385,8 +387,8 @@
|
||||
:max-age cf/deletion-delay}
|
||||
|
||||
:app.tasks.objects-gc/handler
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:storage (ig/ref ::sto/storage)}
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
:app.tasks.file-gc/handler
|
||||
{:pool (ig/ref ::db/pool)}
|
||||
@@ -399,9 +401,13 @@
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::props (ig/ref :app.setup/props)}
|
||||
|
||||
:app.srepl/server
|
||||
{:port (cf/get :srepl-port)
|
||||
:host (cf/get :srepl-host)}
|
||||
[::srepl/urepl ::srepl/server]
|
||||
{:port (cf/get :urepl-port 6062)
|
||||
:host (cf/get :urepl-host "localhost")}
|
||||
|
||||
[::srepl/prepl ::srepl/server]
|
||||
{:port (cf/get :prepl-port 6063)
|
||||
:host (cf/get :prepl-host "localhost")}
|
||||
|
||||
:app.setup/builtin-templates
|
||||
{::http.client/client (ig/ref ::http.client/client)}
|
||||
@@ -413,11 +419,6 @@
|
||||
::lzmq/receiver
|
||||
{}
|
||||
|
||||
::audit/collector
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
::audit.tasks/archive
|
||||
{::props (ig/ref :app.setup/props)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
@@ -443,9 +444,8 @@
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
:app.loggers.database/reporter
|
||||
{:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:pool (ig/ref ::db/pool)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::lzmq/receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::sto/storage
|
||||
{:pool (ig/ref ::db/pool)
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
(defmethod run-collector! :counter
|
||||
[{:keys [::mdef/instance]} {:keys [inc labels] :or {inc 1 labels default-empty-labels}}]
|
||||
(let [instance (.labels instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(let [instance (.labels ^Counter instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(.inc ^Counter$Child instance (double inc))))
|
||||
|
||||
(defmethod run-collector! :gauge
|
||||
|
||||
@@ -271,7 +271,38 @@
|
||||
|
||||
{:name "0087-mod-task-table"
|
||||
:fn (mg/resource "app/migrations/sql/0087-mod-task-table.sql")}
|
||||
])
|
||||
|
||||
{:name "0088-mod-team-profile-rel-table"
|
||||
:fn (mg/resource "app/migrations/sql/0088-mod-team-profile-rel-table.sql")}
|
||||
|
||||
{:name "0089-mod-project-profile-rel-table"
|
||||
:fn (mg/resource "app/migrations/sql/0089-mod-project-profile-rel-table.sql")}
|
||||
|
||||
{:name "0090-mod-http-session-table"
|
||||
:fn (mg/resource "app/migrations/sql/0090-mod-http-session-table.sql")}
|
||||
|
||||
{:name "0091-mod-team-project-profile-rel-table"
|
||||
:fn (mg/resource "app/migrations/sql/0091-mod-team-project-profile-rel-table.sql")}
|
||||
|
||||
{:name "0092-mod-team-invitation-table"
|
||||
:fn (mg/resource "app/migrations/sql/0092-mod-team-invitation-table.sql")}
|
||||
|
||||
{:name "0093-del-file-share-tokens-table"
|
||||
:fn (mg/resource "app/migrations/sql/0093-del-file-share-tokens-table.sql")}
|
||||
|
||||
{:name "0094-del-profile-attr-table"
|
||||
:fn (mg/resource "app/migrations/sql/0094-del-profile-attr-table.sql")}
|
||||
|
||||
{:name "0095-del-storage-data-table"
|
||||
:fn (mg/resource "app/migrations/sql/0095-del-storage-data-table.sql")}
|
||||
|
||||
{:name "0096-del-storage-pending-table"
|
||||
:fn (mg/resource "app/migrations/sql/0096-del-storage-pending-table.sql")}
|
||||
|
||||
{:name "0098-add-quotes-table"
|
||||
:fn (mg/resource "app/migrations/sql/0098-add-quotes-table.sql")}
|
||||
|
||||
])
|
||||
|
||||
|
||||
(defmethod ig/init-key ::migrations [_ _] migrations)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE team_profile_rel DROP CONSTRAINT team_profile_rel_pkey;
|
||||
ALTER TABLE team_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
|
||||
ALTER TABLE team_profile_rel ADD CONSTRAINT team_profile_rel_unique UNIQUE (team_id, profile_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE project_profile_rel DROP CONSTRAINT project_profile_rel_pkey;
|
||||
ALTER TABLE project_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
|
||||
ALTER TABLE project_profile_rel ADD CONSTRAINT project_profile_rel_unique UNIQUE (project_id, profile_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE http_session DROP CONSTRAINT http_session_pkey;
|
||||
ALTER TABLE http_session ADD CONSTRAINT http_session_pkey PRIMARY KEY (id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE team_project_profile_rel DROP CONSTRAINT team_project_profile_rel_pkey;
|
||||
ALTER TABLE team_project_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
|
||||
ALTER TABLE team_project_profile_rel ADD CONSTRAINT team_project_profile_rel_unique UNIQUE (team_id, project_id, profile_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE team_invitation DROP CONSTRAINT team_invitation_pkey;
|
||||
ALTER TABLE team_invitation ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
|
||||
ALTER TABLE team_invitation ADD CONSTRAINT team_invitation_unique UNIQUE (team_id, email_to);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE file_share_token;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE profile_attr;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE storage_data;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE storage_pending;
|
||||
82
backend/src/app/migrations/sql/0098-add-quotes-table.sql
Normal file
82
backend/src/app/migrations/sql/0098-add-quotes-table.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
CREATE TABLE usage_quote (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
target text NOT NULL,
|
||||
quote bigint NOT NULL,
|
||||
|
||||
profile_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
|
||||
project_id uuid NULL REFERENCES project(id) ON DELETE CASCADE DEFERRABLE,
|
||||
team_id uuid NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
|
||||
file_id uuid NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE
|
||||
);
|
||||
|
||||
ALTER TABLE usage_quote
|
||||
ALTER COLUMN target SET STORAGE external;
|
||||
|
||||
CREATE INDEX usage_quote__profile_id__idx ON usage_quote(profile_id, target);
|
||||
CREATE INDEX usage_quote__project_id__idx ON usage_quote(project_id, target);
|
||||
CREATE INDEX usage_quote__team_id__idx ON usage_quote(team_id, target);
|
||||
|
||||
-- DROP TABLE IF EXISTS usage_quote_test;
|
||||
-- CREATE TABLE usage_quote_test (
|
||||
-- id bigserial NOT NULL PRIMARY KEY,
|
||||
-- target text NOT NULL,
|
||||
-- quote bigint NOT NULL,
|
||||
|
||||
-- profile_id bigint NULL,
|
||||
-- team_id bigint NULL,
|
||||
-- project_id bigint NULL,
|
||||
-- file_id bigint NULL
|
||||
-- );
|
||||
|
||||
-- ALTER TABLE usage_quote_test
|
||||
-- ALTER COLUMN target SET STORAGE external;
|
||||
|
||||
-- CREATE INDEX usage_quote_test__profile_id__idx ON usage_quote_test(profile_id, target);
|
||||
-- CREATE INDEX usage_quote_test__project_id__idx ON usage_quote_test(project_id, target);
|
||||
-- CREATE INDEX usage_quote_test__team_id__idx ON usage_quote_test(team_id, target);
|
||||
-- -- CREATE INDEX usage_quote_test__target__idx ON usage_quote_test(target);
|
||||
|
||||
-- DELETE FROM usage_quote_test;
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 50*RANDOM(), 2000*RANDOM(), null, null
|
||||
-- FROM generate_series(1, 5000);
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 200*RANDOM(), 300*RANDOM(), 300*RANDOM(), null
|
||||
-- FROM generate_series(1, 1000);
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), null, 300*RANDOM()
|
||||
-- FROM generate_series(1, 1000);
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), 300*RANDOM(), 300*RANDOM()
|
||||
-- FROM generate_series(1, 1000);
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 30*RANDOM(), null, 2000*RANDOM(), null
|
||||
-- FROM generate_series(1, 5000);
|
||||
|
||||
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
|
||||
-- SELECT 'files-per-project', 10*RANDOM(), null, null, 2000*RANDOM()
|
||||
-- FROM generate_series(1, 5000);
|
||||
|
||||
-- VACUUM ANALYZE usage_quote_test;
|
||||
|
||||
-- select * from usage_quote_test
|
||||
-- where target = 'files-per-project'
|
||||
-- and profile_id = 1
|
||||
-- and team_id is null
|
||||
-- and project_id is null;
|
||||
|
||||
-- select * from usage_quote_test
|
||||
-- where target = 'files-per-project'
|
||||
-- and ((team_id = 1 and (profile_id = 1 or profile_id is null)) or
|
||||
-- (profile_id = 1 and team_id is null and project_id is null));
|
||||
|
||||
-- select * from usage_quote_test
|
||||
-- where target = 'files-per-project'
|
||||
-- and ((project_id = 1 and (profile_id = 1 or profile_id is null)) or
|
||||
-- (team_id = 1 and (profile_id = 1 or profile_id is null)) or
|
||||
-- (profile_id = 1 and team_id is null and project_id is null));
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
(ns app.rpc
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.auth.ldap :as-alias ldap]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.client :as-alias http.client]
|
||||
@@ -26,7 +27,7 @@
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage :as-alias sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as ts]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
@@ -35,6 +36,8 @@
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
|
||||
(defn- default-handler
|
||||
[_]
|
||||
(p/rejected (ex/error :type :not-found)))
|
||||
@@ -68,12 +71,17 @@
|
||||
(defn- rpc-query-handler
|
||||
"Ring handler that dispatches query requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id params] :as request} respond raise]
|
||||
(let [type (keyword (:type params))
|
||||
data (into {::http/request request} params)
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [type (keyword (:type path-params))
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request))
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id ::session-id session-id)
|
||||
(dissoc data :profile-id))
|
||||
(-> data
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))
|
||||
(dissoc data :profile-id ::profile-id))
|
||||
method (get methods type default-handler)]
|
||||
|
||||
(-> (method data)
|
||||
@@ -86,13 +94,17 @@
|
||||
(defn- rpc-mutation-handler
|
||||
"Ring handler that dispatches mutation requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id params] :as request} respond raise]
|
||||
(let [type (keyword (:type params))
|
||||
data (into {::http/request request} params)
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [type (keyword (:type path-params))
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request))
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id ::session-id session-id)
|
||||
(dissoc data :profile-id))
|
||||
|
||||
(-> data
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))
|
||||
(dissoc data :profile-id ::profile-id))
|
||||
method (get methods type default-handler)]
|
||||
(-> (method data)
|
||||
(p/then (partial handle-response request))
|
||||
@@ -104,13 +116,17 @@
|
||||
(defn- rpc-command-handler
|
||||
"Ring handler that dispatches cmd requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id params] :as request} respond raise]
|
||||
(let [cmd (keyword (:command params))
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [cmd (keyword (:type path-params))
|
||||
etag (yrq/get-header request "if-none-match")
|
||||
data (into {::http/request request ::cond/key etag} params)
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id ::session-id session-id)
|
||||
(dissoc data :profile-id))
|
||||
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request)
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(-> (assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))))
|
||||
|
||||
method (get methods cmd default-handler)]
|
||||
(binding [cond/*enabled* true]
|
||||
@@ -126,7 +142,7 @@
|
||||
[{:keys [metrics ::metrics-id]} f mdata]
|
||||
(let [labels (into-array String [(::sv/name mdata)])]
|
||||
(fn [cfg params]
|
||||
(let [tp (ts/tpoint)]
|
||||
(let [tp (dt/tpoint)]
|
||||
(p/finally
|
||||
(f cfg params)
|
||||
(fn [_ _]
|
||||
@@ -148,21 +164,25 @@
|
||||
|
||||
(defn- wrap-audit
|
||||
[cfg f mdata]
|
||||
(if-let [collector (::audit/collector cfg)]
|
||||
(if (or (contains? cf/flags :webhooks)
|
||||
(contains? cf/flags :audit-log))
|
||||
(letfn [(handle-audit [params result]
|
||||
(let [resultm (meta result)
|
||||
request (::http/request params)
|
||||
|
||||
profile-id (or (::audit/profile-id resultm)
|
||||
(:profile-id result)
|
||||
(:profile-id params)
|
||||
(if (= (::type cfg) "command")
|
||||
(::profile-id params)
|
||||
(:profile-id params))
|
||||
uuid/zero)
|
||||
|
||||
props (or (::audit/replace-props resultm)
|
||||
(-> params
|
||||
(d/without-qualified)
|
||||
(merge (::audit/props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
props (-> (or (::audit/replace-props resultm)
|
||||
(-> params
|
||||
(merge (::audit/props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
(audit/clean-props))
|
||||
|
||||
event {:type (or (::audit/type resultm)
|
||||
(::type cfg))
|
||||
@@ -171,6 +191,12 @@
|
||||
:profile-id profile-id
|
||||
:ip-addr (some-> request audit/parse-client-ip)
|
||||
:props props
|
||||
|
||||
;; NOTE: for batch-key lookup we need the params as-is
|
||||
;; because the rpc api does not need to know the
|
||||
;; audit/webhook specific object layout.
|
||||
::params (dissoc params ::http/request)
|
||||
|
||||
::webhooks/batch-key
|
||||
(or (::webhooks/batch-key mdata)
|
||||
(::webhooks/batch-key resultm))
|
||||
@@ -184,13 +210,14 @@
|
||||
(::webhooks/event? resultm)
|
||||
false)}]
|
||||
|
||||
(audit/submit! collector event)))
|
||||
(audit/submit! cfg event)))
|
||||
|
||||
(handle-request [cfg params]
|
||||
(->> (f cfg params)
|
||||
(p/mcat (fn [result]
|
||||
(->> (handle-audit params result)
|
||||
(p/map (constantly result)))))))]
|
||||
(p/fnly (fn [result cause]
|
||||
(when-not cause
|
||||
(handle-audit params result))))))]
|
||||
|
||||
(if-not (::audit/skip mdata)
|
||||
(with-meta handle-request mdata)
|
||||
f))
|
||||
@@ -208,21 +235,24 @@
|
||||
(wrap-audit cfg $ mdata))
|
||||
|
||||
spec (or (::sv/spec mdata) (s/spec any?))
|
||||
auth? (:auth mdata true)]
|
||||
auth? (::auth mdata true)]
|
||||
|
||||
|
||||
(l/debug :hint "register method" :name (::sv/name mdata))
|
||||
(with-meta
|
||||
(fn [{:keys [::request] :as params}]
|
||||
(fn [params]
|
||||
;; Raise authentication error when rpc method requires auth but
|
||||
;; no profile-id is found in the request.
|
||||
|
||||
(p/do!
|
||||
(if (and auth? (not (uuid? (:profile-id params))))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(let [params (us/conform spec (dissoc params ::request))]
|
||||
(f cfg (assoc params ::request request))))))
|
||||
(let [profile-id (if (= "command" (::type cfg))
|
||||
(::profile-id params)
|
||||
(:profile-id params))]
|
||||
(p/do!
|
||||
(if (and auth? (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(let [params (us/conform spec params)]
|
||||
(f cfg params))))))
|
||||
mdata)))
|
||||
|
||||
(defn- process-method
|
||||
@@ -237,7 +267,6 @@
|
||||
(->> (sv/scan-ns 'app.rpc.queries.projects
|
||||
'app.rpc.queries.files
|
||||
'app.rpc.queries.teams
|
||||
'app.rpc.queries.comments
|
||||
'app.rpc.queries.profile
|
||||
'app.rpc.queries.viewer
|
||||
'app.rpc.queries.fonts)
|
||||
@@ -250,13 +279,10 @@
|
||||
(->> (sv/scan-ns 'app.rpc.mutations.media
|
||||
'app.rpc.mutations.profile
|
||||
'app.rpc.mutations.files
|
||||
'app.rpc.mutations.comments
|
||||
'app.rpc.mutations.projects
|
||||
'app.rpc.mutations.teams
|
||||
'app.rpc.mutations.management
|
||||
'app.rpc.mutations.fonts
|
||||
'app.rpc.mutations.share-link
|
||||
'app.rpc.mutations.verify-token)
|
||||
'app.rpc.mutations.share-link)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
|
||||
@@ -268,6 +294,8 @@
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.search
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.teams
|
||||
'app.rpc.commands.auth
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.demo
|
||||
@@ -289,9 +317,9 @@
|
||||
(s/def ::sprops map?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::methods [_]
|
||||
(s/keys :req [::audit/collector
|
||||
::http.client/client
|
||||
(s/keys :req [::http.client/client
|
||||
::db/pool
|
||||
::ldap/provider
|
||||
::wrk/executor]
|
||||
:req-un [::sto/storage
|
||||
::http.session/session
|
||||
@@ -302,8 +330,7 @@
|
||||
::climit
|
||||
::wrk/executor
|
||||
::mtx/metrics
|
||||
::db/pool
|
||||
::ldap]))
|
||||
::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::methods
|
||||
[_ cfg]
|
||||
@@ -331,7 +358,7 @@
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [methods] :as cfg}]
|
||||
[["/rpc"
|
||||
["/command/:command" {:handler (partial rpc-command-handler (:commands methods))}]
|
||||
["/command/:type" {:handler (partial rpc-command-handler (:commands methods))}]
|
||||
["/query/:type" {:handler (partial rpc-query-handler (:queries methods))}]
|
||||
["/mutation/:type" {:handler (partial rpc-mutation-handler (:mutations methods))
|
||||
:allowed-methods #{:post}}]]])
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
@@ -41,7 +42,7 @@
|
||||
:profile-id :ip-addr :props :context])
|
||||
|
||||
(defn- handle-events
|
||||
[{:keys [::db/pool]} {:keys [profile-id events ::http/request] :as params}]
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id events ::http/request] :as params}]
|
||||
(let [ip-addr (audit/parse-client-ip request)
|
||||
xform (comp
|
||||
(map #(assoc % :profile-id profile-id))
|
||||
@@ -53,7 +54,6 @@
|
||||
(when (seq events)
|
||||
(db/insert-multi! pool :audit-log event-columns events))))
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::type ::us/string)
|
||||
(s/def ::props (s/map-of ::us/keyword any?))
|
||||
@@ -67,11 +67,12 @@
|
||||
(s/def ::events (s/every ::event))
|
||||
|
||||
(s/def ::push-audit-events
|
||||
(s/keys :req-un [::events ::profile-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::events]))
|
||||
|
||||
(sv/defmethod ::push-audit-events
|
||||
{::climit/queue :push-audit-events
|
||||
::climit/key-fn :profile-id
|
||||
::climit/key-fn ::rpc/profile-id
|
||||
::audit/skip true
|
||||
::doc/added "1.17"}
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} params]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.rpc.commands.auth
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
@@ -15,15 +16,16 @@
|
||||
[app.emails :as eml]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[buddy.hashers :as hashers]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@@ -31,7 +33,6 @@
|
||||
(s/def ::fullname ::us/not-empty-string)
|
||||
(s/def ::lang ::us/string)
|
||||
(s/def ::path ::us/string)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::password ::us/not-empty-string)
|
||||
(s/def ::old-password ::us/not-empty-string)
|
||||
(s/def ::theme ::us/string)
|
||||
@@ -40,22 +41,6 @@
|
||||
|
||||
;; ---- HELPERS
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password
|
||||
{:alg :argon2id
|
||||
:memory 16384
|
||||
:iterations 20
|
||||
:parallelism 2}))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
(try
|
||||
(hashers/verify attempt password)
|
||||
(catch Exception _e
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
(defn email-domain-in-whitelist?
|
||||
"Returns true if email's domain is in the given whitelist or if
|
||||
given whitelist is an empty string."
|
||||
@@ -84,9 +69,10 @@
|
||||
;; ---- COMMAND: login with password
|
||||
|
||||
(defn login-with-password
|
||||
[{:keys [pool session sprops] :as cfg} {:keys [email password] :as params}]
|
||||
[{:keys [::db/pool session] :as cfg} {:keys [email password] :as params}]
|
||||
|
||||
(when-not (contains? cf/flags :login)
|
||||
(when-not (or (contains? cf/flags :login)
|
||||
(contains? cf/flags :login-with-password))
|
||||
(ex/raise :type :restriction
|
||||
:code :login-disabled
|
||||
:hint "login is disabled in this instance"))
|
||||
@@ -96,7 +82,7 @@
|
||||
(ex/raise :type :validation
|
||||
:code :account-without-password
|
||||
:hint "the current account does not have password"))
|
||||
(:valid (verify-password password (:password profile))))
|
||||
(:valid (auth/verify-password password (:password profile))))
|
||||
|
||||
(validate-profile [profile]
|
||||
(when-not profile
|
||||
@@ -126,27 +112,28 @@
|
||||
(profile/decode-profile-row))
|
||||
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens/verify sprops {:token token :iss :team-invitation}))
|
||||
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
|
||||
|
||||
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
|
||||
;; invitation because invitations matches exactly; and user can't login with other email and
|
||||
;; accept invitation with other email
|
||||
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
|
||||
{:invitation-token (:invitation-token params)}
|
||||
profile)]
|
||||
|
||||
(assoc profile :is-admin (let [admins (cf/get :admins)]
|
||||
(contains? admins (:email profile)))))]
|
||||
(-> response
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-meta {::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))))
|
||||
|
||||
(s/def ::scope ::us/string)
|
||||
(s/def ::login-with-password
|
||||
(s/keys :req-un [::email ::password]
|
||||
:opt-un [::invitation-token]))
|
||||
:opt-un [::invitation-token ::scope]))
|
||||
|
||||
(sv/defmethod ::login-with-password
|
||||
"Performs authentication using penpot password."
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
[cfg params]
|
||||
@@ -155,11 +142,11 @@
|
||||
;; ---- COMMAND: Logout
|
||||
|
||||
(s/def ::logout
|
||||
(s/keys :opt-un [::profile-id]))
|
||||
(s/keys :opt [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::logout
|
||||
"Clears the authentication cookie and logout the current session."
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[{:keys [session] :as cfg} _]
|
||||
(rph/with-transform {} (session/delete-fn session)))
|
||||
@@ -167,13 +154,13 @@
|
||||
;; ---- COMMAND: Recover Profile
|
||||
|
||||
(defn recover-profile
|
||||
[{:keys [pool sprops] :as cfg} {:keys [token password]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [token password]}]
|
||||
(letfn [(validate-token [token]
|
||||
(let [tdata (tokens/verify sprops {:token token :iss :password-recovery})]
|
||||
(let [tdata (tokens/verify (::main/props cfg) {:token token :iss :password-recovery})]
|
||||
(:profile-id tdata)))
|
||||
|
||||
(update-password [conn profile-id]
|
||||
(let [pwd (derive-password password)]
|
||||
(let [pwd (auth/derive-password password)]
|
||||
(db/update! conn :profile {:password pwd} {:id profile-id})))]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -186,7 +173,7 @@
|
||||
(s/keys :req-un [::token ::password]))
|
||||
|
||||
(sv/defmethod ::recover-profile
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
[cfg params]
|
||||
@@ -195,13 +182,13 @@
|
||||
;; ---- COMMAND: Prepare Register
|
||||
|
||||
(defn validate-register-attempt!
|
||||
[{:keys [pool sprops]} params]
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
|
||||
(when-not (contains? cf/flags :registration)
|
||||
(if-not (contains? params :invitation-token)
|
||||
(ex/raise :type :restriction
|
||||
:code :registration-disabled)
|
||||
(let [invitation (tokens/verify sprops {:token (:invitation-token params) :iss :team-invitation})]
|
||||
(let [invitation (tokens/verify (::main/props cfg) {:token (:invitation-token params) :iss :team-invitation})]
|
||||
(when-not (= (:email params) (:member-email invitation))
|
||||
(ex/raise :type :restriction
|
||||
:code :email-does-not-match-invitation
|
||||
@@ -235,7 +222,7 @@
|
||||
(pos? (compare elapsed register-retry-threshold))))
|
||||
|
||||
(defn prepare-register
|
||||
[{:keys [pool sprops] :as cfg} params]
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
@@ -264,7 +251,7 @@
|
||||
|
||||
params (d/without-nils params)
|
||||
|
||||
token (tokens/generate sprops params)]
|
||||
token (tokens/generate (::main/props cfg) params)]
|
||||
(with-meta {:token token}
|
||||
{::audit/profile-id uuid/zero})))
|
||||
|
||||
@@ -273,7 +260,7 @@
|
||||
:opt-un [::invitation-token]))
|
||||
|
||||
(sv/defmethod ::prepare-register-profile
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[cfg params]
|
||||
(prepare-register cfg params))
|
||||
@@ -293,7 +280,7 @@
|
||||
(db/tjson))
|
||||
|
||||
password (if-let [password (:password params)]
|
||||
(derive-password password)
|
||||
(auth/derive-password password)
|
||||
"!")
|
||||
|
||||
locale (:locale params)
|
||||
@@ -326,6 +313,7 @@
|
||||
(throw e)
|
||||
(ex/raise :type :validation
|
||||
:code :email-already-exists
|
||||
:hint "email already exists"
|
||||
:cause e)))))))
|
||||
|
||||
(defn create-profile-relations
|
||||
@@ -339,15 +327,15 @@
|
||||
(assoc :default-project-id (:default-project-id team)))))
|
||||
|
||||
(defn send-email-verification!
|
||||
[conn sprops profile]
|
||||
(let [vtoken (tokens/generate sprops
|
||||
[conn props profile]
|
||||
(let [vtoken (tokens/generate props
|
||||
{:iss :verify-email
|
||||
:exp (dt/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)})
|
||||
;; NOTE: this token is mainly used for possible complains
|
||||
;; identification on the sns webhook
|
||||
ptoken (tokens/generate sprops
|
||||
ptoken (tokens/generate props
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (dt/in-future {:days 30})})]
|
||||
@@ -360,8 +348,8 @@
|
||||
:extra-data ptoken})))
|
||||
|
||||
(defn register-profile
|
||||
[{:keys [conn sprops session] :as cfg} {:keys [token] :as params}]
|
||||
(let [claims (tokens/verify sprops {:token token :iss :prepared-register})
|
||||
[{:keys [::db/conn session] :as cfg} {:keys [token] :as params}]
|
||||
(let [claims (tokens/verify (::main/props cfg) {:token token :iss :prepared-register})
|
||||
params (merge params claims)
|
||||
|
||||
is-active (or (:is-active params)
|
||||
@@ -377,18 +365,17 @@
|
||||
(create-profile-relations conn)
|
||||
(profile/decode-profile-row)))
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens/verify sprops {:token token :iss :team-invitation}))]
|
||||
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))]
|
||||
|
||||
;; If profile is filled in claims, means it tries to register
|
||||
;; again, so we proceed to update the modified-at attr
|
||||
;; accordingly.
|
||||
(when-let [id (:profile-id claims)]
|
||||
(db/update! conn :profile {:modified-at (dt/now)} {:id id})
|
||||
(when-let [collector (::audit/collector cfg)]
|
||||
(audit/submit! collector
|
||||
{:type "fact"
|
||||
:name "register-profile-retry"
|
||||
:profile-id id})))
|
||||
(audit/submit! cfg
|
||||
{:type "fact"
|
||||
:name "register-profile-retry"
|
||||
:profile-id id}))
|
||||
|
||||
(cond
|
||||
;; If invitation token comes in params, this is because the
|
||||
@@ -399,7 +386,7 @@
|
||||
;; email.
|
||||
(and (some? invitation) (= (:email profile) (:member-email invitation)))
|
||||
(let [claims (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate sprops claims)
|
||||
token (tokens/generate (::main/props cfg) claims)
|
||||
resp {:invitation-token token}]
|
||||
(-> resp
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
@@ -426,7 +413,7 @@
|
||||
;; In all other cases, send a verification email.
|
||||
:else
|
||||
(do
|
||||
(send-email-verification! conn sprops profile)
|
||||
(send-email-verification! conn (::main/props cfg) profile)
|
||||
(rph/with-meta profile
|
||||
{::audit/replace-props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})))))
|
||||
@@ -435,27 +422,27 @@
|
||||
(s/keys :req-un [::token ::fullname]))
|
||||
|
||||
(sv/defmethod ::register-profile
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(-> (assoc cfg :conn conn)
|
||||
(-> (assoc cfg ::db/conn conn)
|
||||
(register-profile params))))
|
||||
|
||||
;; ---- COMMAND: Request Profile Recovery
|
||||
|
||||
(defn request-profile-recovery
|
||||
[{:keys [pool sprops] :as cfg} {:keys [email] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
|
||||
(letfn [(create-recovery-token [{:keys [id] :as profile}]
|
||||
(let [token (tokens/generate sprops
|
||||
(let [token (tokens/generate (::main/props cfg)
|
||||
{:iss :password-recovery
|
||||
:exp (dt/in-future "15m")
|
||||
:profile-id id})]
|
||||
(assoc profile :token token)))
|
||||
|
||||
(send-email-notification [conn profile]
|
||||
(let [ptoken (tokens/generate sprops
|
||||
(let [ptoken (tokens/generate (::main/props cfg)
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (dt/in-future {:days 30})})]
|
||||
@@ -493,7 +480,7 @@
|
||||
(s/keys :req-un [::email]))
|
||||
|
||||
(sv/defmethod ::request-profile-recovery
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[cfg params]
|
||||
(request-profile-recovery cfg params))
|
||||
|
||||
@@ -9,21 +9,27 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.features :as ffeat]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pages.migrations :as pmg]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.projects :as projects]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.tasks.file-gc]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.fressian :as fres]
|
||||
[app.util.objects-map :as omap]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -290,7 +296,7 @@
|
||||
|
||||
(defn- retrieve-file
|
||||
[pool file-id]
|
||||
(with-open [conn (db/open pool)]
|
||||
(with-open [^AutoCloseable conn (db/open pool)]
|
||||
(binding [pmap/*load-fn* (partial files/load-pointer conn file-id)]
|
||||
(some-> (db/get* conn :file {:id file-id})
|
||||
(files/decode-row)
|
||||
@@ -605,12 +611,23 @@
|
||||
(vswap! *state* update :index update-index files)
|
||||
(vswap! *state* assoc :version version :files files)))
|
||||
|
||||
(defn- postprocess-file
|
||||
[data]
|
||||
(let [omap-wrap ffeat/*wrap-with-objects-map-fn*
|
||||
pmap-wrap ffeat/*wrap-with-pointer-map-fn*]
|
||||
(-> data
|
||||
(update :pages-index update-vals #(update % :objects omap-wrap))
|
||||
(update :pages-index update-vals pmap-wrap)
|
||||
(update :components update-vals #(update % :objects omap-wrap))
|
||||
(update :components pmap-wrap))))
|
||||
|
||||
(defmethod read-section :v1/files
|
||||
[{:keys [conn ::input ::migrate? ::project-id ::timestamp ::overwrite?]}]
|
||||
(doseq [expected-file-id (-> *state* deref :files)]
|
||||
(let [file (read-obj! input)
|
||||
media' (read-obj! input)
|
||||
file-id (:id file)]
|
||||
(let [file (read-obj! input)
|
||||
media' (read-obj! input)
|
||||
file-id (:id file)
|
||||
features files/default-features]
|
||||
|
||||
(when (not= file-id expected-file-id)
|
||||
(ex/raise :type :validation
|
||||
@@ -625,33 +642,42 @@
|
||||
(l/debug :hint "update media references" ::l/async false)
|
||||
(vswap! *state* update :media into (map #(update % :id lookup-index)) media')
|
||||
|
||||
(l/debug :hint "processing file" :file-id file-id ::l/async false)
|
||||
(l/debug :hint "processing file" :file-id file-id ::features features ::l/async false)
|
||||
|
||||
(let [file-id' (lookup-index file-id)
|
||||
data (-> (:data file)
|
||||
(assoc :id file-id')
|
||||
(cond-> migrate? (pmg/migrate-data))
|
||||
(update :pages-index relink-shapes)
|
||||
(update :components relink-shapes)
|
||||
(update :media relink-media))
|
||||
(binding [ffeat/*current* features
|
||||
ffeat/*wrap-with-objects-map-fn* (if (features "storage/objects-map") omap/wrap identity)
|
||||
ffeat/*wrap-with-pointer-map-fn* (if (features "storage/pointer-map") pmap/wrap identity)
|
||||
pmap/*tracked* (atom {})]
|
||||
|
||||
params {:id file-id'
|
||||
:project-id project-id
|
||||
:name (:name file)
|
||||
:revn (:revn file)
|
||||
:is-shared (:is-shared file)
|
||||
:data (blob/encode data)
|
||||
:created-at timestamp
|
||||
:modified-at timestamp}]
|
||||
(let [file-id' (lookup-index file-id)
|
||||
data (-> (:data file)
|
||||
(assoc :id file-id')
|
||||
(cond-> migrate? (pmg/migrate-data))
|
||||
(update :pages-index relink-shapes)
|
||||
(update :components relink-shapes)
|
||||
(update :media relink-media)
|
||||
(postprocess-file))
|
||||
|
||||
(l/debug :hint "create file" :id file-id' ::l/async false)
|
||||
params {:id file-id'
|
||||
:project-id project-id
|
||||
:features (db/create-array conn "text" features)
|
||||
:name (:name file)
|
||||
:revn (:revn file)
|
||||
:is-shared (:is-shared file)
|
||||
:data (blob/encode data)
|
||||
:created-at timestamp
|
||||
:modified-at timestamp}]
|
||||
|
||||
(if overwrite?
|
||||
(create-or-update-file conn params)
|
||||
(db/insert! conn :file params))
|
||||
(l/debug :hint "create file" :id file-id' ::l/async false)
|
||||
|
||||
(when overwrite?
|
||||
(db/delete! conn :file-thumbnail {:file-id file-id'}))))))
|
||||
(if overwrite?
|
||||
(create-or-update-file conn params)
|
||||
(db/insert! conn :file params))
|
||||
|
||||
(files/persist-pointers! conn file-id')
|
||||
|
||||
(when overwrite?
|
||||
(db/delete! conn :file-thumbnail {:file-id file-id'})))))))
|
||||
|
||||
(defmethod read-section :v1/rels
|
||||
[{:keys [conn ::input ::timestamp]}]
|
||||
@@ -840,10 +866,10 @@
|
||||
(defn import!
|
||||
[{:keys [::input] :as cfg}]
|
||||
(let [id (uuid/next)
|
||||
ts (dt/now)
|
||||
tp (dt/tpoint)
|
||||
cs (volatile! nil)]
|
||||
(l/info :hint "import: started" :import-id id)
|
||||
(try
|
||||
(l/info :hint "start importation" :import-id id)
|
||||
(binding [*position* (atom 0)]
|
||||
(with-open [^AutoCloseable input (io/input-stream input)]
|
||||
(read-import! (assoc cfg ::input input))))
|
||||
@@ -853,25 +879,27 @@
|
||||
(throw cause))
|
||||
|
||||
(finally
|
||||
(l/info :hint "importation finished" :import-id id
|
||||
:elapsed (str (inst-ms (dt/diff ts (dt/now))) "ms")
|
||||
(l/info :hint "import: terminated"
|
||||
:import-id id
|
||||
:elapsed (dt/format-duration (tp))
|
||||
:error? (some? @cs)
|
||||
:cause @cs)))))
|
||||
:cause @cs
|
||||
)))))
|
||||
|
||||
;; --- Command: export-binfile
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::include-libraries? ::us/boolean)
|
||||
(s/def ::embed-assets? ::us/boolean)
|
||||
|
||||
(s/def ::export-binfile
|
||||
(s/keys :req-un [::profile-id ::file-id ::include-libraries? ::embed-assets?]))
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::file-id ::include-libraries? ::embed-assets?]))
|
||||
|
||||
(sv/defmethod ::export-binfile
|
||||
"Export a penpot file in a binary format."
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id include-libraries? embed-assets?] :as params}]
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id include-libraries? embed-assets?] :as params}]
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [body (reify yrs/StreamableResponseBody
|
||||
(-write-body-to-stream [_ _ output-stream]
|
||||
@@ -886,15 +914,18 @@
|
||||
|
||||
(s/def ::file ::media/upload)
|
||||
(s/def ::import-binfile
|
||||
(s/keys :req-un [::profile-id ::project-id ::file]))
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::project-id ::file]))
|
||||
|
||||
(sv/defmethod ::import-binfile
|
||||
"Import a penpot file in a binary format."
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id project-id file] :as params}]
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id file] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(import! (assoc cfg
|
||||
::input (:path file)
|
||||
::project-id project-id
|
||||
::ignore-index-errors? true))))
|
||||
(let [ids (import! (assoc cfg
|
||||
::input (:path file)
|
||||
::project-id project-id
|
||||
::ignore-index-errors? true))]
|
||||
(rph/with-meta ids
|
||||
{::audit/props {:file nil :file-ids ids}}))))
|
||||
|
||||
@@ -6,23 +6,26 @@
|
||||
|
||||
(ns app.rpc.commands.comments
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.retry :as retry]
|
||||
[app.util.blob :as blob]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.retry :as rtry]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUERY COMMANDS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; --- GENERAL PURPOSE INTERNAL HELPERS
|
||||
|
||||
(defn decode-row
|
||||
[{:keys [participants position] :as row}]
|
||||
@@ -30,24 +33,77 @@
|
||||
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
|
||||
|
||||
(def sql:get-file
|
||||
"select f.id, f.modified_at, f.revn, f.features,
|
||||
f.project_id, p.team_id, f.data
|
||||
from file as f
|
||||
join project as p on (p.id = f.project_id)
|
||||
where f.id = ?
|
||||
and f.deleted_at is null")
|
||||
|
||||
(defn- get-file
|
||||
"A specialized version of get-file for comments module."
|
||||
[conn file-id page-id]
|
||||
(binding [pmap/*load-fn* (partial files/load-pointer conn file-id)]
|
||||
(if-let [{:keys [data] :as file} (some-> (db/exec-one! conn [sql:get-file file-id]) (files/decode-row))]
|
||||
(-> file
|
||||
(assoc :page-name (dm/get-in data [:pages-index page-id :name]))
|
||||
(assoc :page-id page-id))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "file not found"))))
|
||||
|
||||
(defn- get-comment-thread
|
||||
[conn thread-id & {:keys [for-update?]}]
|
||||
(-> (db/get-by-id conn :comment-thread thread-id {:for-update for-update?})
|
||||
(decode-row)))
|
||||
|
||||
(defn- get-comment
|
||||
[conn comment-id & {:keys [for-update?]}]
|
||||
(db/get-by-id conn :comment comment-id {:for-update for-update?}))
|
||||
|
||||
(defn- get-next-seqn
|
||||
[conn file-id]
|
||||
(let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
|
||||
res (db/exec-one! conn [sql file-id])]
|
||||
(:next-seqn res)))
|
||||
|
||||
(def sql:upsert-comment-thread-status
|
||||
"insert into comment_thread_status (thread_id, profile_id, modified_at)
|
||||
values (?, ?, ?)
|
||||
on conflict (thread_id, profile_id)
|
||||
do update set modified_at = ?
|
||||
returning modified_at;")
|
||||
|
||||
(defn upsert-comment-thread-status!
|
||||
([conn profile-id thread-id]
|
||||
(upsert-comment-thread-status! conn profile-id thread-id (dt/now)))
|
||||
([conn profile-id thread-id mod-at]
|
||||
(db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id mod-at mod-at])))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUERY COMMANDS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; --- COMMAND: Get Comment Threads
|
||||
|
||||
(declare retrieve-comment-threads)
|
||||
(declare ^:private get-comment-threads)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
|
||||
(s/def ::get-comment-threads
|
||||
(s/and (s/keys :req-un [::profile-id]
|
||||
(s/and (s/keys :req [::rpc/profile-id]
|
||||
:opt-un [::file-id ::share-id ::team-id])
|
||||
#(or (:file-id %) (:team-id %))))
|
||||
|
||||
(sv/defmethod ::get-comment-threads
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(retrieve-comment-threads conn params)))
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-comment-threads conn profile-id file-id)))
|
||||
|
||||
(def sql:comment-threads
|
||||
"select distinct on (ct.id)
|
||||
@@ -71,25 +127,26 @@
|
||||
where ct.file_id = ?
|
||||
window w as (partition by c.thread_id order by c.created_at asc)")
|
||||
|
||||
(defn retrieve-comment-threads
|
||||
[conn {:keys [profile-id file-id share-id]}]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(defn- get-comment-threads
|
||||
[conn profile-id file-id]
|
||||
(->> (db/exec! conn [sql:comment-threads profile-id file-id])
|
||||
(into [] (map decode-row))))
|
||||
|
||||
;; --- COMMAND: Get Unread Comment Threads
|
||||
|
||||
(declare retrieve-unread-comment-threads)
|
||||
(declare ^:private get-unread-comment-threads)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::get-unread-comment-threads
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(sv/defmethod ::get-unread-comment-threads
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-unread-comment-threads conn params)))
|
||||
(get-unread-comment-threads conn profile-id team-id)))
|
||||
|
||||
(def sql:comment-threads-by-team
|
||||
"select distinct on (ct.id)
|
||||
@@ -118,22 +175,22 @@
|
||||
(str "with threads as (" sql:comment-threads-by-team ")"
|
||||
"select * from threads where count_unread_comments > 0"))
|
||||
|
||||
(defn retrieve-unread-comment-threads
|
||||
[conn {:keys [profile-id team-id]}]
|
||||
(defn- get-unread-comment-threads
|
||||
[conn profile-id team-id]
|
||||
(->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
|
||||
(into [] (map decode-row))))
|
||||
|
||||
|
||||
;; --- COMMAND: Get Single Comment Thread
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
(s/def ::get-comment-thread
|
||||
(s/keys :req-un [::profile-id ::file-id ::id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::us/id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::get-comment-thread
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id id share-id] :as params}]
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(let [sql (str "with threads as (" sql:comment-threads ")"
|
||||
@@ -141,37 +198,30 @@
|
||||
(-> (db/exec-one! conn [sql profile-id file-id id])
|
||||
(decode-row)))))
|
||||
|
||||
(defn get-comment-thread
|
||||
[conn {:keys [profile-id file-id id] :as params}]
|
||||
(let [sql (str "with threads as (" sql:comment-threads ")"
|
||||
"select * from threads where id = ?")]
|
||||
(-> (db/exec-one! conn [sql profile-id file-id id])
|
||||
(decode-row))))
|
||||
|
||||
;; --- COMMAND: Retrieve Comments
|
||||
|
||||
(declare get-comments)
|
||||
(declare ^:private get-comments)
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
(s/def ::thread-id ::us/uuid)
|
||||
(s/def ::get-comments
|
||||
(s/keys :req-un [::profile-id ::thread-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::thread-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::get-comments
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id thread-id share-id] :as params}]
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [thread (db/get-by-id conn :comment-thread thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id))
|
||||
(get-comments conn thread-id)))
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-comments conn thread-id))))
|
||||
|
||||
(def sql:comments
|
||||
"select c.* from comment as c
|
||||
where c.thread_id = ?
|
||||
order by c.created_at asc")
|
||||
|
||||
(defn get-comments
|
||||
(defn- get-comments
|
||||
[conn thread-id]
|
||||
(->> (db/query conn :comment
|
||||
{:thread-id thread-id}
|
||||
@@ -180,25 +230,6 @@
|
||||
|
||||
;; --- COMMAND: Get file comments users
|
||||
|
||||
(declare get-file-comments-users)
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
|
||||
(s/def ::get-profiles-for-file-comments
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::get-profiles-for-file-comments
|
||||
"Retrieves a list of profiles with limited set of properties of all
|
||||
participants on comment threads of the file."
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.15" "Imported from queries and renamed."]}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-file-comments-users conn file-id profile-id)))
|
||||
|
||||
;; All the profiles that had comment the file, plus the current
|
||||
;; profile.
|
||||
|
||||
@@ -221,87 +252,113 @@
|
||||
[conn file-id profile-id]
|
||||
(db/exec! conn [sql:file-comment-users file-id profile-id]))
|
||||
|
||||
(s/def ::get-profiles-for-file-comments
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::get-profiles-for-file-comments
|
||||
"Retrieves a list of profiles with limited set of properties of all
|
||||
participants on comment threads of the file."
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.15" "Imported from queries and renamed."]}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-file-comments-users conn file-id profile-id)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MUTATION COMMANDS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare ^:private create-comment-thread)
|
||||
|
||||
;; --- COMMAND: Create Comment Thread
|
||||
|
||||
(declare upsert-comment-thread-status!)
|
||||
(declare create-comment-thread)
|
||||
(declare retrieve-page-name)
|
||||
|
||||
(s/def ::page-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::position ::gpt/point)
|
||||
(s/def ::content ::us/string)
|
||||
(s/def ::frame-id ::us/uuid)
|
||||
|
||||
(s/def ::create-comment-thread
|
||||
(s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id ::frame-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::position ::content ::page-id ::frame-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::create-comment-thread
|
||||
{::retry/max-retries 3
|
||||
::retry/matches retry/conflict-db-insert?
|
||||
::doc/added "1.15"
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg}
|
||||
{:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(create-comment-thread conn params)))
|
||||
(let [{:keys [team-id project-id page-name] :as file} (get-file conn file-id page-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
|
||||
(defn- retrieve-next-seqn
|
||||
[conn file-id]
|
||||
(let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
|
||||
res (db/exec-one! conn [sql file-id])]
|
||||
(:next-seqn res)))
|
||||
(run! (partial quotes/check-quote! conn)
|
||||
(list {::quotes/id ::quotes/comment-threads-per-file
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id
|
||||
::quotes/project-id project-id
|
||||
::quotes/file-id file-id}
|
||||
{::quotes/id ::quotes/comments-per-file
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id
|
||||
::quotes/project-id project-id
|
||||
::quotes/file-id file-id}))
|
||||
|
||||
(defn create-comment-thread
|
||||
[conn {:keys [profile-id file-id page-id position content frame-id] :as params}]
|
||||
(let [seqn (retrieve-next-seqn conn file-id)
|
||||
now (dt/now)
|
||||
pname (retrieve-page-name conn params)
|
||||
thread (db/insert! conn :comment-thread
|
||||
{:file-id file-id
|
||||
:owner-id profile-id
|
||||
:participants (db/tjson #{profile-id})
|
||||
:page-name pname
|
||||
:page-id page-id
|
||||
:created-at now
|
||||
:modified-at now
|
||||
:seqn seqn
|
||||
:position (db/pgpoint position)
|
||||
:frame-id frame-id})]
|
||||
(rtry/with-retry {::rtry/when rtry/conflict-exception?
|
||||
::rtry/max-retries 3
|
||||
::rtry/label "create-comment-thread"}
|
||||
(create-comment-thread conn
|
||||
{:created-at request-at
|
||||
:profile-id profile-id
|
||||
:file-id file-id
|
||||
:page-id page-id
|
||||
:page-name page-name
|
||||
:position position
|
||||
:content content
|
||||
:frame-id frame-id})))))
|
||||
|
||||
|
||||
;; Create a comment entry
|
||||
(db/insert! conn :comment
|
||||
{:thread-id (:id thread)
|
||||
:owner-id profile-id
|
||||
:created-at now
|
||||
:modified-at now
|
||||
:content content})
|
||||
(defn- create-comment-thread
|
||||
[conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
|
||||
(let [;; NOTE: we take the next seq number from a separate query because the whole
|
||||
;; operation can be retried on conflict, and in this case the new seq shold be
|
||||
;; retrieved from the database.
|
||||
seqn (get-next-seqn conn file-id)
|
||||
thread-id (uuid/next)
|
||||
thread (db/insert! conn :comment-thread
|
||||
{:id thread-id
|
||||
:file-id file-id
|
||||
:owner-id profile-id
|
||||
:participants (db/tjson #{profile-id})
|
||||
:page-name page-name
|
||||
:page-id page-id
|
||||
:created-at created-at
|
||||
:modified-at created-at
|
||||
:seqn seqn
|
||||
:position (db/pgpoint position)
|
||||
:frame-id frame-id})
|
||||
comment (db/insert! conn :comment
|
||||
{:id (uuid/next)
|
||||
:thread-id thread-id
|
||||
:owner-id profile-id
|
||||
:created-at created-at
|
||||
:modified-at created-at
|
||||
:content content})]
|
||||
|
||||
;; Make the current thread as read.
|
||||
(upsert-comment-thread-status! conn profile-id (:id thread))
|
||||
(upsert-comment-thread-status! conn profile-id thread-id created-at)
|
||||
|
||||
;; Optimistic update of current seq number on file.
|
||||
(db/update! conn :file
|
||||
{:comment-thread-seqn seqn}
|
||||
{:id file-id})
|
||||
|
||||
(select-keys thread [:id :file-id :page-id])))
|
||||
|
||||
(defn- retrieve-page-name
|
||||
[conn {:keys [file-id page-id]}]
|
||||
(let [{:keys [data]} (db/get-by-id conn :file file-id)
|
||||
data (blob/decode data)]
|
||||
(get-in data [:pages-index page-id :name])))
|
||||
|
||||
(-> thread
|
||||
(select-keys [:id :file-id :page-id])
|
||||
(assoc :comment-id (:id comment)))))
|
||||
|
||||
;; --- COMMAND: Update Comment Thread Status
|
||||
|
||||
@@ -309,49 +366,33 @@
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
|
||||
(s/def ::update-comment-thread-status
|
||||
(s/keys :req-un [::profile-id ::id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::update-comment-thread-status
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id share-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not cthr
|
||||
(ex/raise :type :not-found))
|
||||
|
||||
(files/check-comment-permissions! conn profile-id (:file-id cthr) share-id)
|
||||
(upsert-comment-thread-status! conn profile-id (:id cthr)))))
|
||||
|
||||
(def sql:upsert-comment-thread-status
|
||||
"insert into comment_thread_status (thread_id, profile_id)
|
||||
values (?, ?)
|
||||
on conflict (thread_id, profile_id)
|
||||
do update set modified_at = clock_timestamp()
|
||||
returning modified_at;")
|
||||
|
||||
(defn upsert-comment-thread-status!
|
||||
[conn profile-id thread-id]
|
||||
(db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id]))
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(upsert-comment-thread-status! conn profile-id id))))
|
||||
|
||||
|
||||
;; --- COMMAND: Update Comment Thread
|
||||
|
||||
(s/def ::is-resolved ::us/boolean)
|
||||
(s/def ::update-comment-thread
|
||||
(s/keys :req-un [::profile-id ::id ::is-resolved]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id ::is-resolved]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::update-comment-thread
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id is-resolved share-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not thread
|
||||
(ex/raise :type :not-found))
|
||||
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:is-resolved is-resolved}
|
||||
{:id id})
|
||||
@@ -360,158 +401,149 @@
|
||||
|
||||
;; --- COMMAND: Add Comment
|
||||
|
||||
(declare get-comment-thread)
|
||||
(declare create-comment)
|
||||
|
||||
(s/def ::create-comment
|
||||
(s/keys :req-un [::profile-id ::thread-id ::content]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::thread-id ::content]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::create-comment
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(create-comment conn params)))
|
||||
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id :for-update? true)
|
||||
{:keys [team-id project-id page-name] :as file} (get-file conn file-id page-id)]
|
||||
|
||||
(defn create-comment
|
||||
[conn {:keys [profile-id thread-id content share-id] :as params}]
|
||||
(let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true})
|
||||
(decode-row))
|
||||
pname (retrieve-page-name conn thread)]
|
||||
(files/check-comment-permissions! conn profile-id (:id file) share-id)
|
||||
(quotes/check-quote! conn
|
||||
{::quotes/id ::quotes/comments-per-file
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id
|
||||
::quotes/project-id project-id
|
||||
::quotes/file-id (:id file)})
|
||||
|
||||
;; Standard Checks
|
||||
(when-not thread (ex/raise :type :not-found))
|
||||
;; Update the page-name cached attribute on comment thread table.
|
||||
(when (not= page-name (:page-name thread))
|
||||
(db/update! conn :comment-thread
|
||||
{:page-name page-name}
|
||||
{:id thread-id}))
|
||||
|
||||
;; Permission Checks
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
(let [comment (db/insert! conn :comment
|
||||
{:id (uuid/next)
|
||||
:created-at request-at
|
||||
:modified-at request-at
|
||||
:thread-id thread-id
|
||||
:owner-id profile-id
|
||||
:content content})
|
||||
props {:file-id file-id
|
||||
:share-id nil}]
|
||||
|
||||
;; Update the page-name cachedattribute on comment thread table.
|
||||
(when (not= pname (:page-name thread))
|
||||
(db/update! conn :comment-thread
|
||||
{:page-name pname}
|
||||
{:id thread-id}))
|
||||
;; Update thread modified-at attribute and assoc the current
|
||||
;; profile to the participant set.
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:participants (-> (:participants thread #{})
|
||||
(conj profile-id)
|
||||
(db/tjson))}
|
||||
{:id thread-id})
|
||||
|
||||
;; NOTE: is important that all timestamptz related fields are
|
||||
;; created or updated on the database level for avoid clock
|
||||
;; inconsistencies (some user sees something read that is not
|
||||
;; read, etc...)
|
||||
(let [ppants (:participants thread #{})
|
||||
comment (db/insert! conn :comment
|
||||
{:thread-id thread-id
|
||||
:owner-id profile-id
|
||||
:content content})]
|
||||
;; Update the current profile status in relation to the
|
||||
;; current thread.
|
||||
(upsert-comment-thread-status! conn profile-id thread-id request-at)
|
||||
|
||||
;; NOTE: this is done in SQL instead of using db/update!
|
||||
;; helper because currently the helper does not allow pass raw
|
||||
;; function call parameters to the underlying prepared
|
||||
;; statement; in a future when we fix/improve it, this can be
|
||||
;; changed to use the helper.
|
||||
|
||||
;; Update thread modified-at attribute and assoc the current
|
||||
;; profile to the participant set.
|
||||
(let [ppants (conj ppants profile-id)
|
||||
sql "update comment_thread
|
||||
set modified_at = clock_timestamp(),
|
||||
participants = ?
|
||||
where id = ?"]
|
||||
(db/exec-one! conn [sql (db/tjson ppants) thread-id]))
|
||||
|
||||
;; Update the current profile status in relation to the
|
||||
;; current thread.
|
||||
(upsert-comment-thread-status! conn profile-id thread-id)
|
||||
|
||||
;; Return the created comment object.
|
||||
comment)))
|
||||
(vary-meta comment assoc ::audit/props props)))))
|
||||
|
||||
;; --- COMMAND: Update Comment
|
||||
|
||||
(declare update-comment)
|
||||
|
||||
(s/def ::update-comment
|
||||
(s/keys :req-un [::profile-id ::id ::content]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id ::content]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::update-comment
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id share-id content] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(update-comment conn params)))
|
||||
(let [{:keys [thread-id] :as comment} (get-comment conn id :for-update? true)
|
||||
{:keys [file-id page-id owner-id] :as thread} (get-comment-thread conn thread-id :for-update? true)]
|
||||
|
||||
(defn update-comment
|
||||
[conn {:keys [profile-id id content share-id] :as params}]
|
||||
(let [comment (db/get-by-id conn :comment id {:for-update true})
|
||||
_ (when-not comment (ex/raise :type :not-found))
|
||||
thread (db/get-by-id conn :comment-thread (:thread-id comment) {:for-update true})
|
||||
_ (when-not thread (ex/raise :type :not-found))
|
||||
pname (retrieve-page-name conn thread)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
;; Don't allow edit comments to not owners
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
|
||||
;; Don't allow edit comments to not owners
|
||||
(when-not (= (:owner-id thread) profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
|
||||
(db/update! conn :comment
|
||||
{:content content
|
||||
:modified-at (dt/now)}
|
||||
{:id (:id comment)})
|
||||
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at (dt/now)
|
||||
:page-name pname}
|
||||
{:id (:id thread)})
|
||||
nil))
|
||||
(let [{:keys [page-name] :as file} (get-file conn file-id page-id)]
|
||||
(db/update! conn :comment
|
||||
{:content content
|
||||
:modified-at request-at}
|
||||
{:id id})
|
||||
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:page-name page-name}
|
||||
{:id thread-id})
|
||||
nil))))
|
||||
|
||||
;; --- COMMAND: Delete Comment Thread
|
||||
|
||||
(s/def ::delete-comment-thread
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::delete-comment-thread
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not (= (:owner-id thread) profile-id)
|
||||
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
|
||||
(db/delete! conn :comment-thread {:id id})
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- COMMAND: Delete comment
|
||||
|
||||
(s/def ::delete-comment
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::delete-comment
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [comment (db/get-by-id conn :comment id {:for-update true})]
|
||||
(when-not (= (:owner-id comment) profile-id)
|
||||
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id :for-update? true)
|
||||
{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
|
||||
(db/delete! conn :comment {:id id}))))
|
||||
|
||||
|
||||
;; --- COMMAND: Update comment thread position
|
||||
|
||||
(s/def ::update-comment-thread-position
|
||||
(s/keys :req-un [::profile-id ::id ::position ::frame-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id ::position ::frame-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::update-comment-thread-position
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id position frame-id share-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id position frame-id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at (dt/now)
|
||||
{:modified-at (::rpc/request-at params)
|
||||
:position (db/pgpoint position)
|
||||
:frame-id frame-id}
|
||||
{:id (:id thread)})
|
||||
@@ -520,17 +552,18 @@
|
||||
;; --- COMMAND: Update comment frame
|
||||
|
||||
(s/def ::update-comment-thread-frame
|
||||
(s/keys :req-un [::profile-id ::id ::frame-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id ::frame-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::update-comment-thread-frame
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id frame-id share-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id frame-id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at (dt/now)
|
||||
{:modified-at (::rpc/request-at params)
|
||||
:frame-id frame-id}
|
||||
{:id (:id thread)})
|
||||
{:id id})
|
||||
nil)))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
@@ -26,7 +27,7 @@
|
||||
"A command that is responsible of creating a demo purpose
|
||||
profile. It only works if the `demo-users` flag is enabled in the
|
||||
configuration."
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"
|
||||
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
|
||||
[{:keys [pool] :as cfg} _]
|
||||
|
||||
@@ -15,17 +15,20 @@
|
||||
[app.common.spec :as us]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files.thumbnails :as-alias thumbs]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.projects :as projects]
|
||||
[app.rpc.queries.share-link :refer [retrieve-share-link]]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
@@ -41,7 +44,13 @@
|
||||
"storage/pointer-map"
|
||||
"components/v2"})
|
||||
|
||||
(def default-features #{})
|
||||
(def default-features
|
||||
(cond-> #{}
|
||||
(contains? cf/flags :fdata-storage-pointer-map)
|
||||
(conj "storage/pointer-map")
|
||||
|
||||
(contains? cf/flags :fdata-storage-objects-map)
|
||||
(conj "storage/objects-map")))
|
||||
|
||||
;; --- SPECS
|
||||
|
||||
@@ -51,7 +60,6 @@
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::is-shared ::us/boolean)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::search-term ::us/string)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
@@ -150,11 +158,14 @@
|
||||
(def check-read-permissions!
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; A user has comment permissions if she has read permissions, or comment permissions
|
||||
;; A user has comment permissions if she has read permissions, or
|
||||
;; explicit comment permissions through the share-id
|
||||
|
||||
(defn check-comment-permissions!
|
||||
[conn profile-id file-id share-id]
|
||||
(let [can-read (has-read-permissions? conn profile-id file-id)
|
||||
can-comment (has-comment-permissions? conn profile-id file-id share-id)]
|
||||
(let [perms (get-permissions conn profile-id file-id share-id)
|
||||
can-read (has-read-permissions? perms)
|
||||
can-comment (has-comment-permissions? perms)]
|
||||
(when-not (or can-read can-comment)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
@@ -240,7 +251,6 @@
|
||||
[conn id client-features]
|
||||
;; here we check if client requested features are supported
|
||||
(check-features-compatibility! client-features)
|
||||
|
||||
(binding [pmap/*load-fn* (partial load-pointer conn id)]
|
||||
(-> (db/get-by-id conn :file id)
|
||||
(decode-row)
|
||||
@@ -256,7 +266,8 @@
|
||||
(str (dt/format-instant modified-at :iso) "-" revn))
|
||||
|
||||
(s/def ::get-file
|
||||
(s/keys :req-un [::profile-id ::id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]
|
||||
:opt-un [::features]))
|
||||
|
||||
(sv/defmethod ::get-file
|
||||
@@ -264,7 +275,7 @@
|
||||
{::doc/added "1.17"
|
||||
::cond/get-object #(get-minimal-file %1 (:id %2))
|
||||
::cond/key-fn get-file-etag}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id features] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id features]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [perms (get-permissions conn profile-id id)]
|
||||
(check-read-permissions! perms)
|
||||
@@ -285,13 +296,14 @@
|
||||
|
||||
(s/def ::get-file-fragment
|
||||
(s/keys :req-un [::file-id ::fragment-id]
|
||||
:opt-un [::share-id ::profile-id]))
|
||||
:opt [::rpc/profile-id]
|
||||
:opt-un [::share-id]))
|
||||
|
||||
(sv/defmethod ::get-file-fragment
|
||||
"Retrieve a file by its ID. Only authenticated users."
|
||||
{::doc/added "1.17"
|
||||
:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id fragment-id share-id] :as params}]
|
||||
::rpc/:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id fragment-id share-id] }]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [perms (get-permissions conn profile-id file-id share-id)]
|
||||
(check-read-permissions! perms)
|
||||
@@ -319,7 +331,7 @@
|
||||
(d/index-by :object-id :data)))))
|
||||
|
||||
(s/def ::get-file-object-thumbnails
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::file-id]))
|
||||
|
||||
(sv/defmethod ::get-file-object-thumbnails
|
||||
"Retrieve a file object thumbnails."
|
||||
@@ -327,7 +339,7 @@
|
||||
::cond/get-object #(get-minimal-file %1 (:file-id %2))
|
||||
::cond/reuse-key? true
|
||||
::cond/key-fn get-file-etag}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(get-object-thumbnails conn file-id)))
|
||||
@@ -349,7 +361,7 @@
|
||||
order by f.modified_at desc")
|
||||
|
||||
(s/def ::get-project-files
|
||||
(s/keys :req-un [::profile-id ::project-id]))
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::project-id]))
|
||||
|
||||
(defn get-project-files
|
||||
[conn project-id]
|
||||
@@ -358,7 +370,7 @@
|
||||
(sv/defmethod ::get-project-files
|
||||
"Get all files for the specified project."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id project-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(get-project-files conn project-id)))
|
||||
@@ -369,18 +381,18 @@
|
||||
(declare get-has-file-libraries)
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
|
||||
(s/def ::has-file-libraries
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]))
|
||||
|
||||
(sv/defmethod ::has-file-libraries
|
||||
"Checks if the file has libraries. Returns a boolean"
|
||||
{::doc/added "1.15.1"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(get-has-file-libraries conn params)))
|
||||
(get-has-file-libraries conn file-id)))
|
||||
|
||||
(def ^:private sql:has-file-libraries
|
||||
"SELECT COUNT(*) > 0 AS has_libraries
|
||||
@@ -391,7 +403,7 @@
|
||||
fl.deleted_at > now())")
|
||||
|
||||
(defn- get-has-file-libraries
|
||||
[conn {:keys [file-id]}]
|
||||
[conn file-id]
|
||||
(let [row (db/exec-one! conn [sql:has-file-libraries file-id])]
|
||||
(:has-libraries row)))
|
||||
|
||||
@@ -425,7 +437,8 @@
|
||||
(s/def ::object-id ::us/uuid)
|
||||
(s/def ::get-page
|
||||
(s/and
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::page-id ::object-id ::features])
|
||||
(fn [obj]
|
||||
(if (contains? obj :object-id)
|
||||
@@ -443,7 +456,7 @@
|
||||
|
||||
Mainly used for rendering purposes."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(get-page conn params)))
|
||||
@@ -469,37 +482,37 @@
|
||||
order by f.modified_at desc")
|
||||
|
||||
(defn get-team-shared-files
|
||||
[conn {:keys [team-id] :as params}]
|
||||
(let [assets-sample
|
||||
(fn [assets limit]
|
||||
(let [sorted-assets (->> (vals assets)
|
||||
(sort-by #(str/lower (:name %))))]
|
||||
[conn team-id]
|
||||
(letfn [(assets-sample [assets limit]
|
||||
(let [sorted-assets (->> (vals assets)
|
||||
(sort-by #(str/lower (:name %))))]
|
||||
{:count (count sorted-assets)
|
||||
:sample (into [] (take limit sorted-assets))}))
|
||||
|
||||
{:count (count sorted-assets)
|
||||
:sample (into [] (take limit sorted-assets))}))
|
||||
(library-summary [{:keys [id data] :as file}]
|
||||
(binding [pmap/*load-fn* (partial load-pointer conn id)]
|
||||
{:components (assets-sample (:components data) 4)
|
||||
:media (assets-sample (:media data) 3)
|
||||
:colors (assets-sample (:colors data) 3)
|
||||
:typographies (assets-sample (:typographies data) 3)}))]
|
||||
|
||||
library-summary
|
||||
(fn [data]
|
||||
{:components (assets-sample (:components data) 4)
|
||||
:colors (assets-sample (:colors data) 3)
|
||||
:typographies (assets-sample (:typographies data) 3)})
|
||||
|
||||
xform (comp
|
||||
(map decode-row)
|
||||
(map #(assoc % :library-summary (library-summary (:data %))))
|
||||
(map #(dissoc % :data)))]
|
||||
|
||||
(into #{} xform (db/exec! conn [sql:team-shared-files team-id]))))
|
||||
(->> (db/exec! conn [sql:team-shared-files team-id])
|
||||
(into #{} (comp
|
||||
(map decode-row)
|
||||
(map #(assoc % :library-summary (library-summary %)))
|
||||
(map #(dissoc % :data)))))))
|
||||
|
||||
(s/def ::get-team-shared-files
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(sv/defmethod ::get-team-shared-files
|
||||
"Get all file (libraries) for the specified team."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(get-team-shared-files conn params)))
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(get-team-shared-files conn team-id)))
|
||||
|
||||
|
||||
;; --- COMMAND QUERY: get-file-libraries
|
||||
@@ -533,21 +546,24 @@
|
||||
[conn file-id client-features]
|
||||
(check-features-compatibility! client-features)
|
||||
(->> (db/exec! conn [sql:file-libraries file-id])
|
||||
(mapv (fn [{:keys [id] :as row}]
|
||||
(binding [pmap/*load-fn* (partial load-pointer conn id)]
|
||||
(-> (decode-row row)
|
||||
(assoc :is-indirect false)
|
||||
(update :data dissoc :pages-index)
|
||||
(handle-file-features client-features)))))))
|
||||
(map decode-row)
|
||||
(map #(assoc % :is-indirect false))
|
||||
(map (fn [{:keys [id] :as row}]
|
||||
(binding [pmap/*load-fn* (partial load-pointer conn id)]
|
||||
(-> row
|
||||
(update :data dissoc :pages-index)
|
||||
(handle-file-features client-features)))))
|
||||
(vec)))
|
||||
|
||||
(s/def ::get-file-libraries
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::features]))
|
||||
|
||||
(sv/defmethod ::get-file-libraries
|
||||
"Get libraries used by the specified file."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id features] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id features]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(get-file-libraries conn file-id features)))
|
||||
@@ -568,17 +584,16 @@
|
||||
(db/exec! conn [sql:library-using-files file-id]))
|
||||
|
||||
(s/def ::get-library-file-references
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::file-id]))
|
||||
|
||||
(sv/defmethod ::get-library-file-references
|
||||
"Returns all the file references that use specified file (library) id."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(get-library-file-references conn file-id)))
|
||||
|
||||
|
||||
;; --- COMMAND QUERY: get-team-recent-files
|
||||
|
||||
(def sql:team-recent-files
|
||||
@@ -606,11 +621,12 @@
|
||||
(db/exec! conn [sql:team-recent-files team-id]))
|
||||
|
||||
(s/def ::get-team-recent-files
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(sv/defmethod ::get-team-recent-files
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(get-team-recent-files conn team-id)))
|
||||
@@ -638,12 +654,13 @@
|
||||
(s/def ::revn ::us/integer)
|
||||
|
||||
(s/def ::get-file-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::revn]))
|
||||
|
||||
(sv/defmethod ::get-file-thumbnail
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool]} {:keys [profile-id file-id revn]}]
|
||||
[{:keys [pool]} {:keys [::rpc/profile-id file-id revn]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(-> (get-file-thumbnail conn file-id revn)
|
||||
@@ -705,46 +722,52 @@
|
||||
|
||||
objects)))]
|
||||
|
||||
(let [frame (get-thumbnail-frame data)
|
||||
frame-id (:id frame)
|
||||
page-id (or (:page-id frame)
|
||||
(-> data :pages first))
|
||||
(binding [pmap/*load-fn* (partial load-pointer conn id)]
|
||||
(let [frame (get-thumbnail-frame data)
|
||||
frame-id (:id frame)
|
||||
page-id (or (:page-id frame)
|
||||
(-> data :pages first))
|
||||
|
||||
page (dm/get-in data [:pages-index page-id])
|
||||
frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page))))
|
||||
page (dm/get-in data [:pages-index page-id])
|
||||
page (cond-> page (pmap/pointer-map? page) deref)
|
||||
frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page))))
|
||||
|
||||
obj-ids (map #(str page-id %) frame-ids)
|
||||
thumbs (get-object-thumbnails conn id obj-ids)]
|
||||
obj-ids (map #(str page-id %) frame-ids)
|
||||
thumbs (get-object-thumbnails conn id obj-ids)]
|
||||
|
||||
(cond-> page
|
||||
;; If we have frame, we need to specify it on the page level
|
||||
;; and remove the all other unrelated objects.
|
||||
(some? frame-id)
|
||||
(-> (assoc :thumbnail-frame-id frame-id)
|
||||
(update :objects filter-objects frame-id))
|
||||
(cond-> page
|
||||
;; If we have frame, we need to specify it on the page level
|
||||
;; and remove the all other unrelated objects.
|
||||
(some? frame-id)
|
||||
(-> (assoc :thumbnail-frame-id frame-id)
|
||||
(update :objects filter-objects frame-id))
|
||||
|
||||
;; Assoc the available thumbnails and prune not visible shapes
|
||||
;; for avoid transfer unnecessary data.
|
||||
:always
|
||||
(update :objects assoc-thumbnails page-id thumbs)))))
|
||||
;; Assoc the available thumbnails and prune not visible shapes
|
||||
;; for avoid transfer unnecessary data.
|
||||
:always
|
||||
(update :objects assoc-thumbnails page-id thumbs))))))
|
||||
|
||||
(s/def ::get-file-data-for-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::features]))
|
||||
|
||||
(sv/defmethod ::get-file-data-for-thumbnail
|
||||
"Retrieves the data for generate the thumbnail of the file. Used
|
||||
mainly for render thumbnails on dashboard."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id features] :as props}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id features] :as props}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(let [file (get-file conn file-id features)]
|
||||
;; NOTE: we force here the "storage/pointer-map" feature, because
|
||||
;; it used internally only and is independent if user supports it
|
||||
;; or not.
|
||||
(let [feat (into #{"storage/pointer-map"} features)
|
||||
file (get-file conn file-id feat)]
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:page (get-file-data-for-thumbnail conn file)})))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MUTATION COMMANDS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -752,24 +775,28 @@
|
||||
;; --- MUTATION COMMAND: rename-file
|
||||
|
||||
(defn rename-file
|
||||
[conn {:keys [id name] :as params}]
|
||||
(-> (db/update! conn :file
|
||||
{:name name
|
||||
:modified-at (dt/now)}
|
||||
{:id id})
|
||||
(select-keys [:id :name :created-at :modified-at])))
|
||||
[conn {:keys [id name]}]
|
||||
(db/update! conn :file
|
||||
{:name name
|
||||
:modified-at (dt/now)}
|
||||
{:id id}))
|
||||
|
||||
(s/def ::rename-file
|
||||
(s/keys :req-un [::profile-id ::name ::id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::name ::id]))
|
||||
|
||||
(sv/defmethod ::rename-file
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(rename-file conn params)))
|
||||
|
||||
(let [file (rename-file conn params)]
|
||||
(rph/with-meta
|
||||
(select-keys file [:id :name :created-at :modified-at])
|
||||
{::audit/props {:project-id (:project-id file)
|
||||
:created-at (:created-at file)
|
||||
:modified-at (:modified-at file)}}))))
|
||||
|
||||
;; --- MUTATION COMMAND: set-file-shared
|
||||
|
||||
@@ -779,10 +806,9 @@
|
||||
|
||||
(defn set-file-shared
|
||||
[conn {:keys [id is-shared] :as params}]
|
||||
(-> (db/update! conn :file
|
||||
{:is-shared is-shared}
|
||||
{:id id})
|
||||
(select-keys [:id :name :is-shared])))
|
||||
(db/update! conn :file
|
||||
{:is-shared is-shared}
|
||||
{:id id}))
|
||||
|
||||
(defn absorb-library
|
||||
"Find all files using a shared library, and absorb all library assets
|
||||
@@ -805,19 +831,25 @@
|
||||
{:id id})))))))))
|
||||
|
||||
(s/def ::set-file-shared
|
||||
(s/keys :req-un [::profile-id ::id ::is-shared]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id ::is-shared]))
|
||||
|
||||
(sv/defmethod ::set-file-shared
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id is-shared] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id is-shared] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(when-not is-shared
|
||||
(absorb-library conn params)
|
||||
(unlink-files conn params))
|
||||
(set-file-shared conn params)))
|
||||
|
||||
(let [file (set-file-shared conn params)]
|
||||
(rph/with-meta
|
||||
(select-keys file [:id :name :is-shared])
|
||||
{::audit/props {:name (:name file)
|
||||
:project-id (:project-id file)
|
||||
:is-shared (:is-shared file)}}))))
|
||||
|
||||
;; --- MUTATION COMMAND: delete-file
|
||||
|
||||
@@ -825,20 +857,26 @@
|
||||
[conn {:keys [id] :as params}]
|
||||
(db/update! conn :file
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id})
|
||||
nil)
|
||||
{:id id}))
|
||||
|
||||
(s/def ::delete-file
|
||||
(s/keys :req-un [::id ::profile-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]))
|
||||
|
||||
(sv/defmethod ::delete-file
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(absorb-library conn params)
|
||||
(mark-file-deleted conn params)))
|
||||
(let [file (mark-file-deleted conn params)]
|
||||
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:project-id (:project-id file)
|
||||
:name (:name file)
|
||||
:created-at (:created-at file)
|
||||
:modified-at (:modified-at file)}}))))
|
||||
|
||||
;; --- MUTATION COMMAND: link-file-to-library
|
||||
|
||||
@@ -852,12 +890,13 @@
|
||||
(db/exec-one! conn [sql:link-file-to-library file-id library-id]))
|
||||
|
||||
(s/def ::link-file-to-library
|
||||
(s/keys :req-un [::profile-id ::file-id ::library-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::library-id]))
|
||||
|
||||
(sv/defmethod ::link-file-to-library
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id library-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
(when (= file-id library-id)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-library
|
||||
@@ -870,18 +909,19 @@
|
||||
;; --- MUTATION COMMAND: unlink-file-from-library
|
||||
|
||||
(defn unlink-file-from-library
|
||||
[conn {:keys [file-id library-id] :as params}]
|
||||
[conn {:keys [file-id library-id]}]
|
||||
(db/delete! conn :file-library-rel
|
||||
{:file-id file-id
|
||||
:library-file-id library-id}))
|
||||
|
||||
(s/def ::unlink-file-from-library
|
||||
(s/keys :req-un [::profile-id ::file-id ::library-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::library-id]))
|
||||
|
||||
(sv/defmethod ::unlink-file-from-library
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(unlink-file-from-library conn params)))
|
||||
@@ -897,14 +937,15 @@
|
||||
:library-file-id library-id}))
|
||||
|
||||
(s/def ::update-file-library-sync-status
|
||||
(s/keys :req-un [::profile-id ::file-id ::library-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::library-id]))
|
||||
|
||||
;; TODO: improve naming
|
||||
|
||||
(sv/defmethod ::update-file-library-sync-status
|
||||
"Update the synchronization statos of a file->library link"
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(update-sync conn params)))
|
||||
@@ -919,16 +960,18 @@
|
||||
{:id file-id}))
|
||||
|
||||
(s/def ::ignore-file-library-sync-status
|
||||
(s/keys :req-un [::profile-id ::file-id ::date]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::date]))
|
||||
|
||||
;; TODO: improve naming
|
||||
(sv/defmethod ::ignore-file-library-sync-status
|
||||
"Ignore updates in linked files"
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(ignore-sync conn params)))
|
||||
(-> (ignore-sync conn params)
|
||||
(update :features db/decode-pgarray #{}))))
|
||||
|
||||
|
||||
;; --- MUTATION COMMAND: upsert-file-object-thumbnail
|
||||
@@ -948,11 +991,14 @@
|
||||
(s/def ::data (s/nilable ::us/string))
|
||||
(s/def ::thumbs/object-id ::us/string)
|
||||
(s/def ::upsert-file-object-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id ::thumbs/object-id ::data]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::thumbs/object-id]
|
||||
:opt-un [::data]))
|
||||
|
||||
(sv/defmethod ::upsert-file-object-thumbnail
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
{::doc/added "1.17"
|
||||
::audit/skip true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(upsert-file-object-thumbnail! conn params)
|
||||
@@ -975,13 +1021,15 @@
|
||||
(s/def ::revn ::us/integer)
|
||||
(s/def ::props map?)
|
||||
(s/def ::upsert-file-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id ::revn ::data ::props]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::revn ::data ::props]))
|
||||
|
||||
(sv/defmethod ::upsert-file-thumbnail
|
||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||
grid thumbnails."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
{::doc/added "1.17"
|
||||
::audit/skip true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(upsert-file-thumbnail conn params)
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.objects-map :as omap]
|
||||
[app.util.pointer-map :as pmap]
|
||||
@@ -68,8 +70,8 @@
|
||||
(files/decode-row file)))
|
||||
|
||||
(s/def ::create-file
|
||||
(s/keys :req-un [::files/profile-id
|
||||
::files/name
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::files/name
|
||||
::files/project-id]
|
||||
:opt-un [::files/id
|
||||
::files/is-shared
|
||||
@@ -78,10 +80,17 @@
|
||||
(sv/defmethod ::create-file
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id project-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id project-id)
|
||||
(let [team-id (files/get-team-id conn project-id)]
|
||||
(let [team-id (files/get-team-id conn project-id)
|
||||
params (assoc params :profile-id profile-id)]
|
||||
|
||||
(run! (partial quotes/check-quote! conn)
|
||||
(list {::quotes/id ::quotes/files-per-project
|
||||
::quotes/team-id team-id
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/project-id project-id}))
|
||||
|
||||
(-> (create-file conn params)
|
||||
(vary-meta assoc ::audit/props {:team-id team-id})))))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.files.create :as files.create]
|
||||
[app.rpc.commands.files.update :as files.update]
|
||||
@@ -26,8 +27,8 @@
|
||||
(s/def ::create-page ::us/boolean)
|
||||
|
||||
(s/def ::create-temp-file
|
||||
(s/keys :req-un [::files/profile-id
|
||||
::files/name
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::files/name
|
||||
::files/project-id]
|
||||
:opt-un [::files/id
|
||||
::files/is-shared
|
||||
@@ -36,15 +37,15 @@
|
||||
|
||||
(sv/defmethod ::create-temp-file
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id project-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id project-id)
|
||||
(files.create/create-file conn (assoc params :deleted-at (dt/in-future {:days 1})))))
|
||||
(files.create/create-file conn (assoc params :profile-id profile-id :deleted-at (dt/in-future {:days 1})))))
|
||||
|
||||
;; --- MUTATION COMMAND: update-temp-file
|
||||
|
||||
(defn update-temp-file
|
||||
[conn {:keys [profile-id session-id id revn changes] :as params}]
|
||||
[conn {:keys [::rpc/profile-id session-id id revn changes] :as params}]
|
||||
(db/insert! conn :file-change
|
||||
{:id (uuid/next)
|
||||
:session-id session-id
|
||||
@@ -95,12 +96,12 @@
|
||||
nil))
|
||||
|
||||
(s/def ::persist-temp-file
|
||||
(s/keys :req-un [::files/id
|
||||
::files/profile-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::files/id]))
|
||||
|
||||
(sv/defmethod ::persist-temp-file
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
(persist-temp-file conn params)))
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.loggers.webhooks :as webhooks]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
@@ -52,7 +53,8 @@
|
||||
(s/def ::revn ::us/integer)
|
||||
(s/def ::update-file
|
||||
(s/and
|
||||
(s/keys :req-un [::files/id ::files/profile-id ::session-id ::revn]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::files/id ::session-id ::revn]
|
||||
:opt-un [::changes ::changes-with-metadata ::features])
|
||||
(fn [o]
|
||||
(or (contains? o :changes)
|
||||
@@ -123,30 +125,27 @@
|
||||
;; set is different than the persisted one, update it on the
|
||||
;; database.
|
||||
|
||||
(defn webhook-batch-keyfn
|
||||
[props]
|
||||
(str "rpc:update-file:" (:id props)))
|
||||
|
||||
(sv/defmethod ::update-file
|
||||
{::climit/queue :update-file
|
||||
::climit/key-fn :id
|
||||
::webhooks/event? true
|
||||
::webhooks/batch-timeout (dt/duration "2s")
|
||||
::webhooks/batch-key webhook-batch-keyfn
|
||||
::webhooks/batch-timeout (dt/duration "2m")
|
||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||
::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
(db/xact-lock! conn id)
|
||||
|
||||
(let [cfg (assoc cfg :conn conn)
|
||||
params (assoc params :profile-id profile-id)
|
||||
tpoint (dt/tpoint)]
|
||||
(-> (update-file cfg params)
|
||||
(rph/with-defer #(let [elapsed (tpoint)]
|
||||
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))
|
||||
|
||||
(defn update-file
|
||||
[{:keys [conn metrics] :as cfg} {:keys [id profile-id changes changes-with-metadata] :as params}]
|
||||
[{:keys [conn metrics] :as cfg} {:keys [profile-id id changes changes-with-metadata] :as params}]
|
||||
(let [file (get-file conn id)
|
||||
features (->> (concat (:features file)
|
||||
(:features params))
|
||||
@@ -169,7 +168,18 @@
|
||||
(->> changes-with-metadata (mapcat :changes) vec)
|
||||
(vec changes))
|
||||
|
||||
params (assoc params :file file :changes changes)]
|
||||
params (-> params
|
||||
(assoc :file file)
|
||||
(assoc :changes changes)
|
||||
(assoc ::created-at (dt/now)))]
|
||||
|
||||
(when (> (:revn params)
|
||||
(:revn file))
|
||||
(ex/raise :type :validation
|
||||
:code :revn-conflict
|
||||
:hint "The incoming revision number is greater that stored version."
|
||||
:context {:incoming-revn (:revn params)
|
||||
:stored-revn (:revn file)}))
|
||||
|
||||
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
|
||||
|
||||
@@ -181,24 +191,15 @@
|
||||
|
||||
(-> (update-fn cfg params)
|
||||
(vary-meta assoc ::audit/replace-props
|
||||
{:id (:id file)
|
||||
:name (:name file)
|
||||
:features (:features file)
|
||||
{:id (:id file)
|
||||
:name (:name file)
|
||||
:features (:features file)
|
||||
:project-id (:project-id file)
|
||||
:team-id (:team-id file)}))))))
|
||||
|
||||
(defn- update-file*
|
||||
[{:keys [conn] :as cfg} {:keys [file changes session-id profile-id] :as params}]
|
||||
(when (> (:revn params)
|
||||
(:revn file))
|
||||
(ex/raise :type :validation
|
||||
:code :revn-conflict
|
||||
:hint "The incoming revision number is greater that stored version."
|
||||
:context {:incoming-revn (:revn params)
|
||||
:stored-revn (:revn file)}))
|
||||
|
||||
(let [ts (dt/now)
|
||||
file (-> file
|
||||
[{:keys [conn] :as cfg} {:keys [profile-id file changes session-id ::created-at] :as params}]
|
||||
(let [file (-> file
|
||||
(update :revn inc)
|
||||
(update :data (fn [data]
|
||||
(cond-> data
|
||||
@@ -218,7 +219,7 @@
|
||||
{:id (uuid/next)
|
||||
:session-id session-id
|
||||
:profile-id profile-id
|
||||
:created-at ts
|
||||
:created-at created-at
|
||||
:file-id (:id file)
|
||||
:revn (:revn file)
|
||||
:features (db/create-array conn "text" (:features file))
|
||||
@@ -230,12 +231,12 @@
|
||||
{:revn (:revn file)
|
||||
:data (:data file)
|
||||
:data-backend nil
|
||||
:modified-at ts
|
||||
:modified-at created-at
|
||||
:has-media-trimmed false}
|
||||
{:id (:id file)})
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at ts}
|
||||
{:modified-at created-at}
|
||||
{:id (:project-id file)})
|
||||
|
||||
(let [params (assoc params :file file)]
|
||||
@@ -266,13 +267,10 @@
|
||||
order by s.created_at asc")
|
||||
|
||||
(defn- get-lagged-changes
|
||||
[conn params]
|
||||
(->> (db/exec! conn [sql:lagged-changes (:id params) (:revn params)])
|
||||
(into [] (comp (map files/decode-row)
|
||||
(map (fn [row]
|
||||
(cond-> row
|
||||
(= (:revn row) (:revn (:file params)))
|
||||
(assoc :changes []))))))))
|
||||
[conn {:keys [id revn] :as params}]
|
||||
(->> (db/exec! conn [sql:lagged-changes id revn])
|
||||
(map files/decode-row)
|
||||
(vec)))
|
||||
|
||||
(defn- send-notifications!
|
||||
[{:keys [conn] :as cfg} {:keys [file changes session-id] :as params}]
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
@@ -34,15 +37,15 @@
|
||||
(sv/defmethod ::login-with-ldap
|
||||
"Performs the authentication using LDAP backend. Only works if LDAP
|
||||
is properly configured and enabled with `login-with-ldap` flag."
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[{:keys [session tokens ldap] :as cfg} params]
|
||||
(when-not ldap
|
||||
[{:keys [::main/props ::ldap/provider session] :as cfg} params]
|
||||
(when-not provider
|
||||
(ex/raise :type :restriction
|
||||
:code :ldap-not-initialized
|
||||
:hide "ldap auth provider is not initialized"))
|
||||
|
||||
(let [info (ldap/authenticate ldap params)]
|
||||
(let [info (ldap/authenticate provider params)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :wrong-credentials))
|
||||
@@ -58,12 +61,11 @@
|
||||
;; user comes from team-invitation process; in this case,
|
||||
;; regenerate token and send back to the user a new invitation
|
||||
;; token (and mark current session as logged).
|
||||
(let [claims (tokens :verify {:token token :iss :team-invitation})
|
||||
(let [claims (tokens/verify props {:token token :iss :team-invitation})
|
||||
claims (assoc claims
|
||||
:member-id (:id profile)
|
||||
:member-email (:email profile))
|
||||
token (tokens :generate claims)]
|
||||
|
||||
token (tokens/generate props claims)]
|
||||
(-> {:invitation-token token}
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-meta {::audit/props (:props profile)
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.binfile :as binfile]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams :refer [create-project-role create-project]]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.mutations.projects :refer [create-project-role create-project]]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
@@ -31,22 +32,23 @@
|
||||
(declare duplicate-file)
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
|
||||
(s/def ::duplicate-file
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::name]))
|
||||
|
||||
(sv/defmethod ::duplicate-file
|
||||
"Duplicate a single file in the same team."
|
||||
{::doc/added "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.16"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(duplicate-file conn params)))
|
||||
(duplicate-file conn (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn- remap-id
|
||||
[item index key]
|
||||
@@ -134,7 +136,7 @@
|
||||
and so.deleted_at is null")
|
||||
|
||||
(defn duplicate-file*
|
||||
[conn {:keys [profile-id file index project-id name flibs fmeds]} {:keys [reset-shared-flag] :as opts}]
|
||||
[conn {:keys [profile-id file index project-id name flibs fmeds]} {:keys [reset-shared-flag]}]
|
||||
(let [flibs (or flibs (db/exec! conn [sql:retrieve-used-libraries (:id file)]))
|
||||
fmeds (or fmeds (db/exec! conn [sql:retrieve-used-media-objects (:id file)]))
|
||||
|
||||
@@ -211,15 +213,17 @@
|
||||
(declare duplicate-project)
|
||||
|
||||
(s/def ::duplicate-project
|
||||
(s/keys :req-un [::profile-id ::project-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::project-id]
|
||||
:opt-un [::name]))
|
||||
|
||||
(sv/defmethod ::duplicate-project
|
||||
"Duplicate an entire project with all the files"
|
||||
{::doc/added "1.16"}
|
||||
{::doc/added "1.16"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(duplicate-project conn params)))
|
||||
(duplicate-project conn (assoc params :profile-id (::rpc/profile-id params)))))
|
||||
|
||||
(defn duplicate-project
|
||||
[conn {:keys [profile-id project-id name] :as params}]
|
||||
@@ -247,9 +251,7 @@
|
||||
;; create the duplicated project and assign the current profile as
|
||||
;; a project owner
|
||||
(create-project conn project)
|
||||
(create-project-role conn {:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:role :owner})
|
||||
(create-project-role conn profile-id (:id project) :owner)
|
||||
|
||||
;; duplicate all files
|
||||
(let [index (reduce #(assoc %1 (:id %2) (uuid/next)) {} files)
|
||||
@@ -320,15 +322,16 @@
|
||||
|
||||
(s/def ::ids (s/every ::us/uuid :kind set?))
|
||||
(s/def ::move-files
|
||||
(s/keys :req-un [::profile-id ::ids ::project-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::ids ::project-id]))
|
||||
|
||||
(sv/defmethod ::move-files
|
||||
"Move a set of files from one project to other."
|
||||
{::doc/added "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.16"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(move-files conn params)))
|
||||
|
||||
(move-files conn (assoc params :profile-id profile-id))))
|
||||
|
||||
;; --- COMMAND: Move project
|
||||
|
||||
@@ -359,14 +362,16 @@
|
||||
|
||||
|
||||
(s/def ::move-project
|
||||
(s/keys :req-un [::profile-id ::team-id ::project-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::project-id]))
|
||||
|
||||
(sv/defmethod ::move-project
|
||||
"Move projects between teams."
|
||||
{::doc/added "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.16"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(move-project conn params)))
|
||||
(move-project conn (assoc params :profile-id profile-id))))
|
||||
|
||||
;; --- COMMAND: Clone Template
|
||||
|
||||
@@ -374,15 +379,17 @@
|
||||
|
||||
(s/def ::template-id ::us/not-empty-string)
|
||||
(s/def ::clone-template
|
||||
(s/keys :req-un [::profile-id ::project-id ::template-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::project-id ::template-id]))
|
||||
|
||||
(sv/defmethod ::clone-template
|
||||
"Clone into the specified project the template by its id."
|
||||
{::doc/added "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.16"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(-> (assoc cfg :conn conn)
|
||||
(clone-template params))))
|
||||
(clone-template (assoc params :profile-id profile-id)))))
|
||||
|
||||
(defn- clone-template
|
||||
[{:keys [conn templates] :as cfg} {:keys [profile-id template-id project-id]}]
|
||||
|
||||
274
backend/src/app/rpc/commands/media.clj
Normal file
274
backend/src/app/rpc/commands/media.clj
Normal file
@@ -0,0 +1,274 @@
|
||||
;; 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.media
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
|
||||
(def thumbnail-options
|
||||
{:width 100
|
||||
:height 100
|
||||
:quality 85
|
||||
:format :jpeg})
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
|
||||
(defn validate-content-size!
|
||||
[content]
|
||||
(when (> (:size content) (cf/get :media-max-file-size default-max-file-size))
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size content)
|
||||
default-max-file-size))))
|
||||
|
||||
;; --- Create File Media object (upload)
|
||||
|
||||
(declare create-file-media-object)
|
||||
|
||||
(s/def ::content ::media/upload)
|
||||
(s/def ::is-local ::us/boolean)
|
||||
|
||||
(s/def ::upload-file-media-object
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::is-local ::name ::content]
|
||||
:opt-un [::id]))
|
||||
|
||||
(sv/defmethod ::upload-file-media-object
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(media/validate-media-type! content)
|
||||
(validate-content-size! content)
|
||||
|
||||
(create-file-media-object cfg params)))
|
||||
|
||||
(defn- big-enough-for-thumbnail?
|
||||
"Checks if the provided image info is big enough for
|
||||
create a separate thumbnail storage object."
|
||||
[info]
|
||||
(or (> (:width info) (:width thumbnail-options))
|
||||
(> (:height info) (:height thumbnail-options))))
|
||||
|
||||
(defn- svg-image?
|
||||
[info]
|
||||
(= (:mtype info) "image/svg+xml"))
|
||||
|
||||
;; NOTE: we use the `on conflict do update` instead of `do nothing`
|
||||
;; because postgresql does not returns anything if no update is
|
||||
;; performed, the `do update` does the trick.
|
||||
|
||||
(def sql:create-file-media-object
|
||||
"insert into file_media_object (id, file_id, is_local, name, media_id, thumbnail_id, width, height, mtype)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict (id) do update set created_at=file_media_object.created_at
|
||||
returning *")
|
||||
|
||||
;; NOTE: the following function executes without a transaction, this
|
||||
;; means that if something fails in the middle of this function, it
|
||||
;; will probably leave leaked/unreferenced objects in the database and
|
||||
;; probably in the storage layer. For handle possible object leakage,
|
||||
;; we create all media objects marked as touched, this ensures that if
|
||||
;; something fails, all leaked (already created storage objects) will
|
||||
;; be eventually marked as deleted by the touched-gc task.
|
||||
;;
|
||||
;; The touched-gc task, performs periodic analysis of all touched
|
||||
;; storage objects and check references of it. This is the reason why
|
||||
;; `reference` metadata exists: it indicates the name of the table
|
||||
;; witch holds the reference to storage object (it some kind of
|
||||
;; inverse, soft referential integrity).
|
||||
|
||||
(defn create-file-media-object
|
||||
[{:keys [storage pool climit executor]}
|
||||
{:keys [id file-id is-local name content]}]
|
||||
(letfn [;; Function responsible to retrieve the file information, as
|
||||
;; it is synchronous operation it should be wrapped into
|
||||
;; with-dispatch macro.
|
||||
(get-info [content]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch executor
|
||||
(sto/calculate-hash data)))
|
||||
|
||||
;; Function responsible of generating thumnail. As it is synchronous
|
||||
;; opetation, it should be wrapped into with-dispatch macro
|
||||
(generate-thumbnail [info]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run (assoc thumbnail-options
|
||||
:cmd :generic-thumbnail
|
||||
:input info))))
|
||||
|
||||
(create-thumbnail [info]
|
||||
(when (and (not (svg-image? info))
|
||||
(big-enough-for-thumbnail? info))
|
||||
(p/let [thumb (generate-thumbnail info)
|
||||
hash (calculate-hash (:data thumb))
|
||||
content (-> (sto/content (:data thumb) (:size thumb))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage
|
||||
{::sto/content content
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (dt/now)
|
||||
:content-type (:mtype thumb)
|
||||
:bucket "file-media-object"}))))
|
||||
|
||||
(create-image [info]
|
||||
(p/let [data (:path info)
|
||||
hash (calculate-hash data)
|
||||
content (-> (sto/content data)
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage
|
||||
{::sto/content content
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (dt/now)
|
||||
:content-type (:mtype info)
|
||||
:bucket "file-media-object"})))
|
||||
|
||||
(insert-into-database [info image thumb]
|
||||
(px/with-dispatch executor
|
||||
(db/exec-one! pool [sql:create-file-media-object
|
||||
(or id (uuid/next))
|
||||
file-id is-local name
|
||||
(:id image)
|
||||
(:id thumb)
|
||||
(:width info)
|
||||
(:height info)
|
||||
(:mtype info)])))]
|
||||
|
||||
(p/let [info (get-info content)
|
||||
thumb (create-thumbnail info)
|
||||
image (create-image info)]
|
||||
(insert-into-database info image thumb))))
|
||||
|
||||
;; --- Create File Media Object (from URL)
|
||||
|
||||
(declare ^:private create-file-media-object-from-url)
|
||||
|
||||
(s/def ::create-file-media-object-from-url
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::is-local ::url]
|
||||
:opt-un [::id ::name]))
|
||||
|
||||
(sv/defmethod ::create-file-media-object-from-url
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(create-file-media-object-from-url cfg params)))
|
||||
|
||||
(defn- create-file-media-object-from-url
|
||||
[cfg {:keys [url name] :as params}]
|
||||
(letfn [(parse-and-validate-size [headers]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size
|
||||
:mtype mtype
|
||||
:format format}))
|
||||
|
||||
(download-media [uri]
|
||||
(-> (http/req! cfg {:method :get :uri uri} {:response-type :input-stream})
|
||||
(p/then process-response)))
|
||||
|
||||
(process-response [{:keys [body headers] :as response}]
|
||||
(let [{:keys [size mtype]} (parse-and-validate-size headers)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write-to-file! body path :size size)]
|
||||
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
{:filename "tempfile"
|
||||
:size size
|
||||
:path path
|
||||
:mtype mtype}))]
|
||||
|
||||
(p/let [content (download-media url)]
|
||||
(->> (merge params {:content content :name (or name (:filename content))})
|
||||
(create-file-media-object cfg)))))
|
||||
|
||||
;; --- Clone File Media object (Upload and create from url)
|
||||
|
||||
(declare clone-file-media-object)
|
||||
|
||||
(s/def ::clone-file-media-object
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::is-local ::id]))
|
||||
|
||||
(sv/defmethod ::clone-file-media-object
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(-> (assoc cfg :conn conn)
|
||||
(clone-file-media-object params))))
|
||||
|
||||
(defn clone-file-media-object
|
||||
[{:keys [conn]} {:keys [id file-id is-local]}]
|
||||
(let [mobj (db/get-by-id conn :file-media-object id)]
|
||||
(db/insert! conn :file-media-object
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
:is-local is-local
|
||||
:name (:name mobj)
|
||||
:media-id (:media-id mobj)
|
||||
:thumbnail-id (:thumbnail-id mobj)
|
||||
:width (:width mobj)
|
||||
:height (:height mobj)
|
||||
:mtype (:mtype mobj)})))
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
@@ -47,22 +48,21 @@
|
||||
order by f.created_at asc")
|
||||
|
||||
(defn search-files
|
||||
[conn {:keys [profile-id team-id search-term] :as params}]
|
||||
[conn profile-id team-id search-term]
|
||||
(db/exec! conn [sql:search-files
|
||||
profile-id team-id
|
||||
profile-id team-id
|
||||
search-term]))
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::search-files ::us/string)
|
||||
|
||||
(s/def ::search-files
|
||||
(s/keys :req-un [::profile-id ::team-id]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]
|
||||
:opt-un [::search-term]))
|
||||
|
||||
(sv/defmethod ::search-files
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool]} {:keys [search-term] :as params}]
|
||||
(when search-term
|
||||
(search-files pool params)))
|
||||
[{:keys [pool]} {:keys [::rpc/profile-id team-id search-term]}]
|
||||
(some->> search-term (search-files pool profile-id team-id)))
|
||||
|
||||
907
backend/src/app/rpc/commands/teams.clj
Normal file
907
backend/src/app/rpc/commands/teams.clj
Normal file
@@ -0,0 +1,907 @@
|
||||
;; 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.teams
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.emails :as eml]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
|
||||
(def ^:private sql:team-permissions
|
||||
"select tpr.is_owner,
|
||||
tpr.is_admin,
|
||||
tpr.can_edit
|
||||
from team_profile_rel as tpr
|
||||
join team as t on (t.id = tpr.team_id)
|
||||
where tpr.profile_id = ?
|
||||
and tpr.team_id = ?
|
||||
and t.deleted_at is null")
|
||||
|
||||
(defn get-permissions
|
||||
[conn profile-id team-id]
|
||||
(let [rows (db/exec! conn [sql:team-permissions profile-id team-id])
|
||||
is-owner (boolean (some :is-owner rows))
|
||||
is-admin (boolean (some :is-admin rows))
|
||||
can-edit (boolean (some :can-edit rows))]
|
||||
(when (seq rows)
|
||||
{:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true})))
|
||||
|
||||
(def has-edit-permissions?
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
|
||||
(def check-read-permissions!
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; --- Query: Teams
|
||||
|
||||
(declare retrieve-teams)
|
||||
|
||||
(s/def ::get-teams
|
||||
(s/keys :req [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::get-teams
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(retrieve-teams conn profile-id)))
|
||||
|
||||
(def sql:teams
|
||||
"select t.*,
|
||||
tp.is_owner,
|
||||
tp.is_admin,
|
||||
tp.can_edit,
|
||||
(t.id = ?) as is_default
|
||||
from team_profile_rel as tp
|
||||
join team as t on (t.id = tp.team_id)
|
||||
where t.deleted_at is null
|
||||
and tp.profile_id = ?
|
||||
order by tp.created_at asc")
|
||||
|
||||
(defn process-permissions
|
||||
[team]
|
||||
(let [is-owner (:is-owner team)
|
||||
is-admin (:is-admin team)
|
||||
can-edit (:can-edit team)
|
||||
permissions {:type :membership
|
||||
:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)}]
|
||||
(-> team
|
||||
(dissoc :is-owner :is-admin :can-edit)
|
||||
(assoc :permissions permissions))))
|
||||
|
||||
(defn retrieve-teams
|
||||
[conn profile-id]
|
||||
(let [defaults (profile/retrieve-additional-data conn profile-id)]
|
||||
(->> (db/exec! conn [sql:teams (:default-team-id defaults) profile-id])
|
||||
(mapv process-permissions))))
|
||||
|
||||
;; --- Query: Team (by ID)
|
||||
|
||||
(declare retrieve-team)
|
||||
|
||||
(s/def ::get-team
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]))
|
||||
|
||||
(sv/defmethod ::get-team
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(retrieve-team conn profile-id id)))
|
||||
|
||||
(defn retrieve-team
|
||||
[conn profile-id team-id]
|
||||
(let [defaults (profile/retrieve-additional-data conn profile-id)
|
||||
sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?")
|
||||
result (db/exec-one! conn [sql (:default-team-id defaults) profile-id team-id])]
|
||||
(when-not result
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist))
|
||||
(process-permissions result)))
|
||||
|
||||
|
||||
;; --- Query: Team Members
|
||||
|
||||
(def sql:team-members
|
||||
"select tp.*,
|
||||
p.id,
|
||||
p.email,
|
||||
p.fullname as name,
|
||||
p.fullname as fullname,
|
||||
p.photo_id,
|
||||
p.is_active
|
||||
from team_profile_rel as tp
|
||||
join profile as p on (p.id = tp.profile_id)
|
||||
where tp.team_id = ?")
|
||||
|
||||
(defn retrieve-team-members
|
||||
[conn team-id]
|
||||
(db/exec! conn [sql:team-members team-id]))
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::get-team-members
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(sv/defmethod ::get-team-members
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-team-members conn team-id)))
|
||||
|
||||
|
||||
;; --- Query: Team Users
|
||||
|
||||
(declare retrieve-users)
|
||||
(declare retrieve-team-for-file)
|
||||
|
||||
(s/def ::get-team-users
|
||||
(s/and (s/keys :req [::rpc/profile-id]
|
||||
:opt-un [::team-id ::file-id])
|
||||
#(or (:team-id %) (:file-id %))))
|
||||
|
||||
(sv/defmethod ::get-team-users
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(if team-id
|
||||
(do
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-users conn team-id))
|
||||
(let [{team-id :id} (retrieve-team-for-file conn file-id)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-users conn team-id)))))
|
||||
|
||||
;; This is a similar query to team members but can contain more data
|
||||
;; because some user can be explicitly added to project or file (not
|
||||
;; implemented in UI)
|
||||
|
||||
(def sql:team-users
|
||||
"select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
|
||||
where tpr.team_id = ?
|
||||
union
|
||||
select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
|
||||
inner join project as p on (ppr.project_id = p.id)
|
||||
where p.team_id = ?
|
||||
union
|
||||
select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
|
||||
inner join file as f on (fpr.file_id = f.id)
|
||||
inner join project as p on (f.project_id = p.id)
|
||||
where p.team_id = ?")
|
||||
|
||||
(def sql:team-by-file
|
||||
"select p.team_id as id
|
||||
from project as p
|
||||
join file as f on (p.id = f.project_id)
|
||||
where f.id = ?")
|
||||
|
||||
(defn retrieve-users
|
||||
[conn team-id]
|
||||
(db/exec! conn [sql:team-users team-id team-id team-id]))
|
||||
|
||||
(defn retrieve-team-for-file
|
||||
[conn file-id]
|
||||
(->> [sql:team-by-file file-id]
|
||||
(db/exec-one! conn)))
|
||||
|
||||
;; --- Query: Team Stats
|
||||
|
||||
(declare retrieve-team-stats)
|
||||
|
||||
(s/def ::get-team-stats
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(sv/defmethod ::get-team-stats
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-team-stats conn team-id)))
|
||||
|
||||
(def sql:team-stats
|
||||
"select (select count(*) from project where team_id = ?) as projects,
|
||||
(select count(*) from file as f join project as p on (p.id = f.project_id) where p.team_id = ?) as files")
|
||||
|
||||
(defn retrieve-team-stats
|
||||
[conn team-id]
|
||||
(db/exec-one! conn [sql:team-stats team-id team-id]))
|
||||
|
||||
|
||||
;; --- Query: Team invitations
|
||||
|
||||
(s/def ::get-team-invitations
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(def sql:team-invitations
|
||||
"select email_to as email, role, (valid_until < now()) as expired
|
||||
from team_invitation where team_id = ? order by valid_until desc, created_at desc")
|
||||
|
||||
(defn get-team-invitations
|
||||
[conn team-id]
|
||||
(->> (db/exec! conn [sql:team-invitations team-id])
|
||||
(mapv #(update % :role keyword))))
|
||||
|
||||
(sv/defmethod ::get-team-invitations
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(get-team-invitations conn team-id)))
|
||||
|
||||
;; --- Mutation: Create Team
|
||||
|
||||
(declare create-team)
|
||||
(declare create-project)
|
||||
(declare create-project-role)
|
||||
(declare ^:private create-team*)
|
||||
(declare ^:private create-team-role)
|
||||
(declare ^:private create-team-default-project)
|
||||
|
||||
(s/def ::create-team
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::name]
|
||||
:opt-un [::id]))
|
||||
|
||||
(sv/defmethod ::create-team
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
(create-team conn (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn create-team
|
||||
"This is a complete team creation process, it creates the team
|
||||
object and all related objects (default role and default project)."
|
||||
[conn params]
|
||||
(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)
|
||||
(assoc team :default-project-id (:id project))))
|
||||
|
||||
(defn- create-team*
|
||||
[conn {:keys [id name is-default] :as params}]
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)]
|
||||
(db/insert! conn :team
|
||||
{:id id
|
||||
:name name
|
||||
:is-default is-default})))
|
||||
|
||||
(defn- create-team-role
|
||||
[conn {:keys [profile-id team-id role] :as params}]
|
||||
(let [params {:team-id team-id
|
||||
:profile-id profile-id}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :team-profile-rel))))
|
||||
|
||||
(defn- create-team-default-project
|
||||
[conn {:keys [profile-id team-id] :as params}]
|
||||
(let [project {:id (uuid/next)
|
||||
:team-id team-id
|
||||
:name "Drafts"
|
||||
:is-default true}
|
||||
project (create-project conn project)]
|
||||
(create-project-role conn profile-id (:id project) :owner)
|
||||
project))
|
||||
|
||||
;; NOTE: we have project creation here because there are cyclic
|
||||
;; dependency between teams and projects namespaces, and the project
|
||||
;; creation happens in both sides, on team creation and on simple
|
||||
;; project creation, so it make sense to have this functions in this
|
||||
;; namespace too.
|
||||
|
||||
(defn create-project
|
||||
[conn {:keys [id team-id name is-default] :as params}]
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)]
|
||||
(db/insert! conn :project
|
||||
{:id id
|
||||
:name name
|
||||
:team-id team-id
|
||||
:is-default is-default})))
|
||||
|
||||
(defn create-project-role
|
||||
[conn profile-id project-id role]
|
||||
(let [params {:project-id project-id
|
||||
:profile-id profile-id}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :project-profile-rel))))
|
||||
|
||||
;; --- Mutation: Update Team
|
||||
|
||||
(s/def ::update-team
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::name ::id]))
|
||||
|
||||
(sv/defmethod ::update-team
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id name] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id})
|
||||
nil))
|
||||
|
||||
|
||||
;; --- Mutation: Leave Team
|
||||
|
||||
(declare role->params)
|
||||
|
||||
(defn leave-team
|
||||
[conn {:keys [profile-id id reassign-to]}]
|
||||
(let [perms (get-permissions conn profile-id id)
|
||||
members (retrieve-team-members conn id)]
|
||||
|
||||
(cond
|
||||
;; we can only proceed if there are more members in the team
|
||||
;; besides the current profile
|
||||
(<= (count members) 1)
|
||||
(ex/raise :type :validation
|
||||
:code :no-enough-members-for-leave
|
||||
:context {:members (count members)})
|
||||
|
||||
;; 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))
|
||||
(let [member (d/seek #(= reassign-to (:id %)) members)]
|
||||
(when-not member
|
||||
(ex/raise :type :not-found :code :member-does-not-exist))
|
||||
|
||||
;; unasign owner role to current profile
|
||||
(db/update! conn :team-profile-rel
|
||||
{:is-owner false}
|
||||
{:team-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
;; assign owner role to new profile
|
||||
(db/update! conn :team-profile-rel
|
||||
(role->params :owner)
|
||||
{:team-id id :profile-id reassign-to}))
|
||||
|
||||
;; and finally, if all other conditions does not match and the
|
||||
;; current profile is owner, we dont allow it because there
|
||||
;; must always be an owner.
|
||||
(:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :owner-cant-leave-team
|
||||
:hint "releasing owner before leave"))
|
||||
|
||||
(db/delete! conn :team-profile-rel
|
||||
{:profile-id profile-id
|
||||
:team-id id})
|
||||
|
||||
nil))
|
||||
|
||||
(s/def ::reassign-to ::us/uuid)
|
||||
(s/def ::leave-team
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]
|
||||
:opt-un [::reassign-to]))
|
||||
|
||||
(sv/defmethod ::leave-team
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(leave-team conn (assoc params :profile-id profile-id))))
|
||||
|
||||
;; --- Mutation: Delete Team
|
||||
|
||||
(s/def ::delete-team
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]))
|
||||
|
||||
;; TODO: right now just don't allow delete default team, in future it
|
||||
;; should raise a specific exception for signal that this action is
|
||||
;; not allowed.
|
||||
|
||||
(sv/defmethod ::delete-team
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (get-permissions conn profile-id id)]
|
||||
(when-not (:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
(db/update! conn :team
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :is-default false})
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- Mutation: Team Update Role
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::member-id ::us/uuid)
|
||||
;; Temporarily disabled viewer role
|
||||
;; https://tree.taiga.io/project/penpot/issue/1083
|
||||
;; (s/def ::role #{:owner :admin :editor :viewer})
|
||||
(s/def ::role #{:owner :admin :editor})
|
||||
|
||||
(defn role->params
|
||||
[role]
|
||||
(case role
|
||||
:admin {:is-owner false :is-admin true :can-edit true}
|
||||
:editor {:is-owner false :is-admin false :can-edit true}
|
||||
:owner {:is-owner true :is-admin true :can-edit true}
|
||||
:viewer {:is-owner false :is-admin false :can-edit false}))
|
||||
|
||||
(defn update-team-member-role
|
||||
[conn {:keys [profile-id team-id member-id role] :as params}]
|
||||
;; We retrieve all team members instead of query the
|
||||
;; database for a single member. This is just for
|
||||
;; convenience, if this becomes a bottleneck or problematic,
|
||||
;; we will change it to more efficient fetch mechanisms.
|
||||
(let [perms (get-permissions conn profile-id team-id)
|
||||
members (retrieve-team-members conn team-id)
|
||||
member (d/seek #(= member-id (:id %)) members)
|
||||
|
||||
is-owner? (:is-owner perms)
|
||||
is-admin? (:is-admin perms)]
|
||||
|
||||
;; If no member is found, just 404
|
||||
(when-not member
|
||||
(ex/raise :type :not-found
|
||||
:code :member-does-not-exist))
|
||||
|
||||
;; First check if we have permissions to change roles
|
||||
(when-not (or is-owner? is-admin?)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
;; Don't allow change role of owner member
|
||||
(when (:is-owner member)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-change-role-to-owner))
|
||||
|
||||
;; Don't allow promote to owner to admin users.
|
||||
(when (and (not is-owner?) (= role :owner))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-promote-to-owner))
|
||||
|
||||
(let [params (role->params role)]
|
||||
;; Only allow single owner on team
|
||||
(when (= role :owner)
|
||||
(db/update! conn :team-profile-rel
|
||||
{:is-owner false}
|
||||
{:team-id team-id
|
||||
:profile-id profile-id}))
|
||||
|
||||
(db/update! conn :team-profile-rel
|
||||
params
|
||||
{:team-id team-id
|
||||
:profile-id member-id})
|
||||
nil)))
|
||||
|
||||
(s/def ::update-team-member-role
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::member-id ::role]))
|
||||
|
||||
(sv/defmethod ::update-team-member-role
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(update-team-member-role conn (assoc params :profile-id profile-id))))
|
||||
|
||||
|
||||
;; --- Mutation: Delete Team Member
|
||||
|
||||
(s/def ::delete-team-member
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::member-id]))
|
||||
|
||||
(sv/defmethod ::delete-team-member
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (get-permissions conn profile-id team-id)]
|
||||
(when-not (or (:is-owner perms)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(when (= member-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-remove-yourself))
|
||||
|
||||
(db/delete! conn :team-profile-rel {:profile-id member-id
|
||||
:team-id team-id})
|
||||
|
||||
nil)))
|
||||
|
||||
;; --- Mutation: Update Team Photo
|
||||
|
||||
(declare ^:private upload-photo)
|
||||
(declare ^:private update-team-photo)
|
||||
|
||||
(s/def ::file ::media/upload)
|
||||
(s/def ::update-team-photo
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::file]))
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
{::doc/added "1.17"}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(update-team-photo cfg (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn update-team-photo
|
||||
[{:keys [pool storage executor] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(p/let [team (px/with-dispatch executor
|
||||
(retrieve-team pool profile-id team-id))
|
||||
photo (upload-photo cfg params)]
|
||||
|
||||
;; Mark object as touched for make it ellegible for tentative
|
||||
;; garbage collection.
|
||||
(when-let [id (:photo-id team)]
|
||||
(sto/touch-object! storage id))
|
||||
|
||||
;; Save new photo
|
||||
(db/update! pool :team
|
||||
{:photo-id (:id photo)}
|
||||
{:id team-id})
|
||||
|
||||
(assoc team :photo-id (:id photo))))
|
||||
|
||||
(defn upload-photo
|
||||
[{:keys [storage executor climit] :as cfg} {:keys [file]}]
|
||||
(letfn [(get-info [content]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
(generate-thumbnail [info]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :profile-thumbnail
|
||||
:format :jpeg
|
||||
:quality 85
|
||||
:width 256
|
||||
:height 256
|
||||
:input info})))
|
||||
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch executor
|
||||
(sto/calculate-hash data)))]
|
||||
|
||||
(p/let [info (get-info file)
|
||||
thumb (generate-thumbnail info)
|
||||
hash (calculate-hash (:data thumb))
|
||||
content (-> (sto/content (:data thumb) (:size thumb))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage {::sto/content content
|
||||
::sto/deduplicate? true
|
||||
:bucket "profile"
|
||||
:content-type (:mtype thumb)}))))
|
||||
|
||||
;; --- Mutation: Create Team Invitation
|
||||
|
||||
(def sql:upsert-team-invitation
|
||||
"insert into team_invitation(id, team_id, email_to, role, valid_until)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(team_id, email_to) 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]}]
|
||||
(tokens/generate (::main/props cfg)
|
||||
{:iss :team-invitation
|
||||
:exp valid-until
|
||||
:profile-id profile-id
|
||||
:role role
|
||||
:team-id team-id
|
||||
:member-email member-email
|
||||
:member-id member-id}))
|
||||
|
||||
(defn- create-profile-identity-token
|
||||
[cfg profile]
|
||||
(tokens/generate (::main/props cfg)
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (dt/in-future {:days 30})}))
|
||||
|
||||
(defn- create-invitation
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
|
||||
(let [member (profile/retrieve-profile-data-by-email conn email)]
|
||||
|
||||
(when (and member (not (eml/allow-send-emails? conn member)))
|
||||
(ex/raise :type :validation
|
||||
:code :member-is-muted
|
||||
:email email
|
||||
:hint "the profile has reported repeatedly as spam or has bounces"))
|
||||
|
||||
;; Secondly check if the invited member email is part of the global spam/bounce report.
|
||||
(when (eml/has-bounce-reports? conn email)
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:email email
|
||||
:hint "the email you invite has been repeatedly reported as spam or bounce"))
|
||||
|
||||
;; 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.
|
||||
|
||||
;; TODO: if member does not exists and email verification is
|
||||
;; disabled, we should proceed to create the profile (?)
|
||||
(if (and (not (contains? cf/flags :email-verification))
|
||||
(some? member))
|
||||
(let [params (merge {:team-id (:id team)
|
||||
:profile-id (:id member)}
|
||||
(role->params role))]
|
||||
|
||||
;; Insert the invited member to the team
|
||||
(db/insert! conn :team-profile-rel params {:on-conflict-do-nothing 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)}))
|
||||
|
||||
nil)
|
||||
(let [id (uuid/next)
|
||||
expire (dt/in-future "168h") ;; 7 days
|
||||
invitation (db/exec-one! conn [sql:upsert-team-invitation id
|
||||
(:id team) (str/lower email)
|
||||
(name role) expire
|
||||
(name role) expire])
|
||||
updated? (not= id (:id invitation))
|
||||
tprops {:profile-id (:id profile)
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
:team-id (:id team)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
itoken (create-invitation-token cfg tprops)
|
||||
ptoken (create-profile-identity-token cfg profile)]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/info :hint "invitation token" :token itoken))
|
||||
|
||||
(audit/submit! cfg
|
||||
{:type "action"
|
||||
:name (if updated?
|
||||
"update-team-invitation"
|
||||
"create-team-invitation")
|
||||
:profile-id (:id profile)
|
||||
:props (-> (dissoc tprops :profile-id)
|
||||
(d/without-nils))})
|
||||
|
||||
(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})
|
||||
|
||||
itoken))))
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::emails ::us/set-of-valid-emails)
|
||||
(s/def ::create-team-invitations
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::role]
|
||||
:opt-un [::email ::emails]))
|
||||
|
||||
(sv/defmethod ::create-team-invitations
|
||||
"A rpc call that allow to send a single or multiple invitations to
|
||||
join the team."
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id email emails role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (get-permissions conn profile-id team-id)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
team (db/get-by-id conn :team team-id)
|
||||
emails (cond-> (or emails #{}) (string? email) (conj email))]
|
||||
|
||||
|
||||
(run! (partial quotes/check-quote! conn)
|
||||
(list {::quotes/id ::quotes/invitations-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id (:id team)
|
||||
::quotes/incr (count emails)}
|
||||
{::quotes/id ::quotes/profiles-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id (:id team)
|
||||
::quotes/incr (count emails)}))
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
;; First check if the current profile is allowed to send emails.
|
||||
(when-not (eml/allow-send-emails? conn profile)
|
||||
(ex/raise :type :validation
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
|
||||
(let [cfg (assoc cfg ::db/conn conn)
|
||||
invitations (->> emails
|
||||
(map (fn [email]
|
||||
{:email (str/lower email)
|
||||
:team team
|
||||
:profile profile
|
||||
:role role}))
|
||||
(keep (partial create-invitation cfg)))]
|
||||
(with-meta (vec invitations)
|
||||
{::audit/props {:invitations (count invitations)}})))))
|
||||
|
||||
|
||||
;; --- Mutation: Create Team & Invite Members
|
||||
|
||||
(s/def ::emails ::us/set-of-valid-emails)
|
||||
(s/def ::create-team-with-invitations
|
||||
(s/merge ::create-team
|
||||
(s/keys :req-un [::emails ::role])))
|
||||
|
||||
(sv/defmethod ::create-team-with-invitations
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id emails role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [params (assoc params :profile-id profile-id)
|
||||
team (create-team conn params)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
cfg (assoc cfg ::db/conn conn)]
|
||||
|
||||
;; Create invitations for all provided emails.
|
||||
(->> emails
|
||||
(map (fn [email]
|
||||
{:team team
|
||||
:profile profile
|
||||
:email (str/lower email)
|
||||
:role role}))
|
||||
(run! (partial create-invitation cfg)))
|
||||
|
||||
(run! (partial quotes/check-quote! conn)
|
||||
(list {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id}
|
||||
{::quotes/id ::quotes/invitations-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id (:id team)
|
||||
::quotes/incr (count emails)}
|
||||
{::quotes/id ::quotes/profiles-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id (:id team)
|
||||
::quotes/incr (count emails)}))
|
||||
|
||||
(audit/submit! cfg
|
||||
{:type "command"
|
||||
:name "create-team-invitations"
|
||||
:profile-id profile-id
|
||||
:props {:emails emails
|
||||
:role role
|
||||
:profile-id profile-id
|
||||
:invitations (count emails)}})
|
||||
|
||||
(vary-meta team assoc ::audit/props {:invitations (count emails)}))))
|
||||
|
||||
;; --- Query: get-team-invitation-token
|
||||
|
||||
(s/def ::get-team-invitation-token
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::email]))
|
||||
|
||||
(sv/defmethod ::get-team-invitation-token
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
|
||||
(check-read-permissions! pool profile-id team-id)
|
||||
(let [invit (-> (db/get pool :team-invitation
|
||||
{:team-id team-id
|
||||
:email-to (str/lower email)})
|
||||
(update :role keyword))
|
||||
member (profile/retrieve-profile-data-by-email pool (:email-to invit))
|
||||
token (create-invitation-token cfg {:team-id (:team-id invit)
|
||||
:profile-id profile-id
|
||||
:valid-until (:valid-until invit)
|
||||
:role (:role invit)
|
||||
:member-id (:id member)
|
||||
:member-email (or (:email member) (:email-to invit))})]
|
||||
{:token token}))
|
||||
|
||||
;; --- Mutation: Update invitation role
|
||||
|
||||
(s/def ::update-team-invitation-role
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::email ::role]))
|
||||
|
||||
(sv/defmethod ::update-team-invitation-role
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (get-permissions conn profile-id team-id)]
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(db/update! conn :team-invitation
|
||||
{:role (name role) :updated-at (dt/now)}
|
||||
{:team-id team-id :email-to (str/lower email)})
|
||||
nil)))
|
||||
|
||||
;; --- Mutation: Delete invitation
|
||||
|
||||
(s/def ::delete-team-invitation
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::email]))
|
||||
|
||||
(sv/defmethod ::delete-team-invitation
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (get-permissions conn profile-id team-id)]
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(let [invitation (db/delete! conn :team-invitation
|
||||
{:team-id team-id
|
||||
:email-to (str/lower email)})]
|
||||
(rph/wrap nil {::audit/props {:invitation-id (:id invitation)}})))))
|
||||
@@ -11,10 +11,12 @@
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.tokens :as tokens]
|
||||
[app.tokens.spec.team-invitation :as-alias spec.team-invitation]
|
||||
[app.util.services :as sv]
|
||||
@@ -27,10 +29,10 @@
|
||||
|
||||
(s/def ::verify-token
|
||||
(s/keys :req-un [::token]
|
||||
:opt-un [::profile-id]))
|
||||
:opt [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::verify-token
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[{:keys [pool sprops] :as cfg} {:keys [token] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -95,6 +97,11 @@
|
||||
(ex/raise :type :restriction
|
||||
:code :profile-blocked))
|
||||
|
||||
(quotes/check-quote! conn
|
||||
{::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 {:on-conflict-do-nothing true})
|
||||
|
||||
@@ -126,10 +133,11 @@
|
||||
:opt-un [::spec.team-invitation/member-id]))
|
||||
|
||||
(defmethod process-token :team-invitation
|
||||
[{:keys [conn session] :as cfg} {:keys [profile-id token]}
|
||||
[{:keys [conn] :as cfg}
|
||||
{:keys [::rpc/profile-id token]}
|
||||
{:keys [member-id team-id member-email] :as claims}]
|
||||
|
||||
(us/assert ::team-invitation-claims claims)
|
||||
(us/verify! ::team-invitation-claims claims)
|
||||
|
||||
(let [invitation (db/get* conn :team-invitation
|
||||
{:team-id team-id :email-to member-email})
|
||||
@@ -144,45 +152,30 @@
|
||||
(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 invited team.
|
||||
|
||||
;; if we have logged-in user and it matches the invitation we proceed
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
;; invited team.
|
||||
(let [profile (accept-invitation cfg claims invitation profile)]
|
||||
(-> (assoc claims :state :created)
|
||||
(rph/with-meta {::audit/name "accept-team-invitation"
|
||||
::audit/props (merge
|
||||
(audit/profile->props profile)
|
||||
{:team-id (:team-id claims)
|
||||
:role (:role claims)})
|
||||
::audit/profile-id profile-id})))
|
||||
::audit/profile-id (:id profile)
|
||||
::audit/props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}})))
|
||||
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "logged-in user does not matches the invitation"))
|
||||
|
||||
;; If we have not logged-in user, we try find the invited
|
||||
;; profile by member-id or member-email props of the invitation
|
||||
;; token; If profile is found, we accept the invitation and
|
||||
;; leave the user logged-in.
|
||||
(if-let [member (db/get* conn :profile
|
||||
(if member-id
|
||||
{:id member-id}
|
||||
{:email member-email})
|
||||
{:columns [:id :email]})]
|
||||
(let [profile (accept-invitation cfg claims invitation member)]
|
||||
(-> (assoc claims :state :created)
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-meta {::audit/name "accept-team-invitation"
|
||||
::audit/props (merge
|
||||
(audit/profile->props profile)
|
||||
{:team-id (:team-id claims)
|
||||
:role (:role claims)})
|
||||
::audit/profile-id member-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 in the invitation
|
||||
;; token, we redirect user the the register page.
|
||||
|
||||
{:invitation-token token
|
||||
:iss :team-invitation
|
||||
:redirect-to :auth-register
|
||||
:state :pending}))))
|
||||
{:invitation-token token
|
||||
:iss :team-invitation
|
||||
:redirect-to (if member-id :auth-login :auth-register)
|
||||
:state :pending})))
|
||||
|
||||
;; --- Default
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.comments :as comments]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
@@ -73,16 +74,16 @@
|
||||
|
||||
(s/def ::get-view-only-bundle
|
||||
(s/keys :req-un [::files/file-id]
|
||||
:opt-un [::files/profile-id
|
||||
::files/share-id
|
||||
::files/features]))
|
||||
:opt-un [::files/share-id
|
||||
::files/features]
|
||||
:opt [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::get-view-only-bundle
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::cond/get-object #(files/get-minimal-file %1 (:file-id %2))
|
||||
::cond/key-fn files/get-file-etag
|
||||
::cond/reuse-key? true
|
||||
::doc/added "1.17"}
|
||||
[{:keys [pool]} params]
|
||||
[{:keys [pool]} {:keys [::rpc/profile-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(get-view-only-bundle conn params)))
|
||||
(get-view-only-bundle conn (assoc params :profile-id profile-id))))
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.webhooks :as webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :refer [check-edition-permissions! check-read-permissions!]]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.queries.teams :refer [check-edition-permissions! check-read-permissions!]]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
@@ -21,19 +23,23 @@
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn decode-row
|
||||
[{:keys [uri] :as row}]
|
||||
(cond-> row
|
||||
(string? uri) (assoc :uri (u/uri uri))))
|
||||
|
||||
;; --- Mutation: Create Webhook
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::uri ::us/not-empty-string)
|
||||
(s/def ::uri ::us/uri)
|
||||
(s/def ::is-active ::us/boolean)
|
||||
(s/def ::mtype
|
||||
#{"application/json"
|
||||
"application/x-www-form-urlencoded"
|
||||
"application/transit+json"})
|
||||
|
||||
(s/def ::create-webhook
|
||||
(s/keys :req-un [::profile-id ::team-id ::uri ::mtype]
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::uri ::mtype]
|
||||
:opt-un [::is-active]))
|
||||
|
||||
;; NOTE: for now the quote is hardcoded but this need to be solved in
|
||||
@@ -59,7 +65,7 @@
|
||||
|
||||
(if (not= (:uri whook) (:uri params))
|
||||
(->> (http/req! cfg {:method :head
|
||||
:uri (:uri params)
|
||||
:uri (str (:uri params))
|
||||
:timeout (dt/duration "3s")})
|
||||
(p/hmap (fn [response exception]
|
||||
(if exception
|
||||
@@ -74,33 +80,36 @@
|
||||
(when (>= total max-hooks-for-team)
|
||||
(ex/raise :type :restriction
|
||||
:code :webhooks-quote-reached
|
||||
:hint (str/ffmt "can't create more than % webhooks per team" max-hooks-for-team)))))
|
||||
:hint (str/ffmt "can't create more than % webhooks per team"
|
||||
max-hooks-for-team)))))
|
||||
|
||||
(defn- insert-webhook!
|
||||
[{:keys [::db/pool]} {:keys [team-id uri mtype is-active] :as params}]
|
||||
(db/insert! pool :webhook
|
||||
{:id (uuid/next)
|
||||
:team-id team-id
|
||||
:uri uri
|
||||
:is-active is-active
|
||||
:mtype mtype}))
|
||||
(-> (db/insert! pool :webhook
|
||||
{:id (uuid/next)
|
||||
:team-id team-id
|
||||
:uri (str uri)
|
||||
:is-active is-active
|
||||
:mtype mtype})
|
||||
(decode-row)))
|
||||
|
||||
(defn- update-webhook!
|
||||
[{:keys [::db/pool] :as cfg} {:keys [id] :as wook} {:keys [uri mtype is-active] :as params}]
|
||||
(db/update! pool :webhook
|
||||
{:uri uri
|
||||
:is-active is-active
|
||||
:mtype mtype
|
||||
:error-code nil
|
||||
:error-count 0}
|
||||
{:id id}))
|
||||
(-> (db/update! pool :webhook
|
||||
{:uri (str uri)
|
||||
:is-active is-active
|
||||
:mtype mtype
|
||||
:error-code nil
|
||||
:error-count 0}
|
||||
{:id id})
|
||||
(decode-row)))
|
||||
|
||||
(sv/defmethod ::create-webhook
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(check-edition-permissions! pool profile-id team-id)
|
||||
(->> (validate-quotes! cfg params)
|
||||
(p/fmap executor (fn [_] (validate-webhook! cfg nil params)))
|
||||
(validate-quotes! cfg params)
|
||||
(->> (validate-webhook! cfg nil params)
|
||||
(p/fmap executor (fn [_] (insert-webhook! cfg params)))))
|
||||
|
||||
(s/def ::update-webhook
|
||||
@@ -108,20 +117,21 @@
|
||||
|
||||
(sv/defmethod ::update-webhook
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(let [whook (db/get pool :webhook {:id id})]
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
||||
(check-edition-permissions! pool profile-id (:team-id whook))
|
||||
(->> (validate-webhook! cfg whook params)
|
||||
(p/fmap executor (fn [_] (update-webhook! cfg whook params))))))
|
||||
|
||||
(s/def ::delete-webhook
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::id]))
|
||||
|
||||
(sv/defmethod ::delete-webhook
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id id]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [whook (db/get conn :webhook {:id id})]
|
||||
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
||||
(check-edition-permissions! conn profile-id (:team-id whook))
|
||||
(db/delete! conn :webhook {:id id})
|
||||
nil)))
|
||||
@@ -130,14 +140,16 @@
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::get-webhooks
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id]))
|
||||
|
||||
(def sql:get-webhooks
|
||||
"select id, uri, mtype, is_active, error_code, error_count
|
||||
from webhook where team_id = ? order by uri")
|
||||
|
||||
(sv/defmethod ::get-webhooks
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(db/exec! conn [sql:get-webhooks team-id])))
|
||||
(->> (db/exec! conn [sql:get-webhooks team-id])
|
||||
(mapv decode-row))))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.config :as cf]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.services :as sv]
|
||||
[app.util.template :as tmpl]
|
||||
@@ -35,6 +36,7 @@
|
||||
:name (d/name name)
|
||||
:module (-> (:ns mdata) (str/split ".") last)
|
||||
:auth (:auth mdata true)
|
||||
:webhook (::webhooks/event? mdata false)
|
||||
:docs (::sv/docstring mdata)
|
||||
:deprecated (::deprecated mdata)
|
||||
:added (::added mdata)
|
||||
@@ -51,6 +53,7 @@
|
||||
(->> (:queries methods)
|
||||
(map (partial gen-doc :query))
|
||||
(sort-by (juxt :module :name)))
|
||||
|
||||
:mutation-methods
|
||||
(->> (:mutations methods)
|
||||
(map (partial gen-doc :query))
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
;; 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.mutations.comments
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.comments :as cmd.comments]
|
||||
[app.rpc.commands.files :as cmd.files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.retry :as retry]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Mutation: Create Comment Thread
|
||||
|
||||
(s/def ::create-comment-thread ::cmd.comments/create-comment-thread)
|
||||
|
||||
(sv/defmethod ::create-comment-thread
|
||||
{::retry/max-retries 3
|
||||
::retry/matches retry/conflict-db-insert?
|
||||
::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(cmd.comments/create-comment-thread conn params)))
|
||||
|
||||
;; --- Mutation: Update Comment Thread Status
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::share-id (s/nilable ::us/uuid))
|
||||
|
||||
(s/def ::update-comment-thread-status ::cmd.comments/update-comment-thread-status)
|
||||
|
||||
(sv/defmethod ::update-comment-thread-status
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not cthr (ex/raise :type :not-found))
|
||||
(cmd.files/check-comment-permissions! conn profile-id (:file-id cthr) share-id)
|
||||
(cmd.comments/upsert-comment-thread-status! conn profile-id (:id cthr)))))
|
||||
|
||||
|
||||
;; --- Mutation: Update Comment Thread
|
||||
|
||||
(s/def ::update-comment-thread ::cmd.comments/update-comment-thread)
|
||||
|
||||
(sv/defmethod ::update-comment-thread
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id is-resolved share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not thread
|
||||
(ex/raise :type :not-found))
|
||||
|
||||
(cmd.files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:is-resolved is-resolved}
|
||||
{:id id})
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- Mutation: Add Comment
|
||||
|
||||
(s/def ::add-comment ::cmd.comments/create-comment)
|
||||
|
||||
(sv/defmethod ::add-comment
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.comments/create-comment conn params)))
|
||||
|
||||
|
||||
;; --- Mutation: Update Comment
|
||||
|
||||
(s/def ::update-comment ::cmd.comments/update-comment)
|
||||
|
||||
(sv/defmethod ::update-comment
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.comments/update-comment conn params)))
|
||||
|
||||
|
||||
;; --- Mutation: Delete Comment Thread
|
||||
|
||||
(s/def ::delete-comment-thread ::cmd.comments/delete-comment-thread)
|
||||
|
||||
(sv/defmethod ::delete-comment-thread
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
|
||||
(when-not (= (:owner-id thread) profile-id)
|
||||
(ex/raise :type :validation :code :not-allowed))
|
||||
(db/delete! conn :comment-thread {:id id})
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- Mutation: Delete comment
|
||||
|
||||
(s/def ::delete-comment ::cmd.comments/delete-comment)
|
||||
|
||||
(sv/defmethod ::delete-comment
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [comment (db/get-by-id conn :comment id {:for-update true})]
|
||||
(when-not (= (:owner-id comment) profile-id)
|
||||
(ex/raise :type :validation :code :not-allowed))
|
||||
(db/delete! conn :comment {:id id}))))
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as cmd.files]
|
||||
[app.rpc.commands.files.create :as cmd.files.create]
|
||||
@@ -81,7 +81,8 @@
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.files/check-edition-permissions! conn profile-id id)
|
||||
(cmd.files/absorb-library conn params)
|
||||
(cmd.files/mark-file-deleted conn params)))
|
||||
(cmd.files/mark-file-deleted conn params)
|
||||
nil))
|
||||
|
||||
;; --- Mutation: Link file to library
|
||||
|
||||
@@ -175,7 +176,8 @@
|
||||
|
||||
(sv/defmethod ::upsert-file-object-thumbnail
|
||||
{::doc/added "1.13"
|
||||
::doc/deprecated "1.17"}
|
||||
::doc/deprecated "1.17"
|
||||
::audit/skip true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.files/check-edition-permissions! conn profile-id file-id)
|
||||
@@ -191,7 +193,8 @@
|
||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||
grid thumbnails."
|
||||
{::doc/added "1.13"
|
||||
::doc/deprecated "1.17"}
|
||||
::doc/deprecated "1.17"
|
||||
::audit/skip true}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.files/check-edition-permissions! conn profile-id file-id)
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -49,6 +50,9 @@
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}]
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(quotes/check-quote! pool {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
(create-font-variant cfg params)))
|
||||
|
||||
(defn create-font-variant
|
||||
@@ -110,12 +114,12 @@
|
||||
]
|
||||
|
||||
(->> (generate-fonts data)
|
||||
(p/map validate-data)
|
||||
(p/fmap validate-data)
|
||||
(p/mcat executor persist-fonts)
|
||||
(p/map executor insert-into-db)
|
||||
(p/map (fn [result]
|
||||
(let [params (update params :data (comp vec keys))]
|
||||
(rph/with-meta result {::audit/replace-props params})))))))
|
||||
(p/fmap executor insert-into-db)
|
||||
(p/fmap (fn [result]
|
||||
(let [params (update params :data (comp vec keys))]
|
||||
(rph/with-meta result {::audit/replace-props params})))))))
|
||||
|
||||
;; --- UPDATE FONT FAMILY
|
||||
|
||||
@@ -128,10 +132,15 @@
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id id name] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(db/update! conn :team-font-variant
|
||||
{:font-family name}
|
||||
{:font-id id
|
||||
:team-id team-id})))
|
||||
(rph/with-meta
|
||||
(db/update! conn :team-font-variant
|
||||
{:font-family name}
|
||||
{:font-id id
|
||||
:team-id team-id})
|
||||
{::audit/replace-props {:id id
|
||||
:name name
|
||||
:team-id team-id
|
||||
:profile-id profile-id}})))
|
||||
|
||||
;; --- DELETE FONT
|
||||
|
||||
@@ -144,10 +153,14 @@
|
||||
[{:keys [pool] :as cfg} {:keys [id team-id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:font-id id :team-id team-id})
|
||||
nil))
|
||||
(let [font (db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:font-id id :team-id team-id})]
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:id id
|
||||
:team-id team-id
|
||||
:name (:font-family font)
|
||||
:profile-id profile-id}}))))
|
||||
|
||||
;; --- DELETE FONT VARIANT
|
||||
|
||||
@@ -160,8 +173,9 @@
|
||||
[{:keys [pool] :as cfg} {:keys [id team-id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :team-id team-id})
|
||||
nil))
|
||||
(let [variant (db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :team-id team-id})]
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:font-family (:font-family variant)
|
||||
:font-id (:font-id variant)}}))))
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
;; 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.mutations.management
|
||||
"Move & Duplicate RPC methods for files and projects."
|
||||
(:require
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.management :as cmd.mgm]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- MUTATION: Duplicate File
|
||||
|
||||
(s/def ::duplicate-file ::cmd.mgm/duplicate-file)
|
||||
|
||||
(sv/defmethod ::duplicate-file
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.mgm/duplicate-file conn params)))
|
||||
|
||||
;; --- MUTATION: Duplicate Project
|
||||
|
||||
(s/def ::duplicate-project ::cmd.mgm/duplicate-project)
|
||||
|
||||
(sv/defmethod ::duplicate-project
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.mgm/duplicate-project conn params)))
|
||||
|
||||
;; --- MUTATION: Move file
|
||||
|
||||
(s/def ::move-files ::cmd.mgm/move-files)
|
||||
|
||||
(sv/defmethod ::move-files
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.mgm/move-files conn params)))
|
||||
|
||||
;; --- MUTATION: Move project
|
||||
|
||||
(s/def ::move-project ::cmd.mgm/move-project)
|
||||
|
||||
(sv/defmethod ::move-project
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.16"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(cmd.mgm/move-project conn params)))
|
||||
@@ -6,280 +6,49 @@
|
||||
|
||||
(ns app.rpc.mutations.media
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.media :as media]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.media :as cmd.media]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(def default-max-file-size (* 1024 1024 10)) ; 10 MiB
|
||||
|
||||
(def thumbnail-options
|
||||
{:width 100
|
||||
:height 100
|
||||
:quality 85
|
||||
:format :jpeg})
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Create File Media object (upload)
|
||||
|
||||
(declare create-file-media-object)
|
||||
(declare select-file)
|
||||
|
||||
(s/def ::content ::media/upload)
|
||||
(s/def ::is-local ::us/boolean)
|
||||
|
||||
(s/def ::upload-file-media-object
|
||||
(s/keys :req-un [::profile-id ::file-id ::is-local ::name ::content]
|
||||
:opt-un [::id]))
|
||||
(s/def ::upload-file-media-object ::cmd.media/upload-file-media-object)
|
||||
|
||||
(sv/defmethod ::upload-file-media-object
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id content] :as params}]
|
||||
(let [file (select-file pool file-id)
|
||||
cfg (update cfg :storage media/configure-assets-storage)]
|
||||
|
||||
(teams/check-edition-permissions! pool profile-id (:team-id file))
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(media/validate-media-type! content)
|
||||
|
||||
(when (> (:size content) (cf/get :media-max-file-size default-max-file-size))
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size content)
|
||||
default-max-file-size)))
|
||||
|
||||
(create-file-media-object cfg params)))
|
||||
|
||||
(defn- big-enough-for-thumbnail?
|
||||
"Checks if the provided image info is big enough for
|
||||
create a separate thumbnail storage object."
|
||||
[info]
|
||||
(or (> (:width info) (:width thumbnail-options))
|
||||
(> (:height info) (:height thumbnail-options))))
|
||||
|
||||
(defn- svg-image?
|
||||
[info]
|
||||
(= (:mtype info) "image/svg+xml"))
|
||||
|
||||
;; NOTE: we use the `on conflict do update` instead of `do nothing`
|
||||
;; because postgresql does not returns anything if no update is
|
||||
;; performed, the `do update` does the trick.
|
||||
|
||||
(def sql:create-file-media-object
|
||||
"insert into file_media_object (id, file_id, is_local, name, media_id, thumbnail_id, width, height, mtype)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict (id) do update set created_at=file_media_object.created_at
|
||||
returning *")
|
||||
|
||||
;; NOTE: the following function executes without a transaction, this
|
||||
;; means that if something fails in the middle of this function, it
|
||||
;; will probably leave leaked/unreferenced objects in the database and
|
||||
;; probably in the storage layer. For handle possible object leakage,
|
||||
;; we create all media objects marked as touched, this ensures that if
|
||||
;; something fails, all leaked (already created storage objects) will
|
||||
;; be eventually marked as deleted by the touched-gc task.
|
||||
;;
|
||||
;; The touched-gc task, performs periodic analysis of all touched
|
||||
;; storage objects and check references of it. This is the reason why
|
||||
;; `reference` metadata exists: it indicates the name of the table
|
||||
;; witch holds the reference to storage object (it some kind of
|
||||
;; inverse, soft referential integrity).
|
||||
|
||||
(defn create-file-media-object
|
||||
[{:keys [storage pool climit executor] :as cfg}
|
||||
{:keys [id file-id is-local name content] :as params}]
|
||||
(letfn [;; Function responsible to retrieve the file information, as
|
||||
;; it is synchronous operation it should be wrapped into
|
||||
;; with-dispatch macro.
|
||||
(get-info [content]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch executor
|
||||
(sto/calculate-hash data)))
|
||||
|
||||
;; Function responsible of generating thumnail. As it is synchronous
|
||||
;; opetation, it should be wrapped into with-dispatch macro
|
||||
(generate-thumbnail [info]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run (assoc thumbnail-options
|
||||
:cmd :generic-thumbnail
|
||||
:input info))))
|
||||
|
||||
(create-thumbnail [info]
|
||||
(when (and (not (svg-image? info))
|
||||
(big-enough-for-thumbnail? info))
|
||||
(p/let [thumb (generate-thumbnail info)
|
||||
hash (calculate-hash (:data thumb))
|
||||
content (-> (sto/content (:data thumb) (:size thumb))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage
|
||||
{::sto/content content
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (dt/now)
|
||||
:content-type (:mtype thumb)
|
||||
:bucket "file-media-object"}))))
|
||||
|
||||
(create-image [info]
|
||||
(p/let [data (:path info)
|
||||
hash (calculate-hash data)
|
||||
content (-> (sto/content data)
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage
|
||||
{::sto/content content
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (dt/now)
|
||||
:content-type (:mtype info)
|
||||
:bucket "file-media-object"})))
|
||||
|
||||
(insert-into-database [info image thumb]
|
||||
(px/with-dispatch executor
|
||||
(db/exec-one! pool [sql:create-file-media-object
|
||||
(or id (uuid/next))
|
||||
file-id is-local name
|
||||
(:id image)
|
||||
(:id thumb)
|
||||
(:width info)
|
||||
(:height info)
|
||||
(:mtype info)])))]
|
||||
|
||||
(p/let [info (get-info content)
|
||||
thumb (create-thumbnail info)
|
||||
image (create-image info)]
|
||||
(insert-into-database info image thumb))))
|
||||
(cmd.media/validate-content-size! content)
|
||||
(cmd.media/create-file-media-object cfg params)))
|
||||
|
||||
;; --- Create File Media Object (from URL)
|
||||
|
||||
(declare ^:private create-file-media-object-from-url)
|
||||
|
||||
(s/def ::create-file-media-object-from-url
|
||||
(s/keys :req-un [::profile-id ::file-id ::is-local ::url]
|
||||
:opt-un [::id ::name]))
|
||||
(s/def ::create-file-media-object-from-url ::cmd.media/create-file-media-object-from-url)
|
||||
|
||||
(sv/defmethod ::create-file-media-object-from-url
|
||||
{::doc/added "1.3"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(let [file (select-file pool file-id)
|
||||
cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(teams/check-edition-permissions! pool profile-id (:team-id file))
|
||||
(create-file-media-object-from-url cfg params)))
|
||||
|
||||
(defn- create-file-media-object-from-url
|
||||
[cfg {:keys [url name] :as params}]
|
||||
(letfn [(parse-and-validate-size [headers]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size
|
||||
:mtype mtype
|
||||
:format format}))
|
||||
|
||||
(download-media [uri]
|
||||
(-> (http/req! cfg {:method :get :uri uri} {:response-type :input-stream})
|
||||
(p/then process-response)))
|
||||
|
||||
(process-response [{:keys [body headers] :as response}]
|
||||
(let [{:keys [size mtype]} (parse-and-validate-size headers)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write-to-file! body path :size size)]
|
||||
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
{:filename "tempfile"
|
||||
:size size
|
||||
:path path
|
||||
:mtype mtype}))]
|
||||
|
||||
(p/let [content (download-media url)]
|
||||
(->> (merge params {:content content :name (or name (:filename content))})
|
||||
(create-file-media-object cfg)))))
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(#'cmd.media/create-file-media-object-from-url cfg params)))
|
||||
|
||||
;; --- Clone File Media object (Upload and create from url)
|
||||
|
||||
(declare clone-file-media-object)
|
||||
|
||||
(s/def ::clone-file-media-object
|
||||
(s/keys :req-un [::profile-id ::file-id ::is-local ::id]))
|
||||
(s/def ::clone-file-media-object ::cmd.media/clone-file-media-object)
|
||||
|
||||
(sv/defmethod ::clone-file-media-object
|
||||
{::doc/added "1.2"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [file (select-file conn file-id)]
|
||||
(teams/check-edition-permissions! conn profile-id (:team-id file))
|
||||
(-> (assoc cfg :conn conn)
|
||||
(clone-file-media-object params)))))
|
||||
|
||||
(defn clone-file-media-object
|
||||
[{:keys [conn] :as cfg} {:keys [id file-id is-local]}]
|
||||
(let [mobj (db/get-by-id conn :file-media-object id)]
|
||||
(db/insert! conn :file-media-object
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
:is-local is-local
|
||||
:name (:name mobj)
|
||||
:media-id (:media-id mobj)
|
||||
:thumbnail-id (:thumbnail-id mobj)
|
||||
:width (:width mobj)
|
||||
:height (:height mobj)
|
||||
:mtype (:mtype mobj)})))
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
(def ^:private
|
||||
sql:select-file
|
||||
"select file.*,
|
||||
project.team_id as team_id
|
||||
from file
|
||||
inner join project on (project.id = file.project_id)
|
||||
where file.id = ?")
|
||||
|
||||
(defn- select-file
|
||||
[conn id]
|
||||
(let [row (db/exec-one! conn [sql:select-file id])]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found))
|
||||
row))
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(-> (assoc cfg :conn conn)
|
||||
(cmd.media/clone-file-media-object params))))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.rpc.mutations.profile
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
@@ -18,9 +19,9 @@
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
@@ -111,7 +112,7 @@
|
||||
(defn- validate-password!
|
||||
[conn {:keys [profile-id old-password] :as params}]
|
||||
(let [profile (db/get-by-id conn :profile profile-id)]
|
||||
(when-not (:valid (cmd.auth/verify-password old-password (:password profile)))
|
||||
(when-not (:valid (auth/verify-password old-password (:password profile)))
|
||||
(ex/raise :type :validation
|
||||
:code :old-password-not-match))
|
||||
profile))
|
||||
@@ -119,7 +120,7 @@
|
||||
(defn update-profile-password!
|
||||
[conn {:keys [id password] :as profile}]
|
||||
(db/update! conn :profile
|
||||
{:password (cmd.auth/derive-password password)}
|
||||
{:password (auth/derive-password password)}
|
||||
{:id id}))
|
||||
|
||||
;; --- MUTATION: Update Photo
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
(ns app.rpc.mutations.projects
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
@@ -28,10 +27,6 @@
|
||||
|
||||
;; --- Mutation: Create Project
|
||||
|
||||
(declare create-project)
|
||||
(declare create-project-role)
|
||||
(declare create-team-project-profile)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::create-project
|
||||
(s/keys :req-un [::profile-id ::team-id ::name]
|
||||
@@ -43,41 +38,21 @@
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(let [project (create-project conn params)
|
||||
params (assoc params
|
||||
:project-id (:id project)
|
||||
:role :owner)]
|
||||
(create-project-role conn params)
|
||||
(create-team-project-profile conn params)
|
||||
(quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
|
||||
(let [project (teams/create-project conn params)]
|
||||
(teams/create-project-role conn profile-id (:id project) :owner)
|
||||
|
||||
(db/insert! conn :team-project-profile-rel
|
||||
{:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:team-id team-id
|
||||
:is-pinned true})
|
||||
|
||||
(assoc project :is-pinned true))))
|
||||
|
||||
(defn create-project
|
||||
[conn {:keys [id team-id name is-default] :as params}]
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)]
|
||||
(db/insert! conn :project
|
||||
{:id id
|
||||
:name name
|
||||
:team-id team-id
|
||||
:is-default is-default})))
|
||||
|
||||
(defn create-project-role
|
||||
[conn {:keys [project-id profile-id role]}]
|
||||
(let [params {:project-id project-id
|
||||
:profile-id profile-id}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :project-profile-rel))))
|
||||
|
||||
;; TODO: pending to be refactored
|
||||
(defn create-team-project-profile
|
||||
[conn {:keys [team-id project-id profile-id] :as params}]
|
||||
(db/insert! conn :team-project-profile-rel
|
||||
{:project-id project-id
|
||||
:profile-id profile-id
|
||||
:team-id team-id
|
||||
:is-pinned true}))
|
||||
|
||||
|
||||
;; --- Mutation: Toggle Project Pin
|
||||
|
||||
(def ^:private
|
||||
@@ -94,13 +69,16 @@
|
||||
(s/keys :req-un [::profile-id ::id ::team-id ::is-pinned]))
|
||||
|
||||
(sv/defmethod ::update-project-pin
|
||||
{::doc/added "1.0"
|
||||
::webhooks/batch-timeout (dt/duration "5s")
|
||||
::webhooks/batch-key :id
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id team-id is-pinned] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id id)
|
||||
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
|
||||
nil))
|
||||
|
||||
|
||||
;; --- Mutation: Rename Project
|
||||
|
||||
(declare rename-project)
|
||||
@@ -109,13 +87,19 @@
|
||||
(s/keys :req-un [::profile-id ::name ::id]))
|
||||
|
||||
(sv/defmethod ::rename-project
|
||||
{::doc/added "1.0"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id name] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :project
|
||||
{:name name}
|
||||
{:id id})
|
||||
nil))
|
||||
(let [project (db/get-by-id conn :project id)]
|
||||
(db/update! conn :project
|
||||
{:name name}
|
||||
{:id id})
|
||||
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:team-id (:team-id project)
|
||||
:prev-name (:name project)}}))))
|
||||
|
||||
;; --- Mutation: Delete Project
|
||||
|
||||
@@ -136,4 +120,7 @@
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :is-default false})]
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:team-id (:team-id project)}}))))
|
||||
{::audit/props {:team-id (:team-id project)
|
||||
:name (:name project)
|
||||
:created-at (:created-at project)
|
||||
:modified-at (:modified-at project)}}))))
|
||||
|
||||
@@ -6,30 +6,19 @@
|
||||
|
||||
(ns app.rpc.mutations.teams
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.emails :as eml]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.teams :as cmd.teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.mutations.projects :as projects]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
@@ -39,148 +28,54 @@
|
||||
|
||||
;; --- Mutation: Create Team
|
||||
|
||||
(declare create-team)
|
||||
(declare create-team-entry)
|
||||
(declare create-team-role)
|
||||
(declare create-team-default-project)
|
||||
|
||||
(s/def ::create-team
|
||||
(s/keys :req-un [::profile-id ::name]
|
||||
:opt-un [::id]))
|
||||
(s/def ::create-team ::cmd.teams/create-team)
|
||||
|
||||
(sv/defmethod ::create-team
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(create-team conn params)))
|
||||
|
||||
(defn create-team
|
||||
"This is a complete team creation process, it creates the team
|
||||
object and all related objects (default role and default project)."
|
||||
[conn params]
|
||||
(let [team (create-team-entry conn params)
|
||||
params (assoc params
|
||||
:team-id (:id team)
|
||||
:role :owner)
|
||||
project (create-team-default-project conn params)]
|
||||
(create-team-role conn params)
|
||||
(assoc team :default-project-id (:id project))))
|
||||
|
||||
(defn- create-team-entry
|
||||
[conn {:keys [id name is-default] :as params}]
|
||||
(let [id (or id (uuid/next))
|
||||
is-default (if (boolean? is-default) is-default false)]
|
||||
(db/insert! conn :team
|
||||
{:id id
|
||||
:name name
|
||||
:is-default is-default})))
|
||||
|
||||
(defn- create-team-role
|
||||
[conn {:keys [team-id profile-id role] :as params}]
|
||||
(let [params {:team-id team-id
|
||||
:profile-id profile-id}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :team-profile-rel))))
|
||||
|
||||
(defn- create-team-default-project
|
||||
[conn {:keys [team-id profile-id] :as params}]
|
||||
(let [project {:id (uuid/next)
|
||||
:team-id team-id
|
||||
:name "Drafts"
|
||||
:is-default true}
|
||||
project (projects/create-project conn project)]
|
||||
(projects/create-project-role conn {:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:role :owner})
|
||||
project))
|
||||
(cmd.teams/create-team conn params)))
|
||||
|
||||
;; --- Mutation: Update Team
|
||||
|
||||
(s/def ::update-team
|
||||
(s/keys :req-un [::profile-id ::name ::id]))
|
||||
(s/def ::update-team ::cmd.teams/update-team)
|
||||
|
||||
(sv/defmethod ::update-team
|
||||
[{:keys [pool] :as cfg} {:keys [id name profile-id] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [id name profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id id)
|
||||
(cmd.teams/check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id})
|
||||
nil))
|
||||
|
||||
|
||||
;; --- Mutation: Leave Team
|
||||
|
||||
(declare role->params)
|
||||
|
||||
(s/def ::reassign-to ::us/uuid)
|
||||
(s/def ::leave-team
|
||||
(s/keys :req-un [::profile-id ::id]
|
||||
:opt-un [::reassign-to]))
|
||||
(s/def ::leave-team ::cmd.teams/leave-team)
|
||||
|
||||
(sv/defmethod ::leave-team
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id reassign-to]}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id id)
|
||||
members (teams/retrieve-team-members conn id)]
|
||||
|
||||
(cond
|
||||
;; we can only proceed if there are more members in the team
|
||||
;; besides the current profile
|
||||
(<= (count members) 1)
|
||||
(ex/raise :type :validation
|
||||
:code :no-enough-members-for-leave
|
||||
:context {:members (count members)})
|
||||
|
||||
;; 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))
|
||||
(let [member (d/seek #(= reassign-to (:id %)) members)]
|
||||
(when-not member
|
||||
(ex/raise :type :not-found :code :member-does-not-exist))
|
||||
|
||||
;; unasign owner role to current profile
|
||||
(db/update! conn :team-profile-rel
|
||||
{:is-owner false}
|
||||
{:team-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
;; assign owner role to new profile
|
||||
(db/update! conn :team-profile-rel
|
||||
(role->params :owner)
|
||||
{:team-id id :profile-id reassign-to}))
|
||||
|
||||
;; and finally, if all other conditions does not match and the
|
||||
;; current profile is owner, we dont allow it because there
|
||||
;; must always be an owner.
|
||||
(:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :owner-cant-leave-team
|
||||
:hint "releasing owner before leave"))
|
||||
|
||||
(db/delete! conn :team-profile-rel
|
||||
{:profile-id profile-id
|
||||
:team-id id})
|
||||
|
||||
nil)))
|
||||
(cmd.teams/leave-team conn params)))
|
||||
|
||||
;; --- Mutation: Delete Team
|
||||
|
||||
(s/def ::delete-team
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
|
||||
;; TODO: right now just don't allow delete default team, in future it
|
||||
;; should raise a specific exception for signal that this action is
|
||||
;; not allowed.
|
||||
(s/def ::delete-team ::cmd.teams/delete-team)
|
||||
|
||||
(sv/defmethod ::delete-team
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id id)]
|
||||
(let [perms (cmd.teams/get-permissions conn profile-id id)]
|
||||
(when-not (:is-owner perms)
|
||||
(ex/raise :type :validation
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
(db/update! conn :team
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :is-default false})
|
||||
@@ -189,89 +84,29 @@
|
||||
|
||||
;; --- Mutation: Team Update Role
|
||||
|
||||
(declare retrieve-team-member)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::member-id ::us/uuid)
|
||||
;; Temporarily disabled viewer role
|
||||
;; https://tree.taiga.io/project/uxboxproject/issue/1083
|
||||
;; (s/def ::role #{:owner :admin :editor :viewer})
|
||||
(s/def ::role #{:owner :admin :editor})
|
||||
|
||||
(s/def ::update-team-member-role
|
||||
(s/keys :req-un [::profile-id ::team-id ::member-id ::role]))
|
||||
(s/def ::update-team-member-role ::cmd.teams/update-team-member-role)
|
||||
|
||||
(sv/defmethod ::update-team-member-role
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id member-id role] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)
|
||||
;; We retrieve all team members instead of query the
|
||||
;; database for a single member. This is just for
|
||||
;; convenience, if this becomes a bottleneck or problematic,
|
||||
;; we will change it to more efficient fetch mechanisms.
|
||||
members (teams/retrieve-team-members conn team-id)
|
||||
member (d/seek #(= member-id (:id %)) members)
|
||||
|
||||
is-owner? (:is-owner perms)
|
||||
is-admin? (:is-admin perms)]
|
||||
|
||||
;; If no member is found, just 404
|
||||
(when-not member
|
||||
(ex/raise :type :not-found
|
||||
:code :member-does-not-exist))
|
||||
|
||||
;; First check if we have permissions to change roles
|
||||
(when-not (or is-owner? is-admin?)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
;; Don't allow change role of owner member
|
||||
(when (:is-owner member)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-change-role-to-owner))
|
||||
|
||||
;; Don't allow promote to owner to admin users.
|
||||
(when (and (not is-owner?) (= role :owner))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-promote-to-owner))
|
||||
|
||||
(let [params (role->params role)]
|
||||
;; Only allow single owner on team
|
||||
(when (= role :owner)
|
||||
(db/update! conn :team-profile-rel
|
||||
{:is-owner false}
|
||||
{:team-id team-id
|
||||
:profile-id profile-id}))
|
||||
|
||||
(db/update! conn :team-profile-rel
|
||||
params
|
||||
{:team-id team-id
|
||||
:profile-id member-id})
|
||||
nil))))
|
||||
|
||||
(defn role->params
|
||||
[role]
|
||||
(case role
|
||||
:admin {:is-owner false :is-admin true :can-edit true}
|
||||
:editor {:is-owner false :is-admin false :can-edit true}
|
||||
:owner {:is-owner true :is-admin true :can-edit true}
|
||||
:viewer {:is-owner false :is-admin false :can-edit false}))
|
||||
|
||||
(cmd.teams/update-team-member-role conn params)))
|
||||
|
||||
;; --- Mutation: Delete Team Member
|
||||
|
||||
(s/def ::delete-team-member
|
||||
(s/keys :req-un [::profile-id ::team-id ::member-id]))
|
||||
(s/def ::delete-team-member ::cmd.teams/delete-team-member)
|
||||
|
||||
(sv/defmethod ::delete-team-member
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id member-id] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [team-id profile-id member-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)]
|
||||
(let [perms (cmd.teams/get-permissions conn profile-id team-id)]
|
||||
(when-not (or (:is-owner perms)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(when (= member-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-remove-yourself))
|
||||
@@ -283,85 +118,27 @@
|
||||
|
||||
;; --- Mutation: Update Team Photo
|
||||
|
||||
(declare ^:private upload-photo)
|
||||
(declare ^:private update-team-photo)
|
||||
|
||||
(s/def ::file ::media/upload)
|
||||
(s/def ::update-team-photo
|
||||
(s/keys :req-un [::profile-id ::team-id ::file]))
|
||||
(s/def ::update-team-photo ::cmd.teams/update-team-photo)
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[cfg {:keys [file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(update-team-photo cfg params)))
|
||||
|
||||
(defn update-team-photo
|
||||
[{:keys [pool storage executor] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(p/let [team (px/with-dispatch executor
|
||||
(teams/retrieve-team pool profile-id team-id))
|
||||
photo (upload-photo cfg params)]
|
||||
|
||||
;; Mark object as touched for make it ellegible for tentative
|
||||
;; garbage collection.
|
||||
(when-let [id (:photo-id team)]
|
||||
(sto/touch-object! storage id))
|
||||
|
||||
;; Save new photo
|
||||
(db/update! pool :team
|
||||
{:photo-id (:id photo)}
|
||||
{:id team-id})
|
||||
|
||||
(assoc team :photo-id (:id photo))))
|
||||
|
||||
(defn upload-photo
|
||||
[{:keys [storage executor climit] :as cfg} {:keys [file]}]
|
||||
(letfn [(get-info [content]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
(generate-thumbnail [info]
|
||||
(climit/with-dispatch (:process-image climit)
|
||||
(media/run {:cmd :profile-thumbnail
|
||||
:format :jpeg
|
||||
:quality 85
|
||||
:width 256
|
||||
:height 256
|
||||
:input info})))
|
||||
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch executor
|
||||
(sto/calculate-hash data)))]
|
||||
|
||||
(p/let [info (get-info file)
|
||||
thumb (generate-thumbnail info)
|
||||
hash (calculate-hash (:data thumb))
|
||||
content (-> (sto/content (:data thumb) (:size thumb))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage {::sto/content content
|
||||
::sto/deduplicate? true
|
||||
:bucket "profile"
|
||||
:content-type (:mtype thumb)}))))
|
||||
(cmd.teams/update-team-photo cfg params)))
|
||||
|
||||
;; --- Mutation: Invite Member
|
||||
|
||||
(declare create-team-invitation)
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::emails ::us/set-of-valid-emails)
|
||||
(s/def ::invite-team-member
|
||||
(s/keys :req-un [::profile-id ::team-id ::role]
|
||||
:opt-un [::email ::emails]))
|
||||
(s/def ::invite-team-member ::cmd.teams/create-team-invitations)
|
||||
|
||||
(sv/defmethod ::invite-team-member
|
||||
"A rpc call that allow to send a single or multiple invitations to
|
||||
join the team."
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id email emails role] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id team-id email emails role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)
|
||||
(let [perms (cmd.teams/get-permissions conn profile-id team-id)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
team (db/get-by-id conn :team team-id)
|
||||
emails (cond-> (or emails #{}) (string? email) (conj email))]
|
||||
@@ -376,115 +153,38 @@
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
|
||||
(let [invitations (->> emails
|
||||
(let [cfg (assoc cfg ::cmd.teams/conn conn)
|
||||
invitations (->> emails
|
||||
(map (fn [email]
|
||||
(assoc cfg
|
||||
:email email
|
||||
:conn conn
|
||||
:team team
|
||||
:profile profile
|
||||
:role role)))
|
||||
(map create-team-invitation))]
|
||||
{:email (str/lower email)
|
||||
:team team
|
||||
:profile profile
|
||||
:role role}))
|
||||
(map (partial #'cmd.teams/create-invitation cfg)))]
|
||||
(with-meta (vec invitations)
|
||||
{::audit/props {:invitations (count invitations)}})))))
|
||||
|
||||
(def sql:upsert-team-invitation
|
||||
"insert into team_invitation(team_id, email_to, role, valid_until)
|
||||
values (?, ?, ?, ?)
|
||||
on conflict(team_id, email_to) do
|
||||
update set role = ?, valid_until = ?, updated_at = now();")
|
||||
|
||||
(defn- create-team-invitation
|
||||
[{:keys [conn sprops team profile role email] :as cfg}]
|
||||
(let [member (profile/retrieve-profile-data-by-email conn email)
|
||||
token-exp (dt/in-future "168h") ;; 7 days
|
||||
email (str/lower email)
|
||||
itoken (tokens/generate sprops
|
||||
{:iss :team-invitation
|
||||
:exp token-exp
|
||||
:profile-id (:id profile)
|
||||
:role role
|
||||
:team-id (:id team)
|
||||
:member-email (:email member email)
|
||||
:member-id (:id member)})
|
||||
ptoken (tokens/generate sprops
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (dt/in-future {:days 30})})]
|
||||
|
||||
(when (and member (not (eml/allow-send-emails? conn member)))
|
||||
(ex/raise :type :validation
|
||||
:code :member-is-muted
|
||||
:email email
|
||||
:hint "the profile has reported repeatedly as spam or has bounces"))
|
||||
|
||||
;; Secondly check if the invited member email is part of the global spam/bounce report.
|
||||
(when (eml/has-bounce-reports? conn email)
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:email email
|
||||
:hint "the email you invite has been repeatedly reported as spam or bounce"))
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/trace :hint "invitation token" :token itoken))
|
||||
|
||||
;; 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.
|
||||
|
||||
;; TODO: if member does not exists and email verification is
|
||||
;; disabled, we should proceed to create the profile (?)
|
||||
(if (and (not (contains? cf/flags :email-verification))
|
||||
(some? member))
|
||||
(let [params (merge {:team-id (:id team)
|
||||
:profile-id (:id member)}
|
||||
(role->params role))]
|
||||
|
||||
;; Insert the invited member to the team
|
||||
(db/insert! conn :team-profile-rel params {:on-conflict-do-nothing 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)})))
|
||||
(do
|
||||
(db/exec-one! conn [sql:upsert-team-invitation
|
||||
(:id team) (str/lower email) (name role)
|
||||
token-exp (name role) token-exp])
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-team
|
||||
:public-uri (:public-uri cfg)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:token itoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
itoken))
|
||||
|
||||
;; --- Mutation: Create Team & Invite Members
|
||||
|
||||
(s/def ::emails ::us/set-of-valid-emails)
|
||||
(s/def ::create-team-and-invite-members
|
||||
(s/and ::create-team (s/keys :req-un [::emails ::role])))
|
||||
(s/def ::create-team-and-invite-members ::cmd.teams/create-team-with-invitations)
|
||||
|
||||
(sv/defmethod ::create-team-and-invite-members
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id emails role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [team (create-team conn params)
|
||||
profile (db/get-by-id conn :profile profile-id)]
|
||||
(let [team (cmd.teams/create-team conn params)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
cfg (assoc cfg ::cmd.teams/conn conn)]
|
||||
|
||||
;; Create invitations for all provided emails.
|
||||
(doseq [email emails]
|
||||
(create-team-invitation
|
||||
(assoc cfg
|
||||
:conn conn
|
||||
:team team
|
||||
:profile profile
|
||||
:email email
|
||||
:role role)))
|
||||
(->> emails
|
||||
(map (fn [email]
|
||||
{:team team
|
||||
:profile profile
|
||||
:email (str/lower email)
|
||||
:role role}))
|
||||
(run! (partial #'cmd.teams/create-invitation cfg)))
|
||||
|
||||
(-> team
|
||||
(vary-meta assoc ::audit/props {:invitations (count emails)})
|
||||
@@ -505,9 +205,11 @@
|
||||
(s/keys :req-un [::profile-id ::team-id ::email ::role]))
|
||||
|
||||
(sv/defmethod ::update-team-invitation-role
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)]
|
||||
(let [perms (cmd.teams/get-permissions conn profile-id team-id)]
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
@@ -520,13 +222,14 @@
|
||||
|
||||
;; --- Mutation: Delete invitation
|
||||
|
||||
(s/def ::delete-team-invitation
|
||||
(s/keys :req-un [::profile-id ::team-id ::email]))
|
||||
(s/def ::delete-team-invitation ::cmd.teams/delete-team-invitation)
|
||||
|
||||
(sv/defmethod ::delete-team-invitation
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id email] :as params}]
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id team-id email] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)]
|
||||
(let [perms (cmd.teams/get-permissions conn profile-id team-id)]
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
;; 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.mutations.verify-token
|
||||
(:require
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.verify-token :refer [process-token]]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(s/def ::verify-token
|
||||
(s/keys :req-un [::token]
|
||||
:opt-un [::profile-id]))
|
||||
|
||||
(sv/defmethod ::verify-token
|
||||
{:auth false
|
||||
::doc/added "1.1"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool sprops] :as cfg} {:keys [token] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [claims (tokens/verify sprops {:token token})
|
||||
cfg (assoc cfg :conn conn)]
|
||||
(process-token cfg params claims))))
|
||||
@@ -1,82 +0,0 @@
|
||||
;; 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.queries.comments
|
||||
(:require
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.comments :as cmd.comments]
|
||||
[app.rpc.commands.files :as cmd.files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(defn decode-row
|
||||
[{:keys [participants position] :as row}]
|
||||
(cond-> row
|
||||
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
|
||||
|
||||
;; --- QUERY: Comment Threads
|
||||
|
||||
(s/def ::comment-threads ::cmd.comments/get-comment-threads)
|
||||
|
||||
(sv/defmethod ::comment-threads
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.comments/retrieve-comment-threads conn params)))
|
||||
|
||||
;; --- QUERY: Unread Comment Threads
|
||||
|
||||
(s/def ::unread-comment-threads ::cmd.comments/get-unread-comment-threads)
|
||||
|
||||
(sv/defmethod ::unread-comment-threads
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.comments/retrieve-unread-comment-threads conn params)))
|
||||
|
||||
;; --- QUERY: Single Comment Thread
|
||||
|
||||
(s/def ::comment-thread ::cmd.comments/get-comment-thread)
|
||||
|
||||
(sv/defmethod ::comment-thread
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(cmd.comments/get-comment-thread conn params)))
|
||||
|
||||
;; --- QUERY: Comments
|
||||
|
||||
(s/def ::comments ::cmd.comments/get-comments)
|
||||
|
||||
(sv/defmethod ::comments
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id thread-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [thread (db/get-by-id conn :comment-thread thread-id)]
|
||||
(cmd.files/check-comment-permissions! conn profile-id (:file-id thread) share-id))
|
||||
(cmd.comments/get-comments conn thread-id)))
|
||||
|
||||
|
||||
;; --- QUERY: Get file comments users
|
||||
|
||||
(s/def ::file-comments-users ::cmd.comments/get-profiles-for-file-comments)
|
||||
|
||||
(sv/defmethod ::file-comments-users
|
||||
{::doc/deprecated "1.15"
|
||||
::doc/added "1.13"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(cmd.comments/get-file-comments-users conn file-id profile-id)))
|
||||
@@ -8,38 +8,38 @@
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.files :as cmd.files]
|
||||
[app.rpc.commands.search :as cmd.search]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.search :as search]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.projects :as projects]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Query: Project Files
|
||||
|
||||
(s/def ::project-files ::cmd.files/get-project-files)
|
||||
(s/def ::project-files ::files/get-project-files)
|
||||
|
||||
(sv/defmethod ::project-files
|
||||
{::doc/added "1.1"
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id project-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(cmd.files/get-project-files conn project-id)))
|
||||
(files/get-project-files conn project-id)))
|
||||
|
||||
;; --- Query: File (By ID)
|
||||
|
||||
(s/def ::components-v2 ::us/boolean)
|
||||
(s/def ::file
|
||||
(s/and ::cmd.files/get-file
|
||||
(s/and ::files/get-file
|
||||
(s/keys :opt-un [::components-v2])))
|
||||
|
||||
(defn get-file
|
||||
[conn id features]
|
||||
(let [file (cmd.files/get-file conn id features)
|
||||
thumbs (cmd.files/get-object-thumbnails conn id)]
|
||||
(let [file (files/get-file conn id features)
|
||||
thumbs (files/get-object-thumbnails conn id)]
|
||||
(assoc file :thumbnails thumbs)))
|
||||
|
||||
(sv/defmethod ::file
|
||||
@@ -48,19 +48,19 @@
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id features components-v2] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [perms (cmd.files/get-permissions pool profile-id id)
|
||||
(let [perms (files/get-permissions pool profile-id id)
|
||||
;; BACKWARD COMPATIBILTY with the components-v2 parameter
|
||||
features (cond-> (or features #{})
|
||||
components-v2 (conj "components/v2"))]
|
||||
|
||||
(cmd.files/check-read-permissions! perms)
|
||||
(files/check-read-permissions! perms)
|
||||
(-> (get-file conn id features)
|
||||
(assoc :permissions perms)))))
|
||||
|
||||
;; --- QUERY: page
|
||||
|
||||
(s/def ::page
|
||||
(s/and ::cmd.files/get-page
|
||||
(s/and ::files/get-page
|
||||
(s/keys :opt-un [::components-v2])))
|
||||
|
||||
(sv/defmethod ::page
|
||||
@@ -77,18 +77,18 @@
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id features components-v2] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-read-permissions! conn profile-id file-id)
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(let [;; BACKWARD COMPATIBILTY with the components-v2 parameter
|
||||
features (cond-> (or features #{})
|
||||
components-v2 (conj "components/v2"))
|
||||
params (assoc params :features features)]
|
||||
|
||||
(cmd.files/get-page conn params))))
|
||||
(files/get-page conn params))))
|
||||
|
||||
;; --- QUERY: file-data-for-thumbnail
|
||||
|
||||
(s/def ::file-data-for-thumbnail
|
||||
(s/and ::cmd.files/get-file-data-for-thumbnail
|
||||
(s/and ::files/get-file-data-for-thumbnail
|
||||
(s/keys :opt-un [::components-v2])))
|
||||
|
||||
(sv/defmethod ::file-data-for-thumbnail
|
||||
@@ -98,18 +98,18 @@
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id features components-v2] :as props}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-read-permissions! conn profile-id file-id)
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(let [;; BACKWARD COMPATIBILTY with the components-v2 parameter
|
||||
features (cond-> (or features #{})
|
||||
components-v2 (conj "components/v2"))
|
||||
file (cmd.files/get-file conn file-id features)]
|
||||
file (files/get-file conn file-id features)]
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:page (cmd.files/get-file-data-for-thumbnail conn file)})))
|
||||
:page (files/get-file-data-for-thumbnail conn file)})))
|
||||
|
||||
;; --- Query: Shared Library Files
|
||||
|
||||
(s/def ::team-shared-files ::cmd.files/get-team-shared-files)
|
||||
(s/def ::team-shared-files ::files/get-team-shared-files)
|
||||
|
||||
(sv/defmethod ::team-shared-files
|
||||
{::doc/added "1.3"
|
||||
@@ -117,37 +117,37 @@
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.files/get-team-shared-files conn params)))
|
||||
(files/get-team-shared-files conn params)))
|
||||
|
||||
|
||||
;; --- Query: File Libraries used by a File
|
||||
|
||||
(s/def ::file-libraries ::cmd.files/get-file-libraries)
|
||||
(s/def ::file-libraries ::files/get-file-libraries)
|
||||
|
||||
(sv/defmethod ::file-libraries
|
||||
{::doc/added "1.3"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id features] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-read-permissions! conn profile-id file-id)
|
||||
(cmd.files/get-file-libraries conn file-id features)))
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(files/get-file-libraries conn file-id features)))
|
||||
|
||||
|
||||
;; --- Query: Files that use this File library
|
||||
|
||||
(s/def ::library-using-files ::cmd.files/get-library-file-references)
|
||||
(s/def ::library-using-files ::files/get-library-file-references)
|
||||
|
||||
(sv/defmethod ::library-using-files
|
||||
{::doc/added "1.13"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-read-permissions! conn profile-id file-id)
|
||||
(cmd.files/get-library-file-references conn file-id)))
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(files/get-library-file-references conn file-id)))
|
||||
|
||||
;; --- QUERY: team-recent-files
|
||||
|
||||
(s/def ::team-recent-files ::cmd.files/get-team-recent-files)
|
||||
(s/def ::team-recent-files ::files/get-team-recent-files)
|
||||
|
||||
(sv/defmethod ::team-recent-files
|
||||
{::doc/added "1.0"
|
||||
@@ -155,30 +155,29 @@
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.files/get-team-recent-files conn team-id)))
|
||||
(files/get-team-recent-files conn team-id)))
|
||||
|
||||
|
||||
;; --- QUERY: get file thumbnail
|
||||
|
||||
(s/def ::file-thumbnail ::cmd.files/get-file-thumbnail)
|
||||
(s/def ::file-thumbnail ::files/get-file-thumbnail)
|
||||
|
||||
(sv/defmethod ::file-thumbnail
|
||||
{::doc/added "1.13"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool]} {:keys [profile-id file-id revn]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cmd.files/check-read-permissions! conn profile-id file-id)
|
||||
(-> (cmd.files/get-file-thumbnail conn file-id revn)
|
||||
(rph/with-http-cache cmd.files/long-cache-duration))))
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(-> (files/get-file-thumbnail conn file-id revn)
|
||||
(rph/with-http-cache files/long-cache-duration))))
|
||||
|
||||
|
||||
;; --- QUERY: search files
|
||||
|
||||
(s/def ::search-files ::cmd.search/search-files)
|
||||
(s/def ::search-files ::search/search-files)
|
||||
|
||||
(sv/defmethod ::search-files
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool]} {:keys [search-term] :as params}]
|
||||
(when search-term
|
||||
(cmd.search/search-files pool params)))
|
||||
[{:keys [pool]} {:keys [profile-id team-id search-term]}]
|
||||
(some->> search-term (search/search-files pool profile-id team-id)))
|
||||
|
||||
@@ -9,30 +9,15 @@
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.queries.projects :as projects]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Query: Team Font Variants
|
||||
|
||||
;; TODO: deprecated, should be removed on 1.7.x
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::team-font-variants
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
|
||||
(sv/defmethod ::team-font-variants
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id team-id
|
||||
:deleted-at nil})))
|
||||
|
||||
;; --- Query: Font Variants
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::font-variants
|
||||
@@ -47,6 +32,7 @@
|
||||
(contains? o :project-id)))))
|
||||
|
||||
(sv/defmethod ::font-variants
|
||||
{::doc/added "1.7"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id file-id project-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(cond
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
@@ -36,7 +37,7 @@
|
||||
(s/keys :opt-un [::profile-id]))
|
||||
|
||||
(sv/defmethod ::profile
|
||||
{:auth false}
|
||||
{::rpc/auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id] :as params}]
|
||||
;; We need to return the anonymous profile object in two cases, when
|
||||
;; no profile-id is in session, and when db call raises not found. In all other
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
|
||||
@@ -6,244 +6,82 @@
|
||||
|
||||
(ns app.rpc.queries.teams
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.commands.teams :as cmd.teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Team Edition Permissions
|
||||
|
||||
(def ^:private sql:team-permissions
|
||||
"select tpr.is_owner,
|
||||
tpr.is_admin,
|
||||
tpr.can_edit
|
||||
from team_profile_rel as tpr
|
||||
join team as t on (t.id = tpr.team_id)
|
||||
where tpr.profile_id = ?
|
||||
and tpr.team_id = ?
|
||||
and t.deleted_at is null")
|
||||
|
||||
(defn get-permissions
|
||||
[conn profile-id team-id]
|
||||
(let [rows (db/exec! conn [sql:team-permissions profile-id team-id])
|
||||
is-owner (boolean (some :is-owner rows))
|
||||
is-admin (boolean (some :is-admin rows))
|
||||
can-edit (boolean (some :can-edit rows))]
|
||||
(when (seq rows)
|
||||
{:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true})))
|
||||
|
||||
(def has-edit-permissions?
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
|
||||
(def check-read-permissions!
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; --- Query: Teams
|
||||
|
||||
(declare retrieve-teams)
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::teams
|
||||
(s/keys :req-un [::profile-id]))
|
||||
(s/def ::teams ::cmd.teams/get-teams)
|
||||
|
||||
(sv/defmethod ::teams
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(retrieve-teams conn profile-id)))
|
||||
|
||||
(def sql:teams
|
||||
"select t.*,
|
||||
tp.is_owner,
|
||||
tp.is_admin,
|
||||
tp.can_edit,
|
||||
(t.id = ?) as is_default
|
||||
from team_profile_rel as tp
|
||||
join team as t on (t.id = tp.team_id)
|
||||
where t.deleted_at is null
|
||||
and tp.profile_id = ?
|
||||
order by tp.created_at asc")
|
||||
|
||||
(defn process-permissions
|
||||
[team]
|
||||
(let [is-owner (:is-owner team)
|
||||
is-admin (:is-admin team)
|
||||
can-edit (:can-edit team)
|
||||
permissions {:type :membership
|
||||
:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)}]
|
||||
(-> team
|
||||
(dissoc :is-owner :is-admin :can-edit)
|
||||
(assoc :permissions permissions))))
|
||||
|
||||
(defn retrieve-teams
|
||||
[conn profile-id]
|
||||
(let [defaults (profile/retrieve-additional-data conn profile-id)]
|
||||
(->> (db/exec! conn [sql:teams (:default-team-id defaults) profile-id])
|
||||
(mapv process-permissions))))
|
||||
(cmd.teams/retrieve-teams conn profile-id)))
|
||||
|
||||
;; --- Query: Team (by ID)
|
||||
|
||||
(declare retrieve-team)
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::team
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/def ::team ::cmd.teams/get-team)
|
||||
|
||||
(sv/defmethod ::team
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(retrieve-team conn profile-id id)))
|
||||
|
||||
(defn retrieve-team
|
||||
[conn profile-id team-id]
|
||||
(let [defaults (profile/retrieve-additional-data conn profile-id)
|
||||
sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?")
|
||||
result (db/exec-one! conn [sql (:default-team-id defaults) profile-id team-id])]
|
||||
(when-not result
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist))
|
||||
(process-permissions result)))
|
||||
|
||||
(cmd.teams/retrieve-team conn profile-id id)))
|
||||
|
||||
;; --- Query: Team Members
|
||||
|
||||
(declare retrieve-team-members)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::team-members
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/def ::team-members ::cmd.teams/get-team-members)
|
||||
|
||||
(sv/defmethod ::team-members
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-team-members conn team-id)))
|
||||
|
||||
(def sql:team-members
|
||||
"select tp.*,
|
||||
p.id,
|
||||
p.email,
|
||||
p.fullname as name,
|
||||
p.fullname as fullname,
|
||||
p.photo_id,
|
||||
p.is_active
|
||||
from team_profile_rel as tp
|
||||
join profile as p on (p.id = tp.profile_id)
|
||||
where tp.team_id = ?")
|
||||
|
||||
(defn retrieve-team-members
|
||||
[conn team-id]
|
||||
(db/exec! conn [sql:team-members team-id]))
|
||||
|
||||
(cmd.teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.teams/retrieve-team-members conn team-id)))
|
||||
|
||||
;; --- Query: Team Users
|
||||
|
||||
(declare retrieve-users)
|
||||
(declare retrieve-team-for-file)
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-users
|
||||
(s/and (s/keys :req-un [::profile-id]
|
||||
:opt-un [::team-id ::file-id])
|
||||
#(or (:team-id %) (:file-id %))))
|
||||
(s/def ::team-users ::cmd.teams/get-team-users)
|
||||
|
||||
(sv/defmethod ::team-users
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id file-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(if team-id
|
||||
(do
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-users conn team-id))
|
||||
(let [{team-id :id} (retrieve-team-for-file conn file-id)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-users conn team-id)))))
|
||||
|
||||
;; This is a similar query to team members but can contain more data
|
||||
;; because some user can be explicitly added to project or file (not
|
||||
;; implemented in UI)
|
||||
|
||||
(def sql:team-users
|
||||
"select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
|
||||
where tpr.team_id = ?
|
||||
union
|
||||
select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
|
||||
inner join project as p on (ppr.project_id = p.id)
|
||||
where p.team_id = ?
|
||||
union
|
||||
select pf.id, pf.fullname, pf.photo_id
|
||||
from profile as pf
|
||||
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
|
||||
inner join file as f on (fpr.file_id = f.id)
|
||||
inner join project as p on (f.project_id = p.id)
|
||||
where p.team_id = ?")
|
||||
|
||||
(def sql:team-by-file
|
||||
"select p.team_id as id
|
||||
from project as p
|
||||
join file as f on (p.id = f.project_id)
|
||||
where f.id = ?")
|
||||
|
||||
(defn retrieve-users
|
||||
[conn team-id]
|
||||
(db/exec! conn [sql:team-users team-id team-id team-id]))
|
||||
|
||||
(defn retrieve-team-for-file
|
||||
[conn file-id]
|
||||
(->> [sql:team-by-file file-id]
|
||||
(db/exec-one! conn)))
|
||||
(cmd.teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.teams/retrieve-users conn team-id))
|
||||
(let [{team-id :id} (cmd.teams/retrieve-team-for-file conn file-id)]
|
||||
(cmd.teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.teams/retrieve-users conn team-id)))))
|
||||
|
||||
;; --- Query: Team Stats
|
||||
|
||||
(declare retrieve-team-stats)
|
||||
|
||||
(s/def ::team-stats
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
(s/def ::team-stats ::cmd.teams/get-team-stats)
|
||||
|
||||
(sv/defmethod ::team-stats
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(retrieve-team-stats conn team-id)))
|
||||
|
||||
(def sql:team-stats
|
||||
"select (select count(*) from project where team_id = ?) as projects,
|
||||
(select count(*) from file as f join project as p on (p.id = f.project_id) where p.team_id = ?) as files")
|
||||
|
||||
(defn retrieve-team-stats
|
||||
[conn team-id]
|
||||
(db/exec-one! conn [sql:team-stats team-id team-id]))
|
||||
|
||||
(cmd.teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.teams/retrieve-team-stats conn team-id)))
|
||||
|
||||
;; --- Query: Team invitations
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::team-invitations
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
|
||||
(def sql:team-invitations
|
||||
"select email_to as email, role, (valid_until < now()) as expired
|
||||
from team_invitation where team_id = ? order by valid_until desc")
|
||||
(s/def ::team-invitations ::cmd.teams/get-team-invitations)
|
||||
|
||||
(sv/defmethod ::team-invitations
|
||||
{::doc/added "1.0"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(->> (db/exec! conn [sql:team-invitations team-id])
|
||||
(mapv #(update % :role keyword)))))
|
||||
(cmd.teams/check-read-permissions! conn profile-id team-id)
|
||||
(cmd.teams/get-team-invitations conn team-id)))
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.viewer :as viewer]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
@@ -19,7 +20,7 @@
|
||||
(s/keys :opt-un [::components-v2])))
|
||||
|
||||
(sv/defmethod ::view-only-bundle
|
||||
{:auth false
|
||||
{::rpc/auth false
|
||||
::doc/added "1.3"
|
||||
::doc/deprecated "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [features components-v2] :as params}]
|
||||
|
||||
339
backend/src/app/rpc/quotes.clj
Normal file
339
backend/src/app/rpc/quotes.clj
Normal file
@@ -0,0 +1,339 @@
|
||||
;; 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.quotes
|
||||
"Penpot resource usage quotes."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defmulti check-quote ::id)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUBLIC API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::conn ::db/conn-or-pool)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::incr (s/and int? pos?))
|
||||
(s/def ::target ::us/string)
|
||||
|
||||
(s/def ::quote
|
||||
(s/keys :req [::id ::profile-id]
|
||||
:opt [::conn
|
||||
::team-id
|
||||
::project-id
|
||||
::file-id
|
||||
::incr]))
|
||||
|
||||
(def ^:private enabled (volatile! true))
|
||||
|
||||
(defn enable!
|
||||
"Enable quotes checking at runtime (from server REPL)."
|
||||
[]
|
||||
(vswap! enabled (constantly true)))
|
||||
|
||||
(defn disable!
|
||||
"Disable quotes checking at runtime (from server REPL)."
|
||||
[]
|
||||
(vswap! enabled (constantly false)))
|
||||
|
||||
(defn check-quote!
|
||||
[conn quote]
|
||||
(us/assert! ::db/conn-or-pool conn)
|
||||
(us/assert! ::quote quote)
|
||||
(when (contains? cf/flags :quotes)
|
||||
(when @enabled
|
||||
(check-quote (assoc quote ::conn conn ::target (name (::id quote)))))))
|
||||
|
||||
(defn- send-notification!
|
||||
[{:keys [::conn] :as params}]
|
||||
(l/warn :hint "max quote reached"
|
||||
:target (::target params)
|
||||
:profile-id (some-> params ::profile-id str)
|
||||
:team-id (some-> params ::team-id str)
|
||||
:project-id (some-> params ::project-id str)
|
||||
:file-id (some-> params ::file-id str)
|
||||
:quote (::quote params)
|
||||
:total (::total params)
|
||||
:incr (::inc params 1))
|
||||
|
||||
(when-let [admins (seq (cf/get :admins))]
|
||||
(let [subject (str/istr "[quotes:notification]: max quote reached ~(::target params)")
|
||||
content (str/istr "- Param: profile-id '~(::profile-id params)}'\n"
|
||||
"- Param: team-id '~(::team-id params)'\n"
|
||||
"- Param: project-id '~(::project-id params)'\n"
|
||||
"- Param: file-id '~(::file-id params)'\n"
|
||||
"- Quote ID: '~(::target params)'\n"
|
||||
"- Max: ~(::quote params)\n"
|
||||
"- Total: ~(::total params) (INCR ~(::incr params 1))\n")]
|
||||
(wrk/submit! {::wrk/task :sendmail
|
||||
::wrk/delay (dt/duration "30s")
|
||||
::wrk/max-retries 4
|
||||
::wrk/priority 200
|
||||
::wrk/conn conn
|
||||
::wrk/dedupe true
|
||||
::wrk/label "quotes-notification"
|
||||
:to (vec admins)
|
||||
:subject subject
|
||||
:body [{:type "text/plain"
|
||||
:content content}]}))))
|
||||
|
||||
(defn- generic-check!
|
||||
[{:keys [::conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}]
|
||||
(let [quote (->> (db/exec! conn quote-sql)
|
||||
(map :quote)
|
||||
(reduce max (- Integer/MAX_VALUE)))
|
||||
quote (if (pos? quote) quote default)
|
||||
total (->> (db/exec! conn count-sql) first :total)]
|
||||
|
||||
(when (> (+ total incr) quote)
|
||||
(if (contains? cf/flags :soft-quotes)
|
||||
(send-notification! (assoc params ::quote quote ::total total))
|
||||
(ex/raise :type :restriction
|
||||
:code :max-quote-reached
|
||||
:target target
|
||||
:quote quote
|
||||
:count total)))))
|
||||
|
||||
(def ^:private sql:get-quotes-1
|
||||
"select id, quote from usage_quote
|
||||
where target = ?
|
||||
and profile_id = ?
|
||||
and team_id is null
|
||||
and project_id is null
|
||||
and file_id is null;")
|
||||
|
||||
(def ^:private sql:get-quotes-2
|
||||
"select id, quote from usage_quote
|
||||
where target = ?
|
||||
and ((team_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
||||
|
||||
(def ^:private sql:get-quotes-3
|
||||
"select id, quote from usage_quote
|
||||
where target = ?
|
||||
and ((project_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(team_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
||||
|
||||
(def ^:private sql:get-quotes-4
|
||||
"select id, quote from usage_quote
|
||||
where target = ?
|
||||
and ((file_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(project_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(team_id = ? and (profile_id = ? or profile_id is null)) or
|
||||
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: TEAMS-PER-PROFILE
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-teams-per-profile
|
||||
"select count(*) as total
|
||||
from team_profile_rel
|
||||
where profile_id = ?")
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::teams-per-profile
|
||||
(s/keys :req [::profile-id ::target]))
|
||||
|
||||
(defmethod check-quote ::teams-per-profile
|
||||
[{:keys [::profile-id ::target] :as quote}]
|
||||
(us/assert! ::teams-per-profile quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
|
||||
(assoc ::count-sql [sql:get-teams-per-profile profile-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: PROJECTS-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-projects-per-team
|
||||
"select count(*) as total
|
||||
from project as p
|
||||
where p.team_id = ?
|
||||
and p.deleted_at is null")
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::projects-per-team
|
||||
(s/keys :req [::profile-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::projects-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-projects-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: FONT-VARIANTS-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-font-variants-per-team
|
||||
"select count(*) as total
|
||||
from team_font_variant as v
|
||||
where v.team_id = ?")
|
||||
|
||||
(s/def ::font-variants-per-team
|
||||
(s/keys :req [::profile-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::font-variants-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(us/assert! ::font-variants-per-team quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-font-variants-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: INVITATIONS-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-invitations-per-team
|
||||
"select count(*) as total
|
||||
from team_invitation
|
||||
where team_id = ?")
|
||||
|
||||
(s/def ::invitations-per-team
|
||||
(s/keys :req [::profile-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::invitations-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(us/assert! ::invitations-per-team quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-invitations-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: PROFILES-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-profiles-per-team
|
||||
"select (select count(*)
|
||||
from team_profile_rel
|
||||
where team_id = ?) +
|
||||
(select count(*)
|
||||
from team_invitation
|
||||
where team_id = ?
|
||||
and valid_until > now()) as total;")
|
||||
|
||||
;; NOTE: the total number of profiles is determined by the number of
|
||||
;; effective members plus ongoing valid invitations.
|
||||
|
||||
(s/def ::profiles-per-team
|
||||
(s/keys :req [::profile-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::profiles-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(us/assert! ::profiles-per-team quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: FILES-PER-PROJECT
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-files-per-project
|
||||
"select count(*) as total
|
||||
from file as f
|
||||
where f.project_id = ?
|
||||
and f.deleted_at is null")
|
||||
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::files-per-project
|
||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::files-per-project
|
||||
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
|
||||
(us/assert! ::files-per-project quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-files-per-project project-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: COMMENT-THREADS-PER-FILE
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-comment-threads-per-file
|
||||
"select count(*) as total
|
||||
from comment_thread as ct
|
||||
where ct.file_id = ?")
|
||||
|
||||
(s/def ::comment-threads-per-file
|
||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::comment-threads-per-file
|
||||
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
||||
(us/assert! ::files-per-project quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
||||
profile-id team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-comment-threads-per-file file-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: COMMENTS-PER-FILE
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:get-comments-per-file
|
||||
"select count(*) as total
|
||||
from comment as c
|
||||
join comment_thread as ct on (ct.id = c.thread_id)
|
||||
where ct.file_id = ?")
|
||||
|
||||
(s/def ::comments-per-file
|
||||
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
|
||||
|
||||
(defmethod check-quote ::comments-per-file
|
||||
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
||||
(us/assert! ::files-per-project quote)
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
||||
profile-id team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-comments-per-file file-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: DEFAULT
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmethod check-quote :default
|
||||
[{:keys [::id]}]
|
||||
(ex/raise :type :internal
|
||||
:code :quote-not-defined
|
||||
:quote id
|
||||
:hint "backend using a quote identifier not defined"))
|
||||
@@ -5,23 +5,23 @@
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.retry
|
||||
"A fault tolerance helpers. Allow retry some operations that we know
|
||||
we can retry."
|
||||
"A fault tolerance RPC middleware. Allow retry some operations that we
|
||||
know we can retry."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.util.retry :refer [conflict-exception?]]
|
||||
[app.util.services :as sv]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn conflict-db-insert?
|
||||
"Check if exception matches a insertion conflict on postgresql."
|
||||
[e]
|
||||
(and (instance? org.postgresql.util.PSQLException e)
|
||||
(= "23505" (.getSQLState e))))
|
||||
(conflict-exception? e))
|
||||
|
||||
(def always-false (constantly false))
|
||||
|
||||
(defn wrap-retry
|
||||
[_ f {:keys [::matches ::sv/name]
|
||||
:or {matches (constantly false)}
|
||||
:as mdata}]
|
||||
[_ f {:keys [::matches ::sv/name] :or {matches always-false} :as mdata}]
|
||||
|
||||
(when (::enabled mdata)
|
||||
(l/debug :hint "wrapping retry" :name name))
|
||||
@@ -29,8 +29,8 @@
|
||||
(if-let [max-retries (::max-retries mdata)]
|
||||
(fn [cfg params]
|
||||
(letfn [(run [retry]
|
||||
(-> (f cfg params)
|
||||
(p/catch (partial handle-error retry))))
|
||||
(->> (f cfg params)
|
||||
(p/merr (partial handle-error retry))))
|
||||
|
||||
(handle-error [retry cause]
|
||||
(if (matches cause)
|
||||
@@ -40,6 +40,6 @@
|
||||
(run current-retry)
|
||||
(throw cause)))
|
||||
(throw cause)))]
|
||||
(run 0)))
|
||||
(run 1)))
|
||||
f))
|
||||
|
||||
|
||||
@@ -182,78 +182,94 @@
|
||||
(assoc ::lresult/remaining remaining)
|
||||
(assoc ::lresult/reset (dt/plus ts {unit 1})))))))))
|
||||
|
||||
(defn- process-limits
|
||||
(defn- process-limits!
|
||||
[redis user-id limits now]
|
||||
(-> (p/all (map (partial process-limit redis user-id now) limits))
|
||||
(p/then (fn [results]
|
||||
(let [remaining (->> results
|
||||
(d/index-by ::name ::lresult/remaining)
|
||||
(uri/map->query-string))
|
||||
reset (->> results
|
||||
(d/index-by ::name (comp dt/->seconds ::lresult/reset))
|
||||
(uri/map->query-string))
|
||||
rejected (->> results
|
||||
(filter (complement ::lresult/allowed?))
|
||||
(first))]
|
||||
(->> (p/all (map (partial process-limit redis user-id now) limits))
|
||||
(p/fmap (fn [results]
|
||||
(let [remaining (->> results
|
||||
(d/index-by ::name ::lresult/remaining)
|
||||
(uri/map->query-string))
|
||||
reset (->> results
|
||||
(d/index-by ::name (comp dt/->seconds ::lresult/reset))
|
||||
(uri/map->query-string))
|
||||
rejected (->> results
|
||||
(filter (complement ::lresult/allowed?))
|
||||
(first))]
|
||||
|
||||
(when rejected
|
||||
(l/warn :hint "rejected rate limit"
|
||||
:user-id (str user-id)
|
||||
:limit-service (-> rejected ::service name)
|
||||
:limit-name (-> rejected ::name name)
|
||||
:limit-strategy (-> rejected ::strategy name)))
|
||||
(when rejected
|
||||
(l/warn :hint "rejected rate limit"
|
||||
:user-id (str user-id)
|
||||
:limit-service (-> rejected ::service name)
|
||||
:limit-name (-> rejected ::name name)
|
||||
:limit-strategy (-> rejected ::strategy name)))
|
||||
|
||||
{:enabled? true
|
||||
:allowed? (not (some? rejected))
|
||||
:headers {"x-rate-limit-remaining" remaining
|
||||
"x-rate-limit-reset" reset}})))))
|
||||
{:enabled? true
|
||||
:allowed? (not (some? rejected))
|
||||
:headers {"x-rate-limit-remaining" remaining
|
||||
"x-rate-limit-reset" reset}})))))
|
||||
|
||||
(defn- handle-response
|
||||
[f cfg params result]
|
||||
(if (:enabled? result)
|
||||
(let [headers (:headers result)]
|
||||
(when-not (:allowed? result)
|
||||
(ex/raise :type :rate-limit
|
||||
:code :request-blocked
|
||||
:hint "rate limit reached"
|
||||
::http/headers headers))
|
||||
(-> (f cfg params)
|
||||
(p/then (fn [response]
|
||||
(vary-meta response update ::http/headers merge headers)))))
|
||||
(if (:allowed? result)
|
||||
(->> (f cfg params)
|
||||
(p/fmap (fn [response]
|
||||
(vary-meta response update ::http/headers merge headers))))
|
||||
(p/rejected
|
||||
(ex/error :type :rate-limit
|
||||
:code :request-blocked
|
||||
:hint "rate limit reached"
|
||||
::http/headers headers))))
|
||||
(f cfg params)))
|
||||
|
||||
(defn- get-limits
|
||||
[state skey sname]
|
||||
(some->> (or (get-in @state [::limits skey])
|
||||
(get-in @state [::limits :default]))
|
||||
(map #(assoc % ::service sname))
|
||||
(seq)))
|
||||
|
||||
(defn- get-uid
|
||||
[{:keys [::http/request] :as params}]
|
||||
(or (::rpc/profile-id params)
|
||||
(some-> request parse-client-ip)
|
||||
uuid/zero))
|
||||
|
||||
(defn wrap
|
||||
[{:keys [rlimit redis] :as cfg} f mdata]
|
||||
(if rlimit
|
||||
(let [skey (keyword (::rpc/type cfg) (->> mdata ::sv/spec name))
|
||||
sname (str (::rpc/type cfg) "." (->> mdata ::sv/spec name))]
|
||||
(fn [cfg {:keys [::http/request] :as params}]
|
||||
(let [uid (or (:profile-id params)
|
||||
(some-> request parse-client-ip)
|
||||
uuid/zero)
|
||||
|
||||
rsp (when (and uid @enabled?)
|
||||
(when-let [limits (or (get-in @rlimit [::limits skey])
|
||||
(get-in @rlimit [::limits :default]))]
|
||||
(let [redis (redis/get-or-connect redis ::rlimit default-options)
|
||||
limits (map #(assoc % ::service sname) limits)
|
||||
resp (-> (process-limits redis uid limits (dt/now))
|
||||
(p/catch (fn [cause]
|
||||
;; If we have an error on processing the rate-limit we just skip
|
||||
;; it for do not cause service interruption because of redis
|
||||
;; downtime or similar situation.
|
||||
(l/error :hint "error on processing rate-limit" :cause cause)
|
||||
{:enabled? false})))]
|
||||
(fn [cfg params]
|
||||
(if @enabled?
|
||||
(try
|
||||
(let [uid (get-uid params)
|
||||
rsp (when-let [limits (get-limits rlimit skey sname)]
|
||||
(let [redis (redis/get-or-connect redis ::rpc/rlimit default-options)
|
||||
rsp (->> (process-limits! redis uid limits (dt/now))
|
||||
(p/merr (fn [cause]
|
||||
;; If we have an error on processing the rate-limit we just skip
|
||||
;; it for do not cause service interruption because of redis
|
||||
;; downtime or similar situation.
|
||||
(l/error :hint "error on processing rate-limit" :cause cause)
|
||||
(p/resolved {:enabled? false}))))]
|
||||
|
||||
;; If soft rate are enabled, we process the rate-limit but return unprotected
|
||||
;; response.
|
||||
(if (contains? cf/flags :soft-rpc-rlimit)
|
||||
(p/resolved {:enabled? false})
|
||||
resp))))
|
||||
;; If soft rate are enabled, we process the rate-limit but return unprotected
|
||||
;; response.
|
||||
(if (contains? cf/flags :soft-rpc-rlimit)
|
||||
{:enabled? false}
|
||||
rsp)))]
|
||||
|
||||
rsp (or rsp (p/resolved {:enabled? false}))]
|
||||
(->> (p/promise rsp)
|
||||
(p/fmap #(or % {:enabled? false}))
|
||||
(p/mcat #(handle-response f cfg params %))))
|
||||
|
||||
(p/then rsp (partial handle-response f cfg params)))))
|
||||
(catch Throwable cause
|
||||
(p/rejected cause)))
|
||||
|
||||
(f cfg params))))
|
||||
f))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -68,5 +68,5 @@
|
||||
(let [secret (or key (generate-random-key))]
|
||||
(-> (retrieve-all conn)
|
||||
(assoc :secret-key secret)
|
||||
(assoc :tokens-key (keys/derive secret :salt "tokens" :size 32))
|
||||
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
||||
(update :instance-id handle-instance-id conn (db/read-only? pool))))))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
(defn derive
|
||||
"Derive a key from secret-key"
|
||||
[secret-key & {:keys [salt size]}]
|
||||
[secret-key & {:keys [salt size] :or {size 32}}]
|
||||
(us/assert! ::us/not-empty-string secret-key)
|
||||
(let [engine (bk/engine {:key secret-key
|
||||
:salt salt
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.srepl.ext]
|
||||
[app.srepl.main]
|
||||
[app.util.json :as json]
|
||||
[app.util.locks :as locks]
|
||||
[clojure.core.server :as ccs]
|
||||
[clojure.main :as cm]
|
||||
[clojure.spec.alpha :as s]
|
||||
@@ -20,39 +24,59 @@
|
||||
(ccs/repl-init)
|
||||
(in-ns 'app.srepl.main))
|
||||
|
||||
(defn repl
|
||||
(defn user-repl
|
||||
[]
|
||||
(cm/repl
|
||||
:init repl-init
|
||||
:read ccs/repl-read))
|
||||
|
||||
(defn json-repl
|
||||
[]
|
||||
(let [out *out*
|
||||
lock (locks/create)]
|
||||
(ccs/prepl *in*
|
||||
(fn [m]
|
||||
(binding [*out* out, *flush-on-newline* true, *print-readably* true]
|
||||
(locks/locking lock
|
||||
(println (json/encode-str m))))))))
|
||||
|
||||
;; --- State initialization
|
||||
|
||||
(s/def ::name ::us/not-empty-string)
|
||||
(s/def ::port int?)
|
||||
(s/def ::port ::us/integer)
|
||||
(s/def ::host ::us/not-empty-string)
|
||||
(s/def ::flag #{:urepl-server :prepl-server})
|
||||
(s/def ::type #{::prepl ::urepl})
|
||||
(s/def ::key (s/tuple ::type ::us/keyword))
|
||||
|
||||
(defmethod ig/pre-init-spec ::server
|
||||
[_]
|
||||
(s/keys :opt-un [::port ::host ::name]))
|
||||
(s/keys :req [::flag]
|
||||
:req-un [::port ::host]))
|
||||
|
||||
(defmethod ig/prep-key ::server
|
||||
[_ cfg]
|
||||
(merge {:name "main"} cfg))
|
||||
[[type _] cfg]
|
||||
(assoc cfg ::flag (keyword (str (name type) "-server"))))
|
||||
|
||||
(defmethod ig/init-key ::server
|
||||
[_ {:keys [port host name] :as cfg}]
|
||||
(when (and port host name)
|
||||
(l/info :msg "initializing server repl" :port port :host host :name name)
|
||||
(ccs/start-server {:address host
|
||||
:port port
|
||||
:name name
|
||||
:accept 'app.srepl/repl})
|
||||
cfg))
|
||||
[[type _] {:keys [::flag port host] :as cfg}]
|
||||
(when (contains? cf/flags flag)
|
||||
(let [accept (case type
|
||||
::prepl 'app.srepl/json-repl
|
||||
::urepl 'app.srepl/user-repl)
|
||||
params {:address host
|
||||
:port port
|
||||
:name (name type)
|
||||
:accept accept}]
|
||||
|
||||
(l/info :msg "initializing repl server"
|
||||
:name (name type)
|
||||
:port port
|
||||
:host host)
|
||||
|
||||
(ccs/start-server params)
|
||||
|
||||
params)))
|
||||
|
||||
(defmethod ig/halt-key! ::server
|
||||
[_ cfg]
|
||||
(when cfg
|
||||
(ccs/stop-server (:name cfg))))
|
||||
|
||||
|
||||
[_ params]
|
||||
(some-> params :name ccs/stop-server))
|
||||
|
||||
77
backend/src/app/srepl/ext.clj
Normal file
77
backend/src/app/srepl/ext.clj
Normal file
@@ -0,0 +1,77 @@
|
||||
;; 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.srepl.ext
|
||||
"PREPL API for external usage (CLI or ADMIN)"
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.util.json :as json]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn- get-current-system
|
||||
[]
|
||||
(or (deref (requiring-resolve 'app.main/system))
|
||||
(deref (requiring-resolve 'user/system))))
|
||||
|
||||
(defmulti ^:private run-json-cmd* ::cmd)
|
||||
|
||||
(defn run-json-cmd
|
||||
"Entry point with external tools integrations that uses PREPL
|
||||
interface for interacting with running penpot backend."
|
||||
[data]
|
||||
(let [data (json/decode data)
|
||||
params (merge {::cmd (keyword (:cmd data "default"))}
|
||||
(:params data))]
|
||||
(run-json-cmd* params)))
|
||||
|
||||
(defmethod run-json-cmd* :create-profile
|
||||
[{:keys [fullname email password is-active]
|
||||
:or {is-active true}}]
|
||||
(when-let [system (get-current-system)]
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(let [params {:id (uuid/next)
|
||||
:email email
|
||||
:fullname fullname
|
||||
:is-active is-active
|
||||
:password password
|
||||
:props {}}]
|
||||
(->> (cmd.auth/create-profile conn params)
|
||||
(cmd.auth/create-profile-relations conn))))))
|
||||
|
||||
(defmethod run-json-cmd* :update-profile
|
||||
[{:keys [fullname email password is-active]}]
|
||||
(when-let [system (get-current-system)]
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(let [params (cond-> {}
|
||||
(some? fullname)
|
||||
(assoc :fullname fullname)
|
||||
|
||||
(some? password)
|
||||
(assoc :password (auth/derive-password password))
|
||||
|
||||
(some? is-active)
|
||||
(assoc :is-active is-active))]
|
||||
(when (seq params)
|
||||
(let [res (db/update! conn :profile
|
||||
params
|
||||
{:email email
|
||||
:deleted-at nil}
|
||||
{:return-keys false})]
|
||||
(pos? (:next.jdbc/update-count res))))))))
|
||||
|
||||
(defmethod run-json-cmd* :derive-password
|
||||
[{:keys [password]}]
|
||||
(auth/derive-password password))
|
||||
|
||||
(defmethod run-json-cmd* :default
|
||||
[{:keys [::cmd]}]
|
||||
(ex/raise :type :internal
|
||||
:code :not-implemented
|
||||
:hint (str/ffmt "command '%' not implemented" (name cmd))))
|
||||
@@ -41,3 +41,35 @@
|
||||
([file state]
|
||||
(repair-orphaned-shapes (:data file))
|
||||
(update state :total (fnil inc 0))))
|
||||
|
||||
(defn rename-layout-attrs
|
||||
([file]
|
||||
(let [found? (volatile! false)]
|
||||
(letfn [(update-shape
|
||||
[shape]
|
||||
(when (or (= (:layout-flex-dir shape) :reverse-row)
|
||||
(= (:layout-flex-dir shape) :reverse-column)
|
||||
(= (:layout-wrap-type shape) :no-wrap))
|
||||
(vreset! found? true))
|
||||
(cond-> shape
|
||||
(= (:layout-flex-dir shape) :reverse-row)
|
||||
(assoc :layout-flex-dir :row-reverse)
|
||||
(= (:layout-flex-dir shape) :reverse-column)
|
||||
(assoc :layout-flex-dir :column-reverse)
|
||||
(= (:layout-wrap-type shape) :no-wrap)
|
||||
(assoc :layout-wrap-type :nowrap)))
|
||||
|
||||
(update-page
|
||||
[page]
|
||||
(h/update-shapes page update-shape))]
|
||||
|
||||
(let [new-file (update file :data h/update-pages update-page)]
|
||||
(when @found?
|
||||
(l/info :hint "Found attrs to rename in file"
|
||||
:id (:id file)
|
||||
:name (:name file)))
|
||||
new-file))))
|
||||
|
||||
([file state]
|
||||
(rename-layout-attrs file)
|
||||
(update state :total (fnil inc 0))))
|
||||
@@ -8,8 +8,10 @@
|
||||
"A main namespace for server repl."
|
||||
#_:clj-kondo/ignore
|
||||
(:require
|
||||
[app.auth :refer [derive-password]]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.features :as ffeat]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pages :as cp]
|
||||
[app.common.pages.migrations :as pmg]
|
||||
@@ -20,9 +22,11 @@
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.main :refer [system]]
|
||||
[app.rpc.commands.auth :refer [derive-password]]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.queries.profile :as prof]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.objects-map :as omap]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.stacktrace :as strace]
|
||||
@@ -66,23 +70,33 @@
|
||||
[system & {:keys [update-fn id save? migrate? inc-revn?]
|
||||
:or {save? false migrate? true inc-revn? true}}]
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(let [file (db/get-by-id conn :file id {:for-update true})
|
||||
file (-> file
|
||||
(update :features db/decode-pgarray #{})
|
||||
(update :data blob/decode)
|
||||
(cond-> migrate? (update :data pmg/migrate-data)))
|
||||
file (binding [*conn* conn]
|
||||
(-> (update-fn file)
|
||||
(cond-> inc-revn? (update :revn inc))))]
|
||||
(when save?
|
||||
(let [features (db/create-array conn "text" (:features file))
|
||||
data (blob/encode (:data file))]
|
||||
(db/update! conn :file
|
||||
{:data data
|
||||
:revn (:revn file)
|
||||
:features features}
|
||||
{:id id})))
|
||||
file)))
|
||||
(let [file (-> (db/get-by-id conn :file id {:for-update true})
|
||||
(update :features db/decode-pgarray #{}))]
|
||||
(binding [*conn* conn
|
||||
pmap/*tracked* (atom {})
|
||||
pmap/*load-fn* (partial files/load-pointer conn id)
|
||||
ffeat/*wrap-with-pointer-map-fn*
|
||||
(if (contains? (:features file) "storage/pointer-map") pmap/wrap identity)
|
||||
ffeat/*wrap-with-objects-map-fn*
|
||||
(if (contains? (:features file) "storage/objectd-map") omap/wrap identity)]
|
||||
(let [file (-> file
|
||||
(update :data blob/decode)
|
||||
(cond-> migrate? (update :data pmg/migrate-data))
|
||||
(update-fn)
|
||||
(cond-> inc-revn? (update :revn inc)))]
|
||||
(when save?
|
||||
(let [features (db/create-array conn "text" (:features file))
|
||||
data (blob/encode (:data file))]
|
||||
(db/update! conn :file
|
||||
{:data data
|
||||
:revn (:revn file)
|
||||
:features features}
|
||||
{:id id})
|
||||
|
||||
(when (contains? (:features file) "storage/pointer-map")
|
||||
(files/persist-pointers! conn id))))
|
||||
|
||||
(dissoc file :data))))))
|
||||
|
||||
(def ^:private sql:retrieve-files-chunk
|
||||
"SELECT id, name, created_at, data FROM file
|
||||
|
||||
@@ -110,10 +110,10 @@
|
||||
(if (contains? features "storage/objects-map")
|
||||
file
|
||||
(-> file
|
||||
(update :data migrate-to-omap)
|
||||
(update :data migrate)
|
||||
(update :features conj "storage/objects-map"))))
|
||||
|
||||
(migrate-to-omap [data]
|
||||
(migrate [data]
|
||||
(-> data
|
||||
(update :pages-index update-vals #(update % :objects omap/wrap))
|
||||
(update :components update-vals #(update % :objects omap/wrap))))]
|
||||
@@ -125,24 +125,17 @@
|
||||
|
||||
(defn enable-pointer-map-feature-on-file!
|
||||
[system & {:keys [save? id]}]
|
||||
(letfn [(update-file [{:keys [features id] :as file}]
|
||||
(letfn [(update-file [{:keys [features] :as file}]
|
||||
(if (contains? features "storage/pointer-map")
|
||||
file
|
||||
(-> file
|
||||
(update :data migrate-to-omap id)
|
||||
(update :data migrate)
|
||||
(update :features conj "storage/pointer-map"))))
|
||||
|
||||
(migrate-to-omap [data file-id]
|
||||
(binding [pmap/*tracked* (atom {})]
|
||||
(let [data (-> data
|
||||
(update :pages-index update-vals pmap/wrap)
|
||||
(update :components pmap/wrap))]
|
||||
(doseq [[id item] @pmap/*tracked*]
|
||||
(db/insert! h/*conn* :file-data-fragment
|
||||
{:id id
|
||||
:file-id file-id
|
||||
:content (-> item deref blob/encode)}))
|
||||
data)))]
|
||||
(migrate [data]
|
||||
(-> data
|
||||
(update :pages-index update-vals pmap/wrap)
|
||||
(update :components pmap/wrap)))]
|
||||
|
||||
(h/update-file! system
|
||||
:id id
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
(:import
|
||||
java.io.FilterInputStream
|
||||
java.io.InputStream
|
||||
java.net.URI
|
||||
java.nio.ByteBuffer
|
||||
java.nio.file.Path
|
||||
java.time.Duration
|
||||
java.util.Collection
|
||||
java.util.Optional
|
||||
@@ -40,6 +42,7 @@
|
||||
software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup
|
||||
software.amazon.awssdk.regions.Region
|
||||
software.amazon.awssdk.services.s3.S3AsyncClient
|
||||
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
|
||||
@@ -151,46 +154,51 @@
|
||||
|
||||
(defn build-s3-client
|
||||
[{:keys [region endpoint executor]}]
|
||||
(let [hclient (.. (NettyNioAsyncHttpClient/builder)
|
||||
(eventLoopGroupBuilder (.. (SdkEventLoopGroup/builder)
|
||||
(numberOfThreads (int default-eventloop-threads))))
|
||||
(connectionAcquisitionTimeout default-timeout)
|
||||
(connectionTimeout default-timeout)
|
||||
(readTimeout default-timeout)
|
||||
(writeTimeout default-timeout)
|
||||
(build))
|
||||
client (.. (S3AsyncClient/builder)
|
||||
(asyncConfiguration (.. (ClientAsyncConfiguration/builder)
|
||||
(advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR
|
||||
executor)
|
||||
(build)))
|
||||
(httpClient hclient)
|
||||
(region (lookup-region region)))]
|
||||
(let [aconfig (-> (ClientAsyncConfiguration/builder)
|
||||
(.advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR executor)
|
||||
(.build))
|
||||
|
||||
(when-let [uri (some-> endpoint (java.net.URI.))]
|
||||
(.endpointOverride client uri))
|
||||
sconfig (-> (S3Configuration/builder)
|
||||
(cond-> (some? endpoint) (.pathStyleAccessEnabled true))
|
||||
(.build))
|
||||
|
||||
(let [client (.build client)]
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] client)
|
||||
hclient (-> (NettyNioAsyncHttpClient/builder)
|
||||
(.eventLoopGroupBuilder (-> (SdkEventLoopGroup/builder)
|
||||
(.numberOfThreads (int default-eventloop-threads))))
|
||||
(.connectionAcquisitionTimeout default-timeout)
|
||||
(.connectionTimeout default-timeout)
|
||||
(.readTimeout default-timeout)
|
||||
(.writeTimeout default-timeout)
|
||||
(.build))
|
||||
|
||||
java.lang.AutoCloseable
|
||||
(close [_]
|
||||
(.close hclient)
|
||||
(.close client))))))
|
||||
client (-> (S3AsyncClient/builder)
|
||||
(.serviceConfiguration ^S3Configuration sconfig)
|
||||
(.asyncConfiguration ^ClientAsyncConfiguration aconfig)
|
||||
(.httpClient ^NettyNioAsyncHttpClient hclient)
|
||||
(.region (lookup-region region))
|
||||
(cond-> (some? endpoint) (.endpointOverride (URI. endpoint)))
|
||||
(.build))]
|
||||
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] client)
|
||||
|
||||
java.lang.AutoCloseable
|
||||
(close [_]
|
||||
(.close ^NettyNioAsyncHttpClient hclient)
|
||||
(.close ^S3AsyncClient client)))))
|
||||
|
||||
(defn build-s3-presigner
|
||||
[{:keys [region endpoint]}]
|
||||
(if (string? endpoint)
|
||||
(let [uri (java.net.URI. endpoint)]
|
||||
(.. (S3Presigner/builder)
|
||||
(endpointOverride uri)
|
||||
(region (lookup-region region))
|
||||
(build)))
|
||||
(.. (S3Presigner/builder)
|
||||
(region (lookup-region region))
|
||||
(build))))
|
||||
(let [config (-> (S3Configuration/builder)
|
||||
(cond-> (some? endpoint) (.pathStyleAccessEnabled true))
|
||||
(.build))]
|
||||
|
||||
(-> (S3Presigner/builder)
|
||||
(cond-> (some? endpoint) (.endpointOverride (URI. endpoint)))
|
||||
(.region (lookup-region region))
|
||||
(.serviceConfiguration ^S3Configuration config)
|
||||
(.build))))
|
||||
|
||||
(defn- make-request-body
|
||||
[content]
|
||||
@@ -198,7 +206,7 @@
|
||||
buff-size (* 1024 64)
|
||||
sem (Semaphore. 0)
|
||||
|
||||
writer-fn (fn [s]
|
||||
writer-fn (fn [^Subscriber s]
|
||||
(try
|
||||
(loop []
|
||||
(.acquire sem 1)
|
||||
@@ -261,7 +269,7 @@
|
||||
;; not, read the contento into memory using bytearrays.
|
||||
(if (> size (* 1024 1024 2))
|
||||
(p/let [path (tmp/tempfile :prefix "penpot.storage.s3.")
|
||||
rxf (AsyncResponseTransformer/toFile path)
|
||||
rxf (AsyncResponseTransformer/toFile ^Path path)
|
||||
_ (.getObject ^S3AsyncClient client
|
||||
^GetObjectRequest gor
|
||||
^AsyncResponseTransformer rxf)]
|
||||
@@ -283,9 +291,9 @@
|
||||
(key (str prefix (impl/id->path id)))
|
||||
(build))
|
||||
rxf (AsyncResponseTransformer/toBytes)
|
||||
obj (.getObjectAsBytes ^S3AsyncClient client
|
||||
^GetObjectRequest gor
|
||||
^AsyncResponseTransformer rxf)]
|
||||
obj (.getObject ^S3AsyncClient client
|
||||
^GetObjectRequest gor
|
||||
^AsyncResponseTransformer rxf)]
|
||||
(.asByteArray ^ResponseBytes obj)))
|
||||
|
||||
(def default-max-age
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
(ns app.tasks.objects-gc
|
||||
"A maintenance task that performs a general purpose garbage collection
|
||||
of deleted objects."
|
||||
of deleted or unreachable objects."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
@@ -16,154 +16,247 @@
|
||||
[app.storage :as sto]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def target-tables
|
||||
["profile"
|
||||
"team"
|
||||
"file"
|
||||
"project"
|
||||
"team_font_variant"])
|
||||
|
||||
(defmulti delete-objects :table)
|
||||
|
||||
(def sql:delete-objects
|
||||
"with deleted as (
|
||||
select id from %(table)s
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
order by deleted_at
|
||||
limit %(limit)s
|
||||
)
|
||||
delete from %(table)s
|
||||
where id in (select id from deleted)
|
||||
returning *")
|
||||
|
||||
;; --- IMPL: generic object deletion
|
||||
|
||||
(defmethod delete-objects :default
|
||||
[{:keys [conn min-age table] :as cfg}]
|
||||
(let [sql (str/fmt sql:delete-objects
|
||||
{:table table :limit 50})
|
||||
result (db/exec! conn [sql min-age])]
|
||||
|
||||
(doseq [{:keys [id] :as item} result]
|
||||
(l/debug :hint "permanently delete object" :table table :id id))
|
||||
|
||||
(count result)))
|
||||
|
||||
;; --- IMPL: file deletion
|
||||
|
||||
(defmethod delete-objects "file"
|
||||
[{:keys [conn min-age table] :as cfg}]
|
||||
(let [sql (str/fmt sql:delete-objects {:table table :limit 50})
|
||||
result (db/exec! conn [sql min-age])]
|
||||
|
||||
(doseq [{:keys [id] :as item} result]
|
||||
(l/debug :hint "permanently delete object" :table table :id id))
|
||||
|
||||
(count result)))
|
||||
|
||||
;; --- IMPL: team-font-variant deletion
|
||||
|
||||
(defmethod delete-objects "team_font_variant"
|
||||
[{:keys [conn min-age storage table] :as cfg}]
|
||||
(let [sql (str/fmt sql:delete-objects {:table table :limit 50})
|
||||
fonts (db/exec! conn [sql min-age])
|
||||
storage (media/configure-assets-storage storage conn)]
|
||||
(doseq [{:keys [id] :as font} fonts]
|
||||
(l/debug :hint "permanently delete object" :table table :id id)
|
||||
(some->> (:woff1-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:woff2-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:otf-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:ttf-file-id font) (sto/touch-object! storage) deref))
|
||||
(count fonts)))
|
||||
|
||||
;; --- IMPL: team deletion
|
||||
|
||||
(defmethod delete-objects "team"
|
||||
[{:keys [conn min-age storage table] :as cfg}]
|
||||
(let [sql (str/fmt sql:delete-objects {:table table :limit 50})
|
||||
teams (db/exec! conn [sql min-age])
|
||||
storage (media/configure-assets-storage storage conn)]
|
||||
|
||||
(doseq [{:keys [id] :as team} teams]
|
||||
(l/debug :hint "permanently delete object" :table table :id id)
|
||||
(some->> (:photo-id team) (sto/touch-object! storage) deref))
|
||||
|
||||
(count teams)))
|
||||
|
||||
;; --- IMPL: profile deletion
|
||||
|
||||
(def sql:retrieve-deleted-profiles
|
||||
"select id, photo_id from profile
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
order by deleted_at
|
||||
limit ?
|
||||
for update")
|
||||
|
||||
(defmethod delete-objects "profile"
|
||||
[{:keys [conn min-age storage table] :as cfg}]
|
||||
(let [profiles (db/exec! conn [sql:retrieve-deleted-profiles min-age 50])
|
||||
storage (media/configure-assets-storage storage conn)]
|
||||
|
||||
(doseq [{:keys [id] :as profile} profiles]
|
||||
(l/debug :hint "permanently delete object" :table table :id id)
|
||||
|
||||
;; Mark as deleted the storage object related with the photo-id
|
||||
;; field.
|
||||
(some->> (:photo-id profile) (sto/touch-object! storage) deref)
|
||||
|
||||
;; And finally, permanently delete the profile.
|
||||
(db/delete! conn :profile {:id id}))
|
||||
|
||||
(count profiles)))
|
||||
|
||||
;; --- INIT
|
||||
|
||||
(defn- process-table
|
||||
[{:keys [table] :as cfg}]
|
||||
(loop [n 0]
|
||||
(let [res (delete-objects cfg)]
|
||||
(if (pos? res)
|
||||
(recur (+ n res))
|
||||
(do
|
||||
(l/debug :hint "delete summary" :table table :total n)
|
||||
n)))))
|
||||
(declare ^:private delete-profiles!)
|
||||
(declare ^:private delete-teams!)
|
||||
(declare ^:private delete-fonts!)
|
||||
(declare ^:private delete-projects!)
|
||||
(declare ^:private delete-files!)
|
||||
(declare ^:private delete-orphan-teams!)
|
||||
|
||||
(s/def ::min-age ::dt/duration)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool ::sto/storage]
|
||||
:opt-un [::min-age]))
|
||||
(s/keys :req [::db/pool ::sto/storage]
|
||||
:opt [::min-age]))
|
||||
|
||||
(defmethod ig/prep-key ::handler
|
||||
[_ cfg]
|
||||
(merge {:min-age cf/deletion-delay}
|
||||
(merge {::min-age cf/deletion-delay}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
[_ {:keys [::db/pool ::sto/storage] :as cfg}]
|
||||
(fn [params]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [min-age (or (:min-age params) (:min-age cfg))
|
||||
(let [min-age (or (:min-age params) (::min-age cfg))
|
||||
_ (l/info :hint "gc started"
|
||||
:min-age (dt/format-duration min-age)
|
||||
:rollback? (boolean (:rollback? params)))
|
||||
|
||||
storage (media/configure-assets-storage storage conn)
|
||||
cfg (-> cfg
|
||||
(assoc :min-age (db/interval min-age))
|
||||
(assoc :conn conn))]
|
||||
(loop [tables (seq target-tables)
|
||||
total 0]
|
||||
(if-let [table (first tables)]
|
||||
(recur (rest tables)
|
||||
(+ total (process-table (assoc cfg :table table))))
|
||||
(do
|
||||
(l/info :hint "objects gc finished successfully"
|
||||
:min-age (dt/format-duration min-age)
|
||||
:total total)
|
||||
(assoc ::min-age (db/interval min-age))
|
||||
(assoc ::conn conn)
|
||||
(assoc ::storage storage))
|
||||
|
||||
(when (:rollback? params)
|
||||
(db/rollback! conn))
|
||||
htotal (+ (delete-profiles! cfg)
|
||||
(delete-teams! cfg)
|
||||
(delete-projects! cfg)
|
||||
(delete-files! cfg)
|
||||
(delete-fonts! cfg))
|
||||
stotal (delete-orphan-teams! cfg)]
|
||||
|
||||
{:processed total})))))))
|
||||
(l/info :hint "gc finished"
|
||||
:deleted htotal
|
||||
:orphans stotal
|
||||
:rollback? (boolean (:rollback? params)))
|
||||
|
||||
(when (:rollback? params)
|
||||
(db/rollback! conn))
|
||||
|
||||
{:processed (+ stotal htotal)}))))
|
||||
|
||||
|
||||
(def ^:private sql:get-profiles-chunk
|
||||
"select id, photo_id, created_at from profile
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
and created_at < ?
|
||||
order by created_at desc
|
||||
limit 10
|
||||
for update skip locked")
|
||||
|
||||
(defn- delete-profiles!
|
||||
[{:keys [::conn ::min-age ::storage] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-profiles-chunk min-age cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id photo-id]}]
|
||||
(l/debug :hint "permanently delete profile" :id (str id))
|
||||
|
||||
;; Mark as deleted the storage object related with the
|
||||
;; photo-id field.
|
||||
(some->> photo-id (sto/touch-object! storage) deref)
|
||||
|
||||
;; And finally, permanently delete the profile.
|
||||
(db/delete! conn :profile {:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
(def ^:private sql:get-teams-chunk
|
||||
"select id, photo_id, created_at from team
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
and created_at < ?
|
||||
order by created_at desc
|
||||
limit 10
|
||||
for update skip locked")
|
||||
|
||||
(defn- delete-teams!
|
||||
[{:keys [::conn ::min-age ::storage] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-teams-chunk min-age cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id photo-id]}]
|
||||
(l/debug :hint "permanently delete team" :id (str id))
|
||||
|
||||
;; Mark as deleted the storage object related with the
|
||||
;; photo-id field.
|
||||
(some->> photo-id (sto/touch-object! storage) deref)
|
||||
|
||||
;; And finally, permanently delete the team.
|
||||
(db/delete! conn :team {:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
|
||||
(def ^:private sql:get-orphan-teams-chunk
|
||||
"select t.id, t.created_at
|
||||
from team as t
|
||||
left join team_profile_rel as tpr
|
||||
on (t.id = tpr.team_id)
|
||||
where tpr.profile_id is null
|
||||
and t.created_at < ?
|
||||
order by t.created_at desc
|
||||
limit 10
|
||||
for update of t skip locked;")
|
||||
|
||||
(defn- delete-orphan-teams!
|
||||
"Find all orphan teams (with no members and mark them for
|
||||
deletion (soft delete)."
|
||||
[{:keys [::conn] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-orphan-teams-chunk cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id]}]
|
||||
(l/debug :hint "mark team for deletion" :id (str id))
|
||||
|
||||
;; And finally, permanently delete the team.
|
||||
(db/update! conn :team
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
(def ^:private sql:get-fonts-chunk
|
||||
"select id, created_at, woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id
|
||||
from team_font_variant
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
and created_at < ?
|
||||
order by created_at desc
|
||||
limit 10
|
||||
for update skip locked")
|
||||
|
||||
(defn- delete-fonts!
|
||||
[{:keys [::conn ::min-age ::storage] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-fonts-chunk min-age cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id] :as font}]
|
||||
(l/debug :hint "permanently delete font variant" :id (str id))
|
||||
|
||||
;; Mark as deleted the all related storage objects
|
||||
(some->> (:woff1-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:woff2-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:otf-file-id font) (sto/touch-object! storage) deref)
|
||||
(some->> (:ttf-file-id font) (sto/touch-object! storage) deref)
|
||||
|
||||
;; And finally, permanently delete the team font variant
|
||||
(db/delete! conn :team-font-variant {:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
(def ^:private sql:get-projects-chunk
|
||||
"select id, created_at
|
||||
from project
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
and created_at < ?
|
||||
order by created_at desc
|
||||
limit 10
|
||||
for update skip locked")
|
||||
|
||||
(defn- delete-projects!
|
||||
[{:keys [::conn ::min-age] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-projects-chunk min-age cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id]}]
|
||||
(l/debug :hint "permanently delete project" :id (str id))
|
||||
|
||||
;; And finally, permanently delete the project.
|
||||
(db/delete! conn :project {:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
(def ^:private sql:get-files-chunk
|
||||
"select id, created_at
|
||||
from file
|
||||
where deleted_at is not null
|
||||
and deleted_at < now() - ?::interval
|
||||
and created_at < ?
|
||||
order by created_at desc
|
||||
limit 10
|
||||
for update skip locked")
|
||||
|
||||
(defn- delete-files!
|
||||
[{:keys [::conn ::min-age] :as cfg}]
|
||||
(letfn [(get-chunk [cursor]
|
||||
(let [rows (db/exec! conn [sql:get-files-chunk min-age cursor])]
|
||||
[(some->> rows peek :created-at) rows]))]
|
||||
(reduce
|
||||
(fn [total {:keys [id]}]
|
||||
(l/debug :hint "permanently delete file" :id (str id))
|
||||
|
||||
;; And finally, permanently delete the file.
|
||||
(db/delete! conn :file {:id id})
|
||||
|
||||
(inc total))
|
||||
0
|
||||
(d/iteration get-chunk
|
||||
:vf second
|
||||
:kf first
|
||||
:initk (dt/now)))))
|
||||
|
||||
34
backend/src/app/util/retry.clj
Normal file
34
backend/src/app/util/retry.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 app.util.retry
|
||||
"A fault tolerance helpers. Allow retry some operations that we know
|
||||
we can retry."
|
||||
(:require
|
||||
[app.common.logging :as l])
|
||||
(:import
|
||||
org.postgresql.util.PSQLException))
|
||||
|
||||
(defn conflict-exception?
|
||||
"Check if exception matches a insertion conflict on postgresql."
|
||||
[e]
|
||||
(and (instance? PSQLException e)
|
||||
(= "23505" (.getSQLState ^PSQLException e))))
|
||||
|
||||
(defmacro with-retry
|
||||
[{:keys [::when ::max-retries ::label] :or {max-retries 3}} & body]
|
||||
`(loop [tnum# 1]
|
||||
(let [result# (try
|
||||
~@body
|
||||
(catch Throwable cause#
|
||||
(if (and (~when cause#) (<= tnum# ~max-retries))
|
||||
::retry
|
||||
(throw cause#))))]
|
||||
(if (= ::retry result#)
|
||||
(do
|
||||
(l/warn :hint "retrying operation" :label ~label)
|
||||
(recur (inc tnum#)))
|
||||
result#))))
|
||||
@@ -217,7 +217,7 @@
|
||||
(l/debug :hist "dispatcher: queue tasks"
|
||||
:queue queue
|
||||
:tasks (count ids)
|
||||
:total-queued res)))
|
||||
:queued res)))
|
||||
|
||||
(run-batch! [rconn]
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -446,10 +446,11 @@
|
||||
:else
|
||||
(try
|
||||
(l/debug :hint "worker: executing task"
|
||||
:name (:name task)
|
||||
:id (:id task)
|
||||
:queue queue
|
||||
:worker-id worker-id
|
||||
:task-id (:id task)
|
||||
:task-name (:name task)
|
||||
:task-retry (:retry-num task))
|
||||
:retry (:retry-num task))
|
||||
(handle-task task)
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns backend-tests.helpers
|
||||
(:require
|
||||
[app.auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.flags :as flags]
|
||||
[app.common.pages :as cp]
|
||||
@@ -17,14 +18,14 @@
|
||||
[app.main :as main]
|
||||
[app.media]
|
||||
[app.migrations]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.files.create :as files.create]
|
||||
[app.rpc.commands.files.update :as files.update]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.mutations.profile :as profile]
|
||||
[app.rpc.mutations.projects :as projects]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -58,7 +59,8 @@
|
||||
(def default-flags
|
||||
[:enable-secure-session-cookies
|
||||
:enable-email-verification
|
||||
:enable-smtp])
|
||||
:enable-smtp
|
||||
:enable-quotes])
|
||||
|
||||
(defn state-init
|
||||
[next]
|
||||
@@ -102,8 +104,9 @@
|
||||
*pool* (:app.db/pool system)]
|
||||
(with-redefs [app.config/flags (flags/parse flags/default default-flags (:flags config))
|
||||
app.config/config config
|
||||
app.rpc.commands.auth/derive-password identity
|
||||
app.rpc.commands.auth/verify-password (fn [a b] {:valid (= a b)})]
|
||||
app.loggers.audit/submit! (constantly nil)
|
||||
app.auth/derive-password identity
|
||||
app.auth/verify-password (fn [a b] {:valid (= a b)})]
|
||||
(next)))
|
||||
(finally
|
||||
(ig/halt! system)))))
|
||||
@@ -172,7 +175,7 @@
|
||||
(->> (merge {:id (mk-uuid "project" i)
|
||||
:name (str "project" i)}
|
||||
params)
|
||||
(#'projects/create-project conn)))))
|
||||
(#'teams/create-project conn)))))
|
||||
|
||||
(defn create-file*
|
||||
([i params]
|
||||
@@ -254,7 +257,7 @@
|
||||
([params] (create-project-role* *pool* params))
|
||||
([pool {:keys [project-id profile-id role] :or {role :owner}}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(#'projects/create-project-role conn {:project-id project-id
|
||||
(#'teams/create-project-role conn {:project-id project-id
|
||||
:profile-id profile-id
|
||||
:role role}))))
|
||||
|
||||
@@ -320,17 +323,26 @@
|
||||
[{:keys [::type] :as data}]
|
||||
(let [method-fn (get-in *system* [:app.rpc/methods :commands type])]
|
||||
;; (app.common.pprint/pprint (:app.rpc/methods *system*))
|
||||
(try-on! (method-fn (dissoc data ::type)))))
|
||||
(try-on! (method-fn (-> data
|
||||
(dissoc ::type)
|
||||
(assoc :app.rpc/request-at (dt/now)))))))
|
||||
|
||||
(defn mutation!
|
||||
[{:keys [::type] :as data}]
|
||||
[{:keys [::type profile-id] :as data}]
|
||||
(let [method-fn (get-in *system* [:app.rpc/methods :mutations type])]
|
||||
(try-on! (method-fn (dissoc data ::type)))))
|
||||
(try-on! (method-fn (-> data
|
||||
(dissoc ::type)
|
||||
(assoc ::rpc/profile-id profile-id)
|
||||
(d/without-nils))))))
|
||||
|
||||
(defn query!
|
||||
[{:keys [::type] :as data}]
|
||||
[{:keys [::type profile-id] :as data}]
|
||||
(let [method-fn (get-in *system* [:app.rpc/methods :queries type])]
|
||||
(try-on! (method-fn (dissoc data ::type)))))
|
||||
(try-on! (method-fn (-> data
|
||||
(dissoc ::type)
|
||||
(assoc ::rpc/profile-id profile-id)
|
||||
(d/without-nils))))))
|
||||
|
||||
|
||||
(defn run-task!
|
||||
([name]
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
|
||||
;; Refresh webhook
|
||||
(let [whk' (th/db-get :webhook {:id (:id whk)})]
|
||||
(t/is (nil? (:error-code whk')))
|
||||
(prn whk'))
|
||||
(t/is (nil? (:error-code whk'))))
|
||||
|
||||
)))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.util.time :as dt]
|
||||
[app.rpc :as-alias rpc]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
|
||||
params {::th/type :push-audit-events
|
||||
:app.http/request http-request
|
||||
:profile-id (:id prof)
|
||||
::rpc/profile-id (:id prof)
|
||||
:events [{:name "navigate"
|
||||
:props {:project-id proj-id
|
||||
:team-id team-id
|
||||
@@ -67,7 +68,7 @@
|
||||
|
||||
params {::th/type :push-audit-events
|
||||
:app.http/request http-request
|
||||
:profile-id (:id prof)
|
||||
::rpc/profile-id (:id prof)
|
||||
:events [{:name "navigate"
|
||||
:props {:project-id proj-id
|
||||
:team-id team-id
|
||||
|
||||
290
backend/test/backend_tests/rpc_comment_test.clj
Normal file
290
backend/test/backend_tests/rpc_comment_test.clj
Normal file
@@ -0,0 +1,290 @@
|
||||
;; 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-comment-test
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.comments :as comments]
|
||||
[app.rpc.cond :as cond]
|
||||
[app.rpc.quotes :as-alias quotes]
|
||||
[app.util.time :as dt]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.core :as fs]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest comment-and-threads-crud
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-teams-per-profile 200})}]
|
||||
|
||||
(let [profile-1 (th/create-profile* 1 {:is-active true})
|
||||
profile-2 (th/create-profile* 2 {:is-active true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id profile-1)})
|
||||
;; role (th/create-team-role* {:team-id (:id team)
|
||||
;; :profile-id (:id profile-2)
|
||||
;; :role :admin})
|
||||
|
||||
project (th/create-project* 1 {:team-id (:id team)
|
||||
:profile-id (:id profile-1)})
|
||||
file-1 (th/create-file* 1 {:profile-id (:id profile-1)
|
||||
:project-id (:id project)})
|
||||
file-2 (th/create-file* 2 {:profile-id (:id profile-1)
|
||||
:project-id (:id project)})
|
||||
page-id (get-in file-1 [:data :pages 0])]
|
||||
|
||||
(t/testing "comment thread creation"
|
||||
(let [data {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:file-id (:id file-1)
|
||||
:page-id page-id
|
||||
:position (gpt/point 0)
|
||||
:content "hello world"
|
||||
:frame-id uuid/zero}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)]
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (uuid? (:file-id result)))
|
||||
(t/is (uuid? (:page-id result)))
|
||||
(t/is (uuid? (:comment-id result)))
|
||||
(t/is (= (:file-id result) (:id file-1)))
|
||||
(t/is (= (:page-id result) page-id)))))
|
||||
|
||||
(t/testing "comment thread status update"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
;; comment (-> (th/db-query :comment {:thread-id (:id thread)}) first)
|
||||
data {::th/type :update-comment-thread-status
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:id (:id thread)}
|
||||
status (th/db-get :comment-thread-status
|
||||
{:thread-id (:id thread)
|
||||
:profile-id (:id profile-1)})]
|
||||
|
||||
|
||||
(t/is (= (:modified-at status) (:modified-at thread)))
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (dt/instant? (:modified-at result))))
|
||||
|
||||
(let [status' (th/db-get :comment-thread-status
|
||||
{:thread-id (:id thread)
|
||||
:profile-id (:id profile-1)})]
|
||||
(t/is (not= (:modified-at status') (:modified-at thread))))))
|
||||
|
||||
(t/testing "comment thread status update 2"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :update-comment-thread-status
|
||||
::rpc/profile-id (:id profile-2)
|
||||
:id (:id thread)}]
|
||||
|
||||
(let [{:keys [error] :as out} (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type error))))))
|
||||
|
||||
(t/testing "update comment thread"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :update-comment-thread
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:is-resolved true
|
||||
:id (:id thread)}]
|
||||
|
||||
(t/is (false? (:is-resolved thread)))
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? result)))
|
||||
|
||||
(let [thread (th/db-get :comment-thread {:id (:id thread)})]
|
||||
(t/is (true? (:is-resolved thread))))))
|
||||
|
||||
(t/testing "create comment"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :create-comment
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:thread-id (:id thread)
|
||||
:content "comment 2"}]
|
||||
(let [{:keys [result] :as out} (th/command! data)
|
||||
{:keys [modified-at]} (th/db-get :comment-thread-status
|
||||
{:thread-id (:id thread)
|
||||
:profile-id (:id profile-1)})]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (= (:owner-id result) (:id profile-1)))
|
||||
(t/is (:modified-at result) modified-at))))
|
||||
|
||||
(t/testing "update comment"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2"}) first)
|
||||
data {::th/type :update-comment
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:id (:id comment)
|
||||
:content "comment 2 mod"}]
|
||||
(let [{:keys [result] :as out} (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? result)))
|
||||
|
||||
(let [comment' (th/db-get :comment {:id (:id comment)})]
|
||||
(t/is (not= (:modified-at comment) (:modified-at comment')))
|
||||
(t/is (= (:content data) (:content comment'))))))
|
||||
|
||||
|
||||
(t/testing "retrieve threads"
|
||||
(let [data {::th/type :get-comment-threads
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:file-id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [[thread :as result] (:result out)]
|
||||
(t/is (= 1 (count result)))
|
||||
(t/is (= "Page 1" (:page-name thread)))
|
||||
(t/is (= "hello world" (:content thread)))
|
||||
(t/is (= 2 (:count-comments thread)))
|
||||
(t/is (true? (:is-resolved thread))))))
|
||||
|
||||
|
||||
(t/testing "unread comment threads"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :get-unread-comment-threads
|
||||
::rpc/profile-id (:id profile-1)}]
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:default-team-id profile-1)))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= [] result)))
|
||||
|
||||
(let [{:keys [error] :as out} (th/command! (assoc data :team-id (:default-team-id profile-2)))]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type error))))
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [[thread :as result] (:result out)]
|
||||
(t/is (= 1 (count result)))))
|
||||
|
||||
(let [data {::th/type :update-comment-thread-status
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:id (:id thread)}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)]
|
||||
(t/is (= 0 (count result)))))))
|
||||
|
||||
(t/testing "get comment thread"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :get-comment-thread
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:file-id (:id file-1)
|
||||
:id (:id thread)}]
|
||||
|
||||
(let [{:keys [result] :as out} (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(t/is (= (:id thread) (:id result))))))
|
||||
|
||||
(t/testing "get comments"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :get-comments
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:thread-id (:id thread)}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [comments (:result out)]
|
||||
(t/is (= 2 (count comments))))))
|
||||
|
||||
(t/testing "get profiles"
|
||||
(let [data {::th/type :get-profiles-for-file-comments
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:file-id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [[profile :as profiles] (:result out)]
|
||||
(t/is (= 1 (count profiles)))
|
||||
(t/is (= (:id profile-1) (:id profile))))))
|
||||
|
||||
(t/testing "get profiles 2"
|
||||
(let [data {::th/type :get-profiles-for-file-comments
|
||||
::rpc/profile-id (:id profile-2)
|
||||
:file-id (:id file-1)}
|
||||
out (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))))
|
||||
|
||||
(t/testing "delete comment"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
|
||||
data {::th/type :delete-comment
|
||||
::rpc/profile-id (:id profile-2)
|
||||
:id (:id comment)}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(let [comments (th/db-query :comment {:thread-id (:id thread)})]
|
||||
(t/is (= 2 (count comments))))))
|
||||
|
||||
(t/testing "delete comment 2"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
|
||||
data {::th/type :delete-comment
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:id (:id comment)}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
(let [comments (th/db-query :comment {:thread-id (:id thread)})]
|
||||
(t/is (= 1 (count comments))))))
|
||||
|
||||
(t/testing "delete comment thread"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :delete-comment-thread
|
||||
::rpc/profile-id (:id profile-2)
|
||||
:id (:id thread)}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
|
||||
(t/is (= 1 (count threads))))))
|
||||
|
||||
(t/testing "delete comment thread 2"
|
||||
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
||||
data {::th/type :delete-comment-thread
|
||||
::rpc/profile-id (:id profile-1)
|
||||
:id (:id thread)}
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/success? out))
|
||||
|
||||
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
|
||||
(t/is (= 0 (count threads))))))
|
||||
|
||||
)))
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
(ns backend-tests.rpc-cond-middleware-test
|
||||
(:require
|
||||
[backend-tests.storage-test :refer [configure-storage-backend]]
|
||||
[backend-tests.helpers :as th]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.cond :as cond]
|
||||
[backend-tests.helpers :as th]
|
||||
[backend-tests.storage-test :refer [configure-storage-backend]]
|
||||
[clojure.test :as t]
|
||||
[datoteka.core :as fs]))
|
||||
|
||||
@@ -24,7 +25,9 @@
|
||||
:profile-id (:id profile)})
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
params {::th/type :get-file :id (:id file1) :profile-id (:id profile)}]
|
||||
params {::th/type :get-file
|
||||
:id (:id file1)
|
||||
::rpc/profile-id (:id profile)}]
|
||||
|
||||
(binding [cond/*enabled* true]
|
||||
(let [{:keys [error result]} (th/command! params)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user