mirror of
https://github.com/penpot/penpot.git
synced 2026-01-04 12:28:52 -05:00
Compare commits
1681 Commits
1.7.2-alph
...
1.13.2-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08ad5c8c0 | ||
|
|
2ce766c49e | ||
|
|
bb18a69394 | ||
|
|
96ed66d86e | ||
|
|
57ccb18517 | ||
|
|
d5df465992 | ||
|
|
ea6c34f6b2 | ||
|
|
36390be72a | ||
|
|
3c41693787 | ||
|
|
b25806b172 | ||
|
|
0828d43f8f | ||
|
|
402212c808 | ||
|
|
11b2144274 | ||
|
|
216dbc8e0d | ||
|
|
67b81fbe67 | ||
|
|
fcafe66bd8 | ||
|
|
931759f468 | ||
|
|
f33360a22b | ||
|
|
910fb55b69 | ||
|
|
18849307e9 | ||
|
|
0f2b2d4590 | ||
|
|
ef37abcbbd | ||
|
|
02427285ef | ||
|
|
38bc3b061a | ||
|
|
047b3f0987 | ||
|
|
6a8f3c7283 | ||
|
|
525da266b8 | ||
|
|
97c9035cfd | ||
|
|
35681c3af8 | ||
|
|
8a6f01404c | ||
|
|
6901431f8a | ||
|
|
2261bde6f1 | ||
|
|
40e1d5a2a1 | ||
|
|
d52c4541ae | ||
|
|
b0c3b38cc5 | ||
|
|
494e2df49f | ||
|
|
dcac6d9ea4 | ||
|
|
f5128d8d43 | ||
|
|
4c2182dd0b | ||
|
|
c83affe351 | ||
|
|
51a9b10d51 | ||
|
|
0fc2c312d5 | ||
|
|
ba139d7d2c | ||
|
|
426758d9b2 | ||
|
|
542fb9c754 | ||
|
|
13492f5ac7 | ||
|
|
43d3b06c30 | ||
|
|
d8a7402046 | ||
|
|
93b582c385 | ||
|
|
d45bb0ace1 | ||
|
|
25ff15c62e | ||
|
|
30bcdda90e | ||
|
|
e22ef536ed | ||
|
|
b5e696c6b4 | ||
|
|
2b1e126ff8 | ||
|
|
1690f1ee23 | ||
|
|
6a74f29f96 | ||
|
|
d666755159 | ||
|
|
fa00d674eb | ||
|
|
7c23b7ea79 | ||
|
|
919ca68a77 | ||
|
|
29010453e6 | ||
|
|
a8cc9ace72 | ||
|
|
9ab922a0fa | ||
|
|
c9dadce12a | ||
|
|
eabfa7a541 | ||
|
|
95a2da5ebc | ||
|
|
180c355340 | ||
|
|
01664a04fc | ||
|
|
edce45095e | ||
|
|
5a07599fc7 | ||
|
|
d684970bfb | ||
|
|
216b510900 | ||
|
|
5c2b5f7cda | ||
|
|
712c68fc77 | ||
|
|
f290465edd | ||
|
|
141bcdd25e | ||
|
|
f68a4eb84a | ||
|
|
a240fbdf5b | ||
|
|
799bb87398 | ||
|
|
2b5282025c | ||
|
|
a2de5f8fb4 | ||
|
|
080139cd56 | ||
|
|
570f038062 | ||
|
|
ae84f3cbe8 | ||
|
|
abdc9b2cbd | ||
|
|
92d7521ec7 | ||
|
|
4730273ad3 | ||
|
|
a3935953f7 | ||
|
|
ea50622bf7 | ||
|
|
4b0b7463c7 | ||
|
|
95d4018074 | ||
|
|
3f413e4920 | ||
|
|
db8e829339 | ||
|
|
448e0dd415 | ||
|
|
15418a252e | ||
|
|
21d845d254 | ||
|
|
c84017eb72 | ||
|
|
431e42c80a | ||
|
|
ca2eb1ac12 | ||
|
|
d2983c1110 | ||
|
|
74612178d7 | ||
|
|
af519b3f89 | ||
|
|
d8d4ce7a46 | ||
|
|
3930be5d9e | ||
|
|
d85a4d6539 | ||
|
|
7446fe77b3 | ||
|
|
8b1f8d1418 | ||
|
|
d387ca81d8 | ||
|
|
b7b5f3b4c2 | ||
|
|
698dd872e4 | ||
|
|
767f0fe16b | ||
|
|
a19c56c0ce | ||
|
|
b9e984300c | ||
|
|
0727757eb1 | ||
|
|
50037a6a88 | ||
|
|
5bdea086e9 | ||
|
|
fef69cb707 | ||
|
|
20211101b7 | ||
|
|
ce41a38098 | ||
|
|
c14ece9f8d | ||
|
|
f2bb59fd77 | ||
|
|
af6a687187 | ||
|
|
40de8781ef | ||
|
|
33e776fefe | ||
|
|
efcabe7ffb | ||
|
|
77e9b8aa70 | ||
|
|
238cd14eb8 | ||
|
|
22193635d6 | ||
|
|
8432e970cb | ||
|
|
55df28d5dc | ||
|
|
33882f44ef | ||
|
|
c06042c91b | ||
|
|
2976c5c572 | ||
|
|
8df93c2707 | ||
|
|
0c26dad3b2 | ||
|
|
8d399cb562 | ||
|
|
82d744b94a | ||
|
|
94d3f66ef1 | ||
|
|
40a38cbd38 | ||
|
|
644c796772 | ||
|
|
81dac233a7 | ||
|
|
6bbd76f350 | ||
|
|
3a6072bc8f | ||
|
|
0bcf3d99a0 | ||
|
|
8cd7f61150 | ||
|
|
96aa756eb6 | ||
|
|
4cdf8cec4e | ||
|
|
d9a9eb3729 | ||
|
|
8298d460e6 | ||
|
|
462eabd8a1 | ||
|
|
afa1af6dc2 | ||
|
|
37fdf51eaf | ||
|
|
1102bc9cba | ||
|
|
18afb701fb | ||
|
|
15a26d10f0 | ||
|
|
9b8b6134c5 | ||
|
|
7e05b7e6d9 | ||
|
|
b86ea5b5e2 | ||
|
|
66f7d35510 | ||
|
|
8fb22b8eee | ||
|
|
5b37c11221 | ||
|
|
1723ff1da5 | ||
|
|
9099403421 | ||
|
|
baf3f7ea15 | ||
|
|
1d39bbaa3c | ||
|
|
0db2f87e3e | ||
|
|
430ccda02c | ||
|
|
fe6e62482a | ||
|
|
82185794a8 | ||
|
|
053975ef82 | ||
|
|
7185199d05 | ||
|
|
9dcad7ebef | ||
|
|
39e4651374 | ||
|
|
fe1ae7dbb4 | ||
|
|
39b0de1ced | ||
|
|
2f0e85f619 | ||
|
|
4d106d9e15 | ||
|
|
e5ccf36c07 | ||
|
|
d92df31b3e | ||
|
|
8b3062be0b | ||
|
|
c7e23c1b58 | ||
|
|
9923268589 | ||
|
|
a8103cbc3e | ||
|
|
26a074768f | ||
|
|
1c87195fa6 | ||
|
|
2a1ca07554 | ||
|
|
c3be87ed30 | ||
|
|
609ce1c106 | ||
|
|
5b2d1b310a | ||
|
|
a7ded66eab | ||
|
|
74d195c745 | ||
|
|
1705954b07 | ||
|
|
71bb34efc5 | ||
|
|
32d61eaf70 | ||
|
|
20badb7676 | ||
|
|
dbfa0e7a4b | ||
|
|
95c73585d2 | ||
|
|
c4939c152d | ||
|
|
7560e32911 | ||
|
|
d50299bdbb | ||
|
|
c34c1c4375 | ||
|
|
b62f387ff4 | ||
|
|
d28b4092d9 | ||
|
|
658e3b7aee | ||
|
|
d18c96360f | ||
|
|
c83bb70074 | ||
|
|
02157cbeb9 | ||
|
|
7581230b6e | ||
|
|
049f4ce784 | ||
|
|
c01e4e52f8 | ||
|
|
3ab3ea68b4 | ||
|
|
41948ff86b | ||
|
|
01ca538c72 | ||
|
|
2b9badfd4e | ||
|
|
6ad591eb23 | ||
|
|
581c50b5ff | ||
|
|
9492dd7856 | ||
|
|
b239a9b09e | ||
|
|
e0aeb3b5ac | ||
|
|
58cfd61997 | ||
|
|
a82bcd0ab2 | ||
|
|
dfc9d0709d | ||
|
|
b7d33041e8 | ||
|
|
f945a6e649 | ||
|
|
6a3a460203 | ||
|
|
b576ef02af | ||
|
|
814042909a | ||
|
|
9856da4a1f | ||
|
|
202e7eb3f2 | ||
|
|
38deacdf31 | ||
|
|
c809890cfd | ||
|
|
224d466122 | ||
|
|
08c6e9b702 | ||
|
|
9e940dc042 | ||
|
|
6fda156164 | ||
|
|
5eb53da374 | ||
|
|
68e0b3e756 | ||
|
|
cfe374b08c | ||
|
|
cc046555a3 | ||
|
|
31ec4092ed | ||
|
|
d9d47b2c65 | ||
|
|
506f63317a | ||
|
|
d658145450 | ||
|
|
b2d13f277a | ||
|
|
59310cdd71 | ||
|
|
c8d3975680 | ||
|
|
b6f2800aa3 | ||
|
|
a579ea3c25 | ||
|
|
7b3ab2287a | ||
|
|
b78d9dcc52 | ||
|
|
caa81b4fe2 | ||
|
|
b9ab00c549 | ||
|
|
2707903f8a | ||
|
|
28031a247a | ||
|
|
175f4b57f5 | ||
|
|
2ae2877f45 | ||
|
|
5e7a609b3d | ||
|
|
9ffe406d0d | ||
|
|
adfc0902a2 | ||
|
|
620efcb5cb | ||
|
|
0ed23f94c7 | ||
|
|
1cac7d55d0 | ||
|
|
875fd78f73 | ||
|
|
82ae4e60f8 | ||
|
|
5fc27a7594 | ||
|
|
6ad06d9665 | ||
|
|
c766e08027 | ||
|
|
62f55a47c5 | ||
|
|
b1edcba0c2 | ||
|
|
f7d2f6ec51 | ||
|
|
3a95a1cea1 | ||
|
|
4143573868 | ||
|
|
26daf507b3 | ||
|
|
f2c0683803 | ||
|
|
aa2bb75f95 | ||
|
|
004fddfcf4 | ||
|
|
a61301c698 | ||
|
|
b2607b28ff | ||
|
|
c2c01831fb | ||
|
|
ea38d12a73 | ||
|
|
76abd6796e | ||
|
|
0bb20197f1 | ||
|
|
2af057a79f | ||
|
|
fd9b442075 | ||
|
|
5edbebcfec | ||
|
|
e62f0603b5 | ||
|
|
654e12a2c3 | ||
|
|
5299465864 | ||
|
|
39fa939f58 | ||
|
|
4adc5d25a7 | ||
|
|
7a38b08506 | ||
|
|
df4b92fb6b | ||
|
|
ca02999ae9 | ||
|
|
701a98fab6 | ||
|
|
c026d05bc3 | ||
|
|
602b736163 | ||
|
|
c5b1b67c50 | ||
|
|
8eae892983 | ||
|
|
7d32d03156 | ||
|
|
f9e83f2cc7 | ||
|
|
20d3251a93 | ||
|
|
147f56749e | ||
|
|
9140fc71b9 | ||
|
|
d6abd2202c | ||
|
|
911d4edb9f | ||
|
|
e9e5b07bdb | ||
|
|
cef1c0d1d1 | ||
|
|
0fb54a5edd | ||
|
|
abd7a88ba0 | ||
|
|
d37457dc10 | ||
|
|
fc7707ad3e | ||
|
|
f43c6ab3c5 | ||
|
|
11c3b6cfe2 | ||
|
|
b4a997cde9 | ||
|
|
7105255212 | ||
|
|
1338491616 | ||
|
|
0afb47ade0 | ||
|
|
88292f2f3b | ||
|
|
d389dab8d2 | ||
|
|
1205bdcaae | ||
|
|
5e7e055539 | ||
|
|
3822be76a8 | ||
|
|
b904237c5a | ||
|
|
df930cb879 | ||
|
|
327331475e | ||
|
|
91a8386ba4 | ||
|
|
b7e0619e9a | ||
|
|
0b984a44d7 | ||
|
|
b2b221516c | ||
|
|
1bcb0128f0 | ||
|
|
5633291ab0 | ||
|
|
785ae01a51 | ||
|
|
34fd9d0d88 | ||
|
|
9f19676dc2 | ||
|
|
4a3fb55b30 | ||
|
|
eaa6327663 | ||
|
|
13ca506015 | ||
|
|
59d0bafdc9 | ||
|
|
cee85942e6 | ||
|
|
f303d3c45d | ||
|
|
6f7f74f7c6 | ||
|
|
ba398569c1 | ||
|
|
a8a47dca8f | ||
|
|
f782a7027a | ||
|
|
a434318535 | ||
|
|
134265094c | ||
|
|
4909e7861f | ||
|
|
ad9a7fdce8 | ||
|
|
97e97d0984 | ||
|
|
4c6433b0f1 | ||
|
|
f0d956f71c | ||
|
|
3a9d348cab | ||
|
|
586bd13cc2 | ||
|
|
e601e2acca | ||
|
|
2a3c0e11da | ||
|
|
bee40ae35c | ||
|
|
0392a1649f | ||
|
|
d4b52ad4f1 | ||
|
|
91249bc892 | ||
|
|
369eab3b5f | ||
|
|
6780d17d2e | ||
|
|
af22fee0c1 | ||
|
|
61c111d5ae | ||
|
|
3301148da6 | ||
|
|
9ce0497f00 | ||
|
|
36027583cd | ||
|
|
9abf4b126c | ||
|
|
ec5a4d09b8 | ||
|
|
2832736826 | ||
|
|
b87e3c22b3 | ||
|
|
9582cc0211 | ||
|
|
1943877b21 | ||
|
|
c876534c85 | ||
|
|
b91c42e186 | ||
|
|
27c8f883ff | ||
|
|
5817b5fe19 | ||
|
|
1db9b04bfd | ||
|
|
00d851998b | ||
|
|
927dbbfe82 | ||
|
|
d73ed95719 | ||
|
|
01194d5e25 | ||
|
|
32d31da0da | ||
|
|
655afa088d | ||
|
|
0355e1bfc7 | ||
|
|
5aa68c7052 | ||
|
|
6e36f66dde | ||
|
|
32e4569495 | ||
|
|
5a591d2acd | ||
|
|
e8980fbbfe | ||
|
|
8e68781a1b | ||
|
|
ad19d64ce8 | ||
|
|
5ed84e3ae5 | ||
|
|
5264863863 | ||
|
|
9c5c2ac8bf | ||
|
|
1bbcf67396 | ||
|
|
8b44b4d8f1 | ||
|
|
ea7266dc3b | ||
|
|
effb76c8db | ||
|
|
2d52c4f4f5 | ||
|
|
a753037178 | ||
|
|
0d449f1292 | ||
|
|
a0762aca45 | ||
|
|
88ad68069c | ||
|
|
80ef69c710 | ||
|
|
6b164e10f2 | ||
|
|
b3d70f2556 | ||
|
|
8fa708d573 | ||
|
|
a68612ca2b | ||
|
|
7d483b36d0 | ||
|
|
61e409a09e | ||
|
|
5564d93d59 | ||
|
|
6674135c74 | ||
|
|
a4fbc050cc | ||
|
|
205b6d9881 | ||
|
|
f2d1a4190a | ||
|
|
6008dc12d3 | ||
|
|
118b4367e7 | ||
|
|
e6f8269c0b | ||
|
|
928128ba2d | ||
|
|
444567faac | ||
|
|
eaa6ea80e6 | ||
|
|
a4d362d43d | ||
|
|
89e2f4a481 | ||
|
|
8acc9af1f5 | ||
|
|
0ebc1a766e | ||
|
|
bf6211903c | ||
|
|
ad262f6fb3 | ||
|
|
0a7d1831d2 | ||
|
|
ca56e08459 | ||
|
|
31bfe3930d | ||
|
|
48624b1db6 | ||
|
|
5a33a002e4 | ||
|
|
43d3cc36e9 | ||
|
|
ee813abdc1 | ||
|
|
411acc0a2f | ||
|
|
28cd649db3 | ||
|
|
94f2269ff2 | ||
|
|
c106b74239 | ||
|
|
3ae7c42afa | ||
|
|
0d4de50f13 | ||
|
|
d4c1e2fc36 | ||
|
|
903a9356a9 | ||
|
|
2f6018c35c | ||
|
|
0e0fb68c38 | ||
|
|
f60d8c6c96 | ||
|
|
4a9e38a221 | ||
|
|
f0a9889f33 | ||
|
|
aa386e12bc | ||
|
|
ba46ab7361 | ||
|
|
5ce3ce06c6 | ||
|
|
e95d940b5d | ||
|
|
14ed83fb31 | ||
|
|
497d42b822 | ||
|
|
3bae4839bd | ||
|
|
81adcd03fb | ||
|
|
7f3c67724e | ||
|
|
741ad29d82 | ||
|
|
374de57e15 | ||
|
|
ff30d505af | ||
|
|
d4dc32a5e5 | ||
|
|
c073a66e7e | ||
|
|
4d2de63374 | ||
|
|
fa33c5852c | ||
|
|
510d9ab4d8 | ||
|
|
4f07613154 | ||
|
|
d2b5283489 | ||
|
|
aec68c52ab | ||
|
|
b5e965cf1a | ||
|
|
640723a4e7 | ||
|
|
ccca3a38f0 | ||
|
|
9b862b672f | ||
|
|
ad4c1aae45 | ||
|
|
099d1259b2 | ||
|
|
e5206e65e7 | ||
|
|
9332d6f36c | ||
|
|
f4be3aa9de | ||
|
|
0f54e85b36 | ||
|
|
ed9400912c | ||
|
|
999af63118 | ||
|
|
b0e2200166 | ||
|
|
43d4acc94b | ||
|
|
7a253dc9e4 | ||
|
|
b587f88968 | ||
|
|
491748af9f | ||
|
|
10e981d034 | ||
|
|
e188ae732a | ||
|
|
7e8d8eef5a | ||
|
|
e6d6b60b63 | ||
|
|
70beb6c60c | ||
|
|
1990722f18 | ||
|
|
aa416a782d | ||
|
|
7f2d5f4d69 | ||
|
|
4fa6d37d6f | ||
|
|
b061844530 | ||
|
|
5add196d88 | ||
|
|
1e580638d2 | ||
|
|
f33d6610e7 | ||
|
|
a592f37593 | ||
|
|
51dd869874 | ||
|
|
5347409804 | ||
|
|
aa6f82c31f | ||
|
|
d9bd63d34f | ||
|
|
a8f5604718 | ||
|
|
cf4f999b6a | ||
|
|
52029f83ef | ||
|
|
0c9a06789a | ||
|
|
5709d2e757 | ||
|
|
11a0e01f08 | ||
|
|
553c0e6d6a | ||
|
|
7b81bb3fc2 | ||
|
|
e609670a41 | ||
|
|
a7b455fb9a | ||
|
|
8ed857b4b9 | ||
|
|
2bb8c535bd | ||
|
|
e09884af60 | ||
|
|
57399aeab2 | ||
|
|
33c3e86e66 | ||
|
|
a7e77c3ea6 | ||
|
|
2d76364b09 | ||
|
|
36eaa18749 | ||
|
|
f7bb08382c | ||
|
|
9841a39d04 | ||
|
|
edf53840de | ||
|
|
6bd2dcff2a | ||
|
|
73117f6f27 | ||
|
|
3d588a88e2 | ||
|
|
636dbd4e57 | ||
|
|
0a04a856da | ||
|
|
e139284a98 | ||
|
|
a04980b251 | ||
|
|
8120a0cb9c | ||
|
|
c84f8808cb | ||
|
|
1b444a42f2 | ||
|
|
a7e79b13f9 | ||
|
|
3e6be7e04c | ||
|
|
aa1e3f59ed | ||
|
|
a13fb1f94f | ||
|
|
19f4faa03f | ||
|
|
965148f3a6 | ||
|
|
a0c0ab1871 | ||
|
|
43cbe2dd39 | ||
|
|
9c00de047a | ||
|
|
49649a8814 | ||
|
|
18a67a80bc | ||
|
|
867669cc98 | ||
|
|
0158a93391 | ||
|
|
fdb6533149 | ||
|
|
6f32d721c2 | ||
|
|
5f49656e30 | ||
|
|
8114b165d9 | ||
|
|
dd39cb5a1c | ||
|
|
7f8c217e7c | ||
|
|
d731a095c6 | ||
|
|
6630899d6e | ||
|
|
0cfd5095a7 | ||
|
|
a588267fc2 | ||
|
|
4f379821b5 | ||
|
|
9eea7dabc2 | ||
|
|
ca85a9a2a5 | ||
|
|
e34885de9b | ||
|
|
192b9213ac | ||
|
|
7e26e2bc21 | ||
|
|
f9c0482949 | ||
|
|
7e0d7ef727 | ||
|
|
d6820a69d4 | ||
|
|
cf09ff8dc3 | ||
|
|
bda941746b | ||
|
|
f638a2ff49 | ||
|
|
b348a882f4 | ||
|
|
9e4a50fb15 | ||
|
|
cfe657d853 | ||
|
|
a1c3789ec2 | ||
|
|
1cf9ad55c6 | ||
|
|
087d896569 | ||
|
|
17fc15138a | ||
|
|
d4af28c52b | ||
|
|
767a162077 | ||
|
|
78d7fe3e10 | ||
|
|
dc18a6c3bc | ||
|
|
03cb738e55 | ||
|
|
d1c834e647 | ||
|
|
03a082fe40 | ||
|
|
7691377c1b | ||
|
|
0534570784 | ||
|
|
f2e389593a | ||
|
|
2037c3b202 | ||
|
|
1dc7db4456 | ||
|
|
fae79d67e6 | ||
|
|
271f69d59d | ||
|
|
6563cd9c8b | ||
|
|
8d700491da | ||
|
|
7962c104b6 | ||
|
|
505d0f4768 | ||
|
|
e60b8a7aef | ||
|
|
cb65eca062 | ||
|
|
d6a5913086 | ||
|
|
a644599b16 | ||
|
|
52def43f5a | ||
|
|
5d2715dd32 | ||
|
|
13af98e5ad | ||
|
|
d14e907954 | ||
|
|
3f804339b9 | ||
|
|
a73a393e26 | ||
|
|
1bad233e2f | ||
|
|
f64b1d3651 | ||
|
|
eb57c2f980 | ||
|
|
ecd491cd09 | ||
|
|
dead3138b3 | ||
|
|
0416082d4d | ||
|
|
98d1fd85fb | ||
|
|
719aacd6f8 | ||
|
|
4ee2ca2a33 | ||
|
|
45f9d5bb81 | ||
|
|
9f2d87d7d7 | ||
|
|
d5b163f04d | ||
|
|
05c77d0248 | ||
|
|
2fc4c30bed | ||
|
|
d2590c7651 | ||
|
|
237af505f9 | ||
|
|
7b4f522a33 | ||
|
|
0e7ce55f9a | ||
|
|
fe43b3494c | ||
|
|
4c00c8f3ec | ||
|
|
f05518e357 | ||
|
|
6e667e078c | ||
|
|
84a36624a6 | ||
|
|
165c551e39 | ||
|
|
fe6ed2ceae | ||
|
|
92bcd549ef | ||
|
|
5216471226 | ||
|
|
6497ee02fb | ||
|
|
859e26cf8f | ||
|
|
9964360656 | ||
|
|
73f5e7c2ef | ||
|
|
64ffa9bb3f | ||
|
|
ec63d23666 | ||
|
|
a3063eb46d | ||
|
|
40b7cafacc | ||
|
|
82c6b8daae | ||
|
|
3228582cbe | ||
|
|
d0e008665f | ||
|
|
96eacb6efe | ||
|
|
e183d67e2a | ||
|
|
bbf91a8957 | ||
|
|
618d22d214 | ||
|
|
d83459f674 | ||
|
|
6cb6adc134 | ||
|
|
18dded1a00 | ||
|
|
1c2785f34e | ||
|
|
a411cbc640 | ||
|
|
b4c87ad0b9 | ||
|
|
37a35b1827 | ||
|
|
ddae26b48b | ||
|
|
c3f57cf900 | ||
|
|
56b74c6ff2 | ||
|
|
8682c07148 | ||
|
|
96870c3fee | ||
|
|
24a0b4445e | ||
|
|
e139cba621 | ||
|
|
07e8d110a2 | ||
|
|
87c1bc4bdb | ||
|
|
31b13f3551 | ||
|
|
e15f5bb432 | ||
|
|
340ee859f9 | ||
|
|
496ba433e9 | ||
|
|
b183dc3e62 | ||
|
|
0b0ae756a3 | ||
|
|
0ade0405f5 | ||
|
|
fcf8ad0611 | ||
|
|
e0cb6d32ea | ||
|
|
aeed535f1b | ||
|
|
974084a9ca | ||
|
|
88706534c2 | ||
|
|
70def21153 | ||
|
|
941174a9fa | ||
|
|
46bfb2aacd | ||
|
|
a4ef3f770c | ||
|
|
7cf27ac86d | ||
|
|
823e5ca058 | ||
|
|
b7a182129d | ||
|
|
10b147a25d | ||
|
|
6550631003 | ||
|
|
9d04dc7d9a | ||
|
|
486d89c5d0 | ||
|
|
d24f16563f | ||
|
|
bb68838fa4 | ||
|
|
aed6a8a5ff | ||
|
|
e13bceeb59 | ||
|
|
1dab89f7ae | ||
|
|
96facc5100 | ||
|
|
43d94d208f | ||
|
|
741ee99e6b | ||
|
|
6f2cff2f33 | ||
|
|
0035827209 | ||
|
|
c626b1d106 | ||
|
|
9c895cb8bb | ||
|
|
23a9c74297 | ||
|
|
aecb8a1464 | ||
|
|
b9e3426532 | ||
|
|
809d7ab7f4 | ||
|
|
6486b24c8b | ||
|
|
e11d78d37a | ||
|
|
3a34b3ae5f | ||
|
|
75a8f85ebb | ||
|
|
3d8f757712 | ||
|
|
b37d6ec500 | ||
|
|
4efd8b7d3f | ||
|
|
5d17933593 | ||
|
|
277d8f8b93 | ||
|
|
f2c5add752 | ||
|
|
60d37b6de0 | ||
|
|
206778021f | ||
|
|
4a262de550 | ||
|
|
350663b7ce | ||
|
|
f1db0fea03 | ||
|
|
1990232adc | ||
|
|
256ed7410f | ||
|
|
09a4cb30ec | ||
|
|
aa3826c389 | ||
|
|
b91042c1e5 | ||
|
|
7eed8c5ee5 | ||
|
|
3207860374 | ||
|
|
b3bb8b6692 | ||
|
|
5b8b13c94c | ||
|
|
e8426006e3 | ||
|
|
116fafd0e1 | ||
|
|
e9fe1800e0 | ||
|
|
82796822d1 | ||
|
|
ce61b783fb | ||
|
|
9b78b2a432 | ||
|
|
321b2c7c23 | ||
|
|
dee397615c | ||
|
|
ef9339f6f1 | ||
|
|
f7f32408fc | ||
|
|
d4e6992442 | ||
|
|
420ece7005 | ||
|
|
741d2b3f3c | ||
|
|
c8bf319b39 | ||
|
|
34df52be5f | ||
|
|
fc2399a885 | ||
|
|
699ec93ca4 | ||
|
|
10598063d1 | ||
|
|
db1e9574cd | ||
|
|
af74a1575b | ||
|
|
03242e1a9c | ||
|
|
dcbd89ff7c | ||
|
|
2312561041 | ||
|
|
b591fbecf0 | ||
|
|
3fbb440436 | ||
|
|
d358185a04 | ||
|
|
8babb59f75 | ||
|
|
3461ec2281 | ||
|
|
3dd94bd362 | ||
|
|
827c2140b7 | ||
|
|
5a5222a97a | ||
|
|
bea3699451 | ||
|
|
93174f54a3 | ||
|
|
e1348725c1 | ||
|
|
528839cde2 | ||
|
|
c5c331ee30 | ||
|
|
69effa37a3 | ||
|
|
4c7a781228 | ||
|
|
62a67bdb94 | ||
|
|
c5c0b36f28 | ||
|
|
0d48c758df | ||
|
|
4856413b24 | ||
|
|
a1586280a9 | ||
|
|
00950b2c97 | ||
|
|
79666bd51a | ||
|
|
ca284a86a3 | ||
|
|
ee5b341d0e | ||
|
|
85cab5031d | ||
|
|
2f7029516b | ||
|
|
a1da4d4233 | ||
|
|
24724e3340 | ||
|
|
048ab9a0fc | ||
|
|
40b005f46e | ||
|
|
ae2a99acb0 | ||
|
|
a81b6db093 | ||
|
|
39b05f5f9f | ||
|
|
979f61df99 | ||
|
|
e665f4e285 | ||
|
|
2c25dfcf1b | ||
|
|
0632028579 | ||
|
|
95b9085258 | ||
|
|
cdc91feb28 | ||
|
|
4caf278da5 | ||
|
|
809a3420c1 | ||
|
|
af8e9058a3 | ||
|
|
2b1c8cafe9 | ||
|
|
1abcd5819b | ||
|
|
76b34bb600 | ||
|
|
67c6a042a0 | ||
|
|
72c2a213b4 | ||
|
|
ec1cc8ec64 | ||
|
|
fbbb079599 | ||
|
|
b8f2f3e34d | ||
|
|
39b29ee3f0 | ||
|
|
5f6cb1e0d7 | ||
|
|
46250e6fab | ||
|
|
fc2a26f249 | ||
|
|
341caa3489 | ||
|
|
38b7474f0b | ||
|
|
c91e2d13c0 | ||
|
|
7134bbf484 | ||
|
|
6b18b258a4 | ||
|
|
86e4826e48 | ||
|
|
6461ebe2b8 | ||
|
|
bfb23ad60b | ||
|
|
637d6a0076 | ||
|
|
cbb8d13570 | ||
|
|
2a6ba79e9a | ||
|
|
1e0dacfe9b | ||
|
|
b194c0c5d8 | ||
|
|
9789b7081a | ||
|
|
03052ddd28 | ||
|
|
779f685f72 | ||
|
|
1dee767762 | ||
|
|
5cac5eb26b | ||
|
|
b26cbeccca | ||
|
|
8d4612c683 | ||
|
|
e352c70013 | ||
|
|
8c3c9a8ca4 | ||
|
|
ada837f7e4 | ||
|
|
1599b2644a | ||
|
|
acc3d00fd5 | ||
|
|
0f459ede50 | ||
|
|
105cb6fa13 | ||
|
|
1797c702a7 | ||
|
|
5f580f10ca | ||
|
|
bd359f42f5 | ||
|
|
34bf73210e | ||
|
|
f1db4aae35 | ||
|
|
7710ffcbf1 | ||
|
|
e9f45a0d0a | ||
|
|
743c2c3385 | ||
|
|
6f714facf9 | ||
|
|
5f81c7bc2d | ||
|
|
72b00fa9af | ||
|
|
449756a0e4 | ||
|
|
75930a0ce9 | ||
|
|
a2c3b0926b | ||
|
|
57666e9173 | ||
|
|
37f4b83d96 | ||
|
|
5576b7568c | ||
|
|
99e067b863 | ||
|
|
5103624fe0 | ||
|
|
26e5d57ced | ||
|
|
b586f2552c | ||
|
|
0fbcec667c | ||
|
|
f40c58c64a | ||
|
|
d66619fe6d | ||
|
|
5c1b007c1b | ||
|
|
86c394f4ce | ||
|
|
90d130a3bc | ||
|
|
f185836fd4 | ||
|
|
4c851856ff | ||
|
|
bc2a0432b9 | ||
|
|
f72e140327 | ||
|
|
a633ed3c9a | ||
|
|
a8a6882708 | ||
|
|
1b76ed97e1 | ||
|
|
04f7169aef | ||
|
|
d83b362c9f | ||
|
|
b1d55348dc | ||
|
|
f8a46c56e9 | ||
|
|
420525cdf0 | ||
|
|
686cacd5ae | ||
|
|
0092806dda | ||
|
|
2f8c63505f | ||
|
|
d892be4971 | ||
|
|
59ed833abc | ||
|
|
110fb2e8db | ||
|
|
9f7a04e330 | ||
|
|
ccbc519c04 | ||
|
|
036860b91b | ||
|
|
7ac2a55315 | ||
|
|
f6cf8d2b1b | ||
|
|
16788d7ab7 | ||
|
|
3142d48f3c | ||
|
|
e1a88ae899 | ||
|
|
a2e80cee47 | ||
|
|
5f14769abc | ||
|
|
406c4063de | ||
|
|
b4bc30e56f | ||
|
|
3482d6c303 | ||
|
|
9dfd5c0bcc | ||
|
|
b2b3de2782 | ||
|
|
50c20e2290 | ||
|
|
a10dcbd918 | ||
|
|
6e0433a34b | ||
|
|
8833e19c7f | ||
|
|
663358bdae | ||
|
|
d9b1c0e2e6 | ||
|
|
39334b81ac | ||
|
|
62f7323acf | ||
|
|
3f89baa1fe | ||
|
|
f0fd1bb40c | ||
|
|
f303d7b33e | ||
|
|
d356a3fa56 | ||
|
|
64e7cad292 | ||
|
|
0766938f98 | ||
|
|
918829ad0a | ||
|
|
540e1fc492 | ||
|
|
ac30754a96 | ||
|
|
b470a0ebbf | ||
|
|
69daee4137 | ||
|
|
3d6c903273 | ||
|
|
bc04a0b9f0 | ||
|
|
bfef94dbfb | ||
|
|
9e06275945 | ||
|
|
6410bcf3c8 | ||
|
|
20baf02726 | ||
|
|
8f6fdf361b | ||
|
|
ffa134f824 | ||
|
|
b4bf6b9235 | ||
|
|
c3e37b0e04 | ||
|
|
374bba763b | ||
|
|
2d00e68b78 | ||
|
|
9a965dc693 | ||
|
|
b96ad5b37f | ||
|
|
07a0f67b32 | ||
|
|
c754a757eb | ||
|
|
dcd53183a8 | ||
|
|
5641132eb9 | ||
|
|
b4c23f3554 | ||
|
|
7385445aa8 | ||
|
|
5409f83167 | ||
|
|
43951aad69 | ||
|
|
9681d8c805 | ||
|
|
ff4d3cfeac | ||
|
|
8e4338c1c9 | ||
|
|
c27d709b6b | ||
|
|
8caa289586 | ||
|
|
f7568f6348 | ||
|
|
6a6f079a84 | ||
|
|
0f04b86316 | ||
|
|
1dae8a0771 | ||
|
|
9bc816fc1c | ||
|
|
11ea4c7aec | ||
|
|
0c53aa158b | ||
|
|
072e4a4f98 | ||
|
|
1b3b3b0ee6 | ||
|
|
d1e4f0de3e | ||
|
|
fd3f304e07 | ||
|
|
9e7551551f | ||
|
|
36bb5cbe01 | ||
|
|
f754c12e8c | ||
|
|
6f5916e334 | ||
|
|
13dd1cb6b6 | ||
|
|
eb4e7e0f0c | ||
|
|
7afb3e2c6d | ||
|
|
9cf5258053 | ||
|
|
56dfdaecb7 | ||
|
|
1d174a4379 | ||
|
|
2aeded1940 | ||
|
|
c23691284c | ||
|
|
f7f6515561 | ||
|
|
438c14d29d | ||
|
|
87351000ae | ||
|
|
0895a69bac | ||
|
|
4285972e41 | ||
|
|
d33542c4dc | ||
|
|
bda97adf4f | ||
|
|
b6f460940f | ||
|
|
aa0e8ed8d6 | ||
|
|
b99fa16b96 | ||
|
|
630d7a3220 | ||
|
|
03c91664cb | ||
|
|
13773d829a | ||
|
|
d9e6e9b017 | ||
|
|
5d8982c734 | ||
|
|
f13c82da2a | ||
|
|
363b0ba997 | ||
|
|
a4c45942c9 | ||
|
|
a86e3a8636 | ||
|
|
db61d579e6 | ||
|
|
e6e3f2cbd5 | ||
|
|
ffdd539233 | ||
|
|
ef17af38a1 | ||
|
|
6dedfaea2f | ||
|
|
cbb3783d84 | ||
|
|
327c095d79 | ||
|
|
88e222420c | ||
|
|
045eec072b | ||
|
|
5f3c381f88 | ||
|
|
6090cf6c68 | ||
|
|
9ac4239c11 | ||
|
|
da2a3b6883 | ||
|
|
b4accaad07 | ||
|
|
edaef0096a | ||
|
|
afba5ff083 | ||
|
|
8b8d614150 | ||
|
|
090dbfda10 | ||
|
|
04f5a6a9f9 | ||
|
|
d8311ac3fa | ||
|
|
9c7f4dfd98 | ||
|
|
8da66e1599 | ||
|
|
2927b0cfc6 | ||
|
|
4663c296cd | ||
|
|
9403f8fd6e | ||
|
|
badb5c6a9b | ||
|
|
e5430259e9 | ||
|
|
50fd44d3f2 | ||
|
|
a8249b73b6 | ||
|
|
a15f867059 | ||
|
|
4216e2e92b | ||
|
|
8ef20be9bd | ||
|
|
6413c9dddd | ||
|
|
eb10f075b9 | ||
|
|
cd55ed7c8d | ||
|
|
2fb96a1b7d | ||
|
|
c48da3d316 | ||
|
|
9488a9a1ad | ||
|
|
2feb22d3bd | ||
|
|
f74569506e | ||
|
|
6633d0b4fb | ||
|
|
6fb35b40d7 | ||
|
|
614d699098 | ||
|
|
8f4fbff40f | ||
|
|
aaf8d2a233 | ||
|
|
0eb2336bc6 | ||
|
|
f9cc9164b3 | ||
|
|
238ec60f89 | ||
|
|
363a82d068 | ||
|
|
4360c1fe4b | ||
|
|
1d575ece06 | ||
|
|
d246788a35 | ||
|
|
e9fa04dd1b | ||
|
|
8e57932966 | ||
|
|
51ea354bcb | ||
|
|
6334520c66 | ||
|
|
6354883a6f | ||
|
|
477f553675 | ||
|
|
1ded4b2b28 | ||
|
|
16c4116c15 | ||
|
|
f5cfbce1c2 | ||
|
|
7bbf98dfb1 | ||
|
|
533cac7881 | ||
|
|
6afc734e91 | ||
|
|
c4fb826d89 | ||
|
|
1321bdeac5 | ||
|
|
e0b7001a09 | ||
|
|
88120b83bd | ||
|
|
a952f7369c | ||
|
|
d4fab3b46c | ||
|
|
06b3499e7d | ||
|
|
fdd66bd513 | ||
|
|
3b5aaf21fa | ||
|
|
59c46833ed | ||
|
|
aee35cb456 | ||
|
|
4a55ee2965 | ||
|
|
4b490e3ca4 | ||
|
|
6727717d1a | ||
|
|
d08891cffa | ||
|
|
799a83ba73 | ||
|
|
261724e555 | ||
|
|
10e7d660ef | ||
|
|
bdfea7cda5 | ||
|
|
fdb1c5e1f9 | ||
|
|
71734df489 | ||
|
|
071b81eadd | ||
|
|
2abe3fde71 | ||
|
|
27e64ccaa8 | ||
|
|
c9185f265c | ||
|
|
79e5716f36 | ||
|
|
9f0e156916 | ||
|
|
d24d45f4cb | ||
|
|
bf55250ae9 | ||
|
|
36016ad9ef | ||
|
|
bf66b81702 | ||
|
|
758ffbf217 | ||
|
|
f24563503a | ||
|
|
a2dbc40571 | ||
|
|
a096b0777f | ||
|
|
87690a534c | ||
|
|
a70e416b0b | ||
|
|
cd1170c543 | ||
|
|
2962dc1faa | ||
|
|
535c1fd007 | ||
|
|
2bd94aff0e | ||
|
|
9ea90c3400 | ||
|
|
0ac5d85117 | ||
|
|
d3a83142ae | ||
|
|
d5886123d8 | ||
|
|
dea090e7d3 | ||
|
|
ba5e345677 | ||
|
|
13ae7b0976 | ||
|
|
39c7bfb49f | ||
|
|
8479a6581d | ||
|
|
e5885e83eb | ||
|
|
914b41fcd4 | ||
|
|
224aa5b89a | ||
|
|
01c89f6554 | ||
|
|
f0e1bc1d59 | ||
|
|
7b487e1bc3 | ||
|
|
c394495a26 | ||
|
|
6dae420254 | ||
|
|
c69d7f50a3 | ||
|
|
e9c654f30d | ||
|
|
ae9b95f81b | ||
|
|
c240b69b5a | ||
|
|
493a7680e0 | ||
|
|
c28a2acfc7 | ||
|
|
60af960f42 | ||
|
|
4c86d5cfe3 | ||
|
|
99a6142134 | ||
|
|
b2211aec59 | ||
|
|
fa09fff2b5 | ||
|
|
0204cdab83 | ||
|
|
445195e9eb | ||
|
|
7f5b0f359c | ||
|
|
d8f4176487 | ||
|
|
220ab22115 | ||
|
|
67776c46d6 | ||
|
|
2d118ecc65 | ||
|
|
4bc2d7444d | ||
|
|
5c6d72b353 | ||
|
|
1839397ebc | ||
|
|
0ee34637f5 | ||
|
|
c6054f7ab2 | ||
|
|
9554dfbc5e | ||
|
|
98d5789b1b | ||
|
|
0cad1a1e7e | ||
|
|
31c07274cd | ||
|
|
37a736339e | ||
|
|
869abcc835 | ||
|
|
a6f05ea8c2 | ||
|
|
6812099900 | ||
|
|
53e6d7ef2a | ||
|
|
c2f604cd01 | ||
|
|
888ffa1bcd | ||
|
|
d06cfed50e | ||
|
|
e06d063946 | ||
|
|
634ec1b113 | ||
|
|
0bf883d5b2 | ||
|
|
c6d0e0124f | ||
|
|
ce115c53e2 | ||
|
|
7014bc7a3c | ||
|
|
219f9c478d | ||
|
|
a9904c6ada | ||
|
|
81cbc33dbb | ||
|
|
24062beebe | ||
|
|
f3548aff8c | ||
|
|
771bb20976 | ||
|
|
8072caeff1 | ||
|
|
d5568fcc25 | ||
|
|
eb1bcfba83 | ||
|
|
a2d3616171 | ||
|
|
a83e37493a | ||
|
|
0feccc9d1c | ||
|
|
e18ecb8c49 | ||
|
|
f5b87a9865 | ||
|
|
3b93434dd3 | ||
|
|
d522096caf | ||
|
|
6c67110dde | ||
|
|
963efc369b | ||
|
|
384f0a05c6 | ||
|
|
a3016b8400 | ||
|
|
0df219c3ad | ||
|
|
a0d527f795 | ||
|
|
e44ea47497 | ||
|
|
9ee5a3159c | ||
|
|
06d41c552b | ||
|
|
7874971550 | ||
|
|
9925716134 | ||
|
|
64c456678b | ||
|
|
16ed09a303 | ||
|
|
1359a1aa7a | ||
|
|
6ae36982b6 | ||
|
|
136d269605 | ||
|
|
932c0ed4ad | ||
|
|
371875440f | ||
|
|
b01a9f2f95 | ||
|
|
0d2def102f | ||
|
|
beff3fe843 | ||
|
|
7a97c94f2b | ||
|
|
ce81908f02 | ||
|
|
f8cecfd61f | ||
|
|
8a2a1d6d70 | ||
|
|
76dafea8a6 | ||
|
|
86bbfde19e | ||
|
|
71d6f7b1a2 | ||
|
|
0c0ab612c0 | ||
|
|
73042115e0 | ||
|
|
0f7166d34a | ||
|
|
f35f2c95f0 | ||
|
|
4d280bdb6d | ||
|
|
47acab766d | ||
|
|
1cc3819e65 | ||
|
|
16fa6259ea | ||
|
|
95717c4c32 | ||
|
|
7564f27f95 | ||
|
|
565046aaa6 | ||
|
|
fb9b023fae | ||
|
|
b05908a760 | ||
|
|
3bbcd235e1 | ||
|
|
9d66984c62 | ||
|
|
9024408ed2 | ||
|
|
2b32e864fd | ||
|
|
626d0cba46 | ||
|
|
2a11e9962d | ||
|
|
7dffddd437 | ||
|
|
6a7600fd52 | ||
|
|
b897f202dd | ||
|
|
eb396f2367 | ||
|
|
95bf3e3af4 | ||
|
|
19944202fb | ||
|
|
2596ad27c3 | ||
|
|
ece914303a | ||
|
|
7a0c12e073 | ||
|
|
14b23b491f | ||
|
|
039b03249b | ||
|
|
3919cf4f86 | ||
|
|
319a9fd2de | ||
|
|
cf1f9f93aa | ||
|
|
0dd805da7f | ||
|
|
e7a1833c44 | ||
|
|
54f8487b46 | ||
|
|
b68d721b39 | ||
|
|
b9ccb4e52c | ||
|
|
c4a11f73a0 | ||
|
|
f96d4198c3 | ||
|
|
fe6a0ec5b8 | ||
|
|
e7b4010eba | ||
|
|
c4947d3737 | ||
|
|
8a8d677f85 | ||
|
|
baf4393310 | ||
|
|
723916d930 | ||
|
|
591d66564d | ||
|
|
79a2d522bf | ||
|
|
4ad34ab5c8 | ||
|
|
33c7847dfc | ||
|
|
7a04f15710 | ||
|
|
7e5b10eb3e | ||
|
|
896a07fa9a | ||
|
|
07e8bb00fb | ||
|
|
5d2742dd37 | ||
|
|
9ae3f1eb68 | ||
|
|
8c6e0cf43a | ||
|
|
1e220fd506 | ||
|
|
4ff7855fd4 | ||
|
|
eb57354109 | ||
|
|
a82a33cecf | ||
|
|
c90fc2a9bf | ||
|
|
c1a40e4aeb | ||
|
|
9999b8bfab | ||
|
|
cf62008acf | ||
|
|
1c959a6653 | ||
|
|
b8043a2432 | ||
|
|
ed5de525aa | ||
|
|
8105d9388b | ||
|
|
8151dcc05f | ||
|
|
25b1c5fe90 | ||
|
|
f566d2a0da | ||
|
|
ea218839e4 | ||
|
|
4c18a1881b | ||
|
|
0be2b2791f | ||
|
|
bf51e3db60 | ||
|
|
abca69f408 | ||
|
|
6eac9102c9 | ||
|
|
0a7da1b7f2 | ||
|
|
b4361cb202 | ||
|
|
d2d4090e27 | ||
|
|
583eb53c9d | ||
|
|
39246f2beb | ||
|
|
cd2d3d5fa3 | ||
|
|
589e646023 | ||
|
|
b7ba3098ae | ||
|
|
631c5ecae3 | ||
|
|
4962e45bd9 | ||
|
|
c57219a356 | ||
|
|
03e6a187c5 | ||
|
|
0bdbbd35e3 | ||
|
|
401afe7c1a | ||
|
|
66b0039566 | ||
|
|
17da51440c | ||
|
|
c5adeecd90 | ||
|
|
da6c62414b | ||
|
|
6650fe863f | ||
|
|
76c00c42b5 | ||
|
|
f8609419a1 | ||
|
|
250e79eda1 | ||
|
|
f7401daeae | ||
|
|
7390e372e0 | ||
|
|
239c521ad9 | ||
|
|
77b4f09cfb | ||
|
|
bb178af278 | ||
|
|
3c39661174 | ||
|
|
1fffc1e828 | ||
|
|
ed50cd1fa8 | ||
|
|
ef6a02e8ef | ||
|
|
e7003dde83 | ||
|
|
bf2a393fd3 | ||
|
|
bb2cfd52f4 | ||
|
|
6a6f88c6ef | ||
|
|
0a2b1a4fbe | ||
|
|
5fd48c9e98 | ||
|
|
022d32cd44 | ||
|
|
af10cf71db | ||
|
|
1bf1de8ce8 | ||
|
|
b80ddfa580 | ||
|
|
aa276ab308 | ||
|
|
f50943d470 | ||
|
|
959c998664 | ||
|
|
b6b6b6043c | ||
|
|
8e0807d502 | ||
|
|
78d027b25e | ||
|
|
503f0bee69 | ||
|
|
50d756b189 | ||
|
|
7c3d71e572 | ||
|
|
bf895d26b0 | ||
|
|
5530e8581a | ||
|
|
f913816d87 | ||
|
|
3d59d31b0a | ||
|
|
9a66f26bd9 | ||
|
|
d5b6605ce8 | ||
|
|
38e5184be4 | ||
|
|
369ec9f814 | ||
|
|
620b454c49 | ||
|
|
2e5040e65d | ||
|
|
71fe7ef125 | ||
|
|
e0e8fd7ddc | ||
|
|
01b4b4933e | ||
|
|
fced22bc60 | ||
|
|
898ae64a57 | ||
|
|
8d50852cbe | ||
|
|
a11c7b10ac | ||
|
|
fe9033b8be | ||
|
|
e26f9e4a71 | ||
|
|
c477328da4 | ||
|
|
214c64c49e | ||
|
|
bce0e9194c | ||
|
|
40326177fd | ||
|
|
4ab0272fa6 | ||
|
|
fb33366c91 | ||
|
|
75352c9afe | ||
|
|
a0f98e3823 | ||
|
|
bff6768adf | ||
|
|
8ce2eb448c | ||
|
|
7c5d00f8a4 | ||
|
|
30cd499014 | ||
|
|
99d173789e | ||
|
|
ae72db8129 | ||
|
|
9437cc1806 | ||
|
|
0e76aa0265 | ||
|
|
756e654d32 | ||
|
|
78d1c57b7c | ||
|
|
bb27405e8f | ||
|
|
0cfc46b417 | ||
|
|
bfb30fe68d | ||
|
|
63959b4b22 | ||
|
|
66d086892f | ||
|
|
b878570b14 | ||
|
|
b059610d16 | ||
|
|
972aa7f4e3 | ||
|
|
797c1421da | ||
|
|
e65cbcba65 | ||
|
|
6d96dd3818 | ||
|
|
16db31c53c | ||
|
|
c72138d15a | ||
|
|
6d28a9ad58 | ||
|
|
75c8d97a6e | ||
|
|
c35f53af89 | ||
|
|
55784f64b8 | ||
|
|
a7241d4128 | ||
|
|
1573d794b9 | ||
|
|
bc725800ed | ||
|
|
007728819b | ||
|
|
f32f13069f | ||
|
|
5ec73da17f | ||
|
|
5fd3689333 | ||
|
|
cca1431012 | ||
|
|
7ba9558a7a | ||
|
|
c65e8b4a5e | ||
|
|
eed75bcbda | ||
|
|
1af4325e8f | ||
|
|
5e6719e22e | ||
|
|
a4bbfe3c79 | ||
|
|
63f42fc8bb | ||
|
|
5b9bcf8b1d | ||
|
|
f02bc82525 | ||
|
|
92f89c6cc1 | ||
|
|
a1908be982 | ||
|
|
f08894629d | ||
|
|
e46f11e6f8 | ||
|
|
df6234ea28 | ||
|
|
21cdf8b0ae | ||
|
|
192fb07ef1 | ||
|
|
226216d111 | ||
|
|
bed2deb683 | ||
|
|
6e327b69f5 | ||
|
|
1d3c8e867e | ||
|
|
d0f761172a | ||
|
|
fd6a8aec71 | ||
|
|
e00e501605 | ||
|
|
81a42ef1df | ||
|
|
ee5eb2abc5 | ||
|
|
0ed14f0288 | ||
|
|
c55f740978 | ||
|
|
38952b6734 | ||
|
|
925058467f | ||
|
|
e5afeccadf | ||
|
|
ad18604552 | ||
|
|
d2d506dbf0 | ||
|
|
2833d3126f | ||
|
|
950367b055 | ||
|
|
703859ac75 | ||
|
|
4bf5434e8f | ||
|
|
350c44f56f | ||
|
|
679c630a4d | ||
|
|
dbbb0a4a3d | ||
|
|
0ca7d074ac | ||
|
|
65894bf582 | ||
|
|
8eacf738c2 | ||
|
|
1b69eda43e | ||
|
|
09d1c958ce | ||
|
|
589d16bc37 | ||
|
|
a6dfa6bbbd | ||
|
|
b2721305c5 | ||
|
|
24b3404876 | ||
|
|
92ca1e4873 | ||
|
|
59d44c41e4 | ||
|
|
08a503f160 | ||
|
|
e0e68835ef | ||
|
|
734287b66d | ||
|
|
ddc9d30a3e | ||
|
|
890bf9eced | ||
|
|
b8677b2b9a | ||
|
|
1f5e974cfc | ||
|
|
54e7e44df1 | ||
|
|
9736810f87 | ||
|
|
5547383434 | ||
|
|
85f8e77928 | ||
|
|
6918216b86 | ||
|
|
efd2ad8f8b | ||
|
|
5a8ce52105 | ||
|
|
cbee65671c | ||
|
|
75a7ce24bf | ||
|
|
013f56347d | ||
|
|
a052bfd2fa | ||
|
|
4b1fa2589e | ||
|
|
1a61c855ca | ||
|
|
0159eea526 | ||
|
|
f3bb5c55f5 | ||
|
|
9ecbddc18c | ||
|
|
d36bf188ae | ||
|
|
b8cddbca88 | ||
|
|
ee9b7166a6 | ||
|
|
9c1c755836 | ||
|
|
6722ca41bf | ||
|
|
9586d478ad | ||
|
|
77cf4a5332 | ||
|
|
7199ab7cbe | ||
|
|
5de2ff40d8 | ||
|
|
790d532cee | ||
|
|
9f03e353c7 | ||
|
|
68e3d53cb7 | ||
|
|
f9082e18e2 | ||
|
|
02d31a7947 | ||
|
|
3e3faf6576 | ||
|
|
bee1db135f | ||
|
|
09d39ca425 | ||
|
|
d58b6e5117 | ||
|
|
f0cf3e6411 | ||
|
|
b64d5ef357 | ||
|
|
2eccf77986 | ||
|
|
0b8b766b62 | ||
|
|
8d634a79c8 | ||
|
|
4b9e7fdb15 | ||
|
|
165a84534a | ||
|
|
fe4cab3a9e | ||
|
|
9e5166d991 | ||
|
|
48e78125e8 | ||
|
|
3fb3a92a8f | ||
|
|
8dba55d5cb | ||
|
|
045a5156d1 | ||
|
|
8a162e39d5 | ||
|
|
695788df0e | ||
|
|
4df96b03eb | ||
|
|
49c2cb985c | ||
|
|
a189dc8243 | ||
|
|
ff8db0cd77 | ||
|
|
eff3e4015b | ||
|
|
9ad43e13da | ||
|
|
1bd3a792da | ||
|
|
75f8e473a5 | ||
|
|
8c25ee7796 | ||
|
|
c3520cf606 | ||
|
|
75d2d97d8e | ||
|
|
778a542e1c | ||
|
|
74f3d551f2 | ||
|
|
fcc7b6791e | ||
|
|
56e2db22eb | ||
|
|
c56f024a86 | ||
|
|
6fd35ae5d9 | ||
|
|
1db2895606 | ||
|
|
df60ee06a1 | ||
|
|
0b4b2d3814 | ||
|
|
9f08153a85 | ||
|
|
5031700af6 | ||
|
|
57245dd77e | ||
|
|
4697a1904a | ||
|
|
ed380c86eb | ||
|
|
02deecf54b | ||
|
|
133c0312be | ||
|
|
45e501ce02 | ||
|
|
87dfa8c7fc | ||
|
|
edefb588b6 | ||
|
|
8ce8b85089 | ||
|
|
54c409a71c | ||
|
|
2f8960d34f | ||
|
|
20036bd72b | ||
|
|
38a84d4598 | ||
|
|
bc1372c2f9 | ||
|
|
c241100886 | ||
|
|
fea2d91a63 | ||
|
|
f2c4aa852d | ||
|
|
f8d09917a5 | ||
|
|
bbdf1152c1 | ||
|
|
f208731746 | ||
|
|
0516cfa296 | ||
|
|
157e8413fb | ||
|
|
4708af3b91 | ||
|
|
bee47d7fda | ||
|
|
d246db7be8 | ||
|
|
02025bc70a | ||
|
|
4275298f19 | ||
|
|
f0a02e4734 | ||
|
|
59464469c2 | ||
|
|
4d880a0d77 | ||
|
|
26b28e2364 | ||
|
|
835b597af5 | ||
|
|
c44d22ccf5 | ||
|
|
a11cda91de | ||
|
|
cfbbb85254 | ||
|
|
8a0bba3c7a | ||
|
|
da1135c80f | ||
|
|
7fcf481243 | ||
|
|
06e54a17c0 | ||
|
|
1fe23ff732 | ||
|
|
39278b47dd | ||
|
|
bff0030f2b | ||
|
|
b4b2f91363 | ||
|
|
c7252a950b | ||
|
|
e48b01fd18 | ||
|
|
ef2337f6d8 | ||
|
|
13d83cb0d1 | ||
|
|
033355395f | ||
|
|
6c332b949b | ||
|
|
0711438433 | ||
|
|
ee6350189f | ||
|
|
46189c0ff1 | ||
|
|
45d55e87eb | ||
|
|
8a158146cd | ||
|
|
1a859fc639 | ||
|
|
43518c6cfe | ||
|
|
7bfb7b6da0 | ||
|
|
c0474b206e | ||
|
|
fe6623b342 | ||
|
|
de8220245c | ||
|
|
562f0d9872 | ||
|
|
ed89f858e1 | ||
|
|
9527b2c456 | ||
|
|
5da2e5e7b7 | ||
|
|
e55e5aa168 | ||
|
|
22b45266bf | ||
|
|
b280b5a517 | ||
|
|
60cb358cce | ||
|
|
f03a74abc7 | ||
|
|
34885b64bd | ||
|
|
f3bfa4e587 | ||
|
|
3136ce7dc2 | ||
|
|
15a050517b | ||
|
|
85a1c61880 | ||
|
|
15991d0226 | ||
|
|
413bc41695 | ||
|
|
36137808f0 | ||
|
|
12c1852297 | ||
|
|
95e3c3eafc | ||
|
|
c458fa6441 | ||
|
|
66c1e386ce | ||
|
|
59e203fd52 | ||
|
|
7e0c097f23 | ||
|
|
926fa483b9 | ||
|
|
2ebc92a167 | ||
|
|
eb511757db | ||
|
|
b5b97f7626 | ||
|
|
ba0f7416bb | ||
|
|
f6e18de6af | ||
|
|
320a4552bc | ||
|
|
203473c965 | ||
|
|
255177d12b | ||
|
|
290bf00b2d | ||
|
|
8464e6a822 | ||
|
|
8af46ac7fc | ||
|
|
daeaf14032 | ||
|
|
bd52a7c926 | ||
|
|
c8c43de510 | ||
|
|
bb49071088 | ||
|
|
7a523a9d89 | ||
|
|
885d7de11b | ||
|
|
f44675a1e4 | ||
|
|
ce912c7430 | ||
|
|
e9fdd74a99 | ||
|
|
df8269bc7f | ||
|
|
23e4fa82c8 | ||
|
|
9bea604a46 | ||
|
|
119fbd114d | ||
|
|
1b6e6ec2e4 | ||
|
|
2dfa4f9ec9 | ||
|
|
3cd3e89679 | ||
|
|
c3be1c870d | ||
|
|
6b571fd2bb | ||
|
|
92df7abcf0 | ||
|
|
498d1570ce | ||
|
|
e587179359 | ||
|
|
c9985121c4 | ||
|
|
e768600df3 | ||
|
|
3dffb9c8a0 | ||
|
|
eb40297a35 | ||
|
|
837985ccc5 | ||
|
|
1def4b0f0c | ||
|
|
4c430cedf5 | ||
|
|
18d9212253 | ||
|
|
36314691f1 | ||
|
|
24da25f0f7 | ||
|
|
84ba8e6dde | ||
|
|
c6fe035939 | ||
|
|
be9073f0b7 | ||
|
|
ac6c07b771 | ||
|
|
c8102f4bff | ||
|
|
df1fcd5e22 | ||
|
|
de87da9c91 | ||
|
|
3532263af4 | ||
|
|
a9cf4dad82 | ||
|
|
1de1eb6b9b | ||
|
|
f6742d1bbf | ||
|
|
a377c602cc | ||
|
|
58f0ad999c | ||
|
|
f612d35daf | ||
|
|
7d202cb492 | ||
|
|
39bb7f209d | ||
|
|
bbd38a7e47 | ||
|
|
d8b2cc7e1b | ||
|
|
09b328167c | ||
|
|
4439ef07b6 | ||
|
|
f8491e9631 | ||
|
|
63259b3f92 | ||
|
|
10db35eab4 | ||
|
|
0fa79c7a46 | ||
|
|
e20f557bd6 | ||
|
|
25d8d76524 | ||
|
|
cc0f99333f | ||
|
|
982aa874f2 | ||
|
|
2a70964dce | ||
|
|
3051a185e5 | ||
|
|
5e788fff99 | ||
|
|
326c52604b | ||
|
|
e7d1647769 | ||
|
|
1e35116d8f | ||
|
|
35ca3ec895 | ||
|
|
7c30cccc97 | ||
|
|
4194abe4f2 | ||
|
|
0b698576da | ||
|
|
3fbd73129e | ||
|
|
bbd6d171be | ||
|
|
f7929bbf93 | ||
|
|
29cd8530a3 | ||
|
|
574387acac | ||
|
|
6a1ab4d73c | ||
|
|
29e0c32679 | ||
|
|
db7fe023c6 | ||
|
|
ccf3d7a285 | ||
|
|
fb59d5d268 | ||
|
|
05cf14846c | ||
|
|
1d6a421388 | ||
|
|
f73880e565 | ||
|
|
ce13902680 | ||
|
|
fdbf94f415 |
@@ -2,20 +2,13 @@ version: 2
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: penpotapp/devenv:latest
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||
# - image: circleci/postgres:9.4
|
||||
- image: circleci/postgres:13.3-ram
|
||||
- image: cimg/postgres:13.5
|
||||
environment:
|
||||
POSTGRES_USER: penpot_test
|
||||
POSTGRES_PASSWORD: penpot_test
|
||||
POSTGRES_DB: penpot_test
|
||||
|
||||
- image: circleci/redis:6.0.8
|
||||
- image: cimg/redis:6.2.6
|
||||
|
||||
working_directory: ~/repo
|
||||
|
||||
@@ -36,17 +29,30 @@ jobs:
|
||||
- run:
|
||||
name: common lint
|
||||
working_directory: "./common"
|
||||
command: "clj-kondo --parallel --lint src/"
|
||||
command: |
|
||||
clj-kondo --version
|
||||
clj-kondo --parallel --lint src/
|
||||
|
||||
- run:
|
||||
name: frontend lint
|
||||
working_directory: "./frontend"
|
||||
command: "clj-kondo --parallel --lint src/"
|
||||
command: |
|
||||
clj-kondo --version
|
||||
clj-kondo --parallel --lint src/
|
||||
|
||||
- run:
|
||||
name: frontend styles prettier
|
||||
working_directory: "./frontend"
|
||||
command: |
|
||||
yarn install
|
||||
yarn run lint-scss
|
||||
|
||||
- run:
|
||||
name: backend lint
|
||||
working_directory: "./backend"
|
||||
command: "clj-kondo --parallel --lint src/"
|
||||
command: |
|
||||
clj-kondo --version
|
||||
clj-kondo --parallel --lint src/
|
||||
|
||||
# run backend test
|
||||
- run:
|
||||
@@ -68,21 +74,27 @@ jobs:
|
||||
node target/tests.js
|
||||
|
||||
environment:
|
||||
JAVA_HOME: /usr/lib/jvm/openjdk16
|
||||
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin:/usr/lib/jvm/openjdk16/bin
|
||||
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
|
||||
|
||||
# - run:
|
||||
# working_directory: "./common"
|
||||
# name: common tests (cljs)
|
||||
# command: |
|
||||
# yarn install
|
||||
# yarn run compile-test
|
||||
# node target/test.js
|
||||
#
|
||||
# environment:
|
||||
# PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
|
||||
|
||||
- run:
|
||||
working_directory: "./common"
|
||||
name: common tests
|
||||
name: common tests (clj)
|
||||
command: |
|
||||
yarn install
|
||||
clojure -M:dev:shadow-cljs compile test
|
||||
node target/tests.js
|
||||
clojure -X:dev:test
|
||||
|
||||
environment:
|
||||
JAVA_HOME: /usr/lib/jvm/openjdk16
|
||||
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin:/usr/lib/jvm/openjdk16/bin
|
||||
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
|
||||
|
||||
- save_cache:
|
||||
paths:
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
{:lint-as
|
||||
{promesa.core/let clojure.core/let
|
||||
promesa.core/->> clojure.core/->>
|
||||
promesa.core/-> clojure.core/->
|
||||
rumext.alpha/defc clojure.core/defn
|
||||
rumext.alpha/fnc clojure.core/fn
|
||||
app.common.data/export clojure.core/def
|
||||
app.db/with-atomic clojure.core/with-open}
|
||||
app.db/with-atomic clojure.core/with-open
|
||||
app.common.data.macros/get-in clojure.core/get-in
|
||||
app.common.data.macros/select-keys clojure.core/select-keys
|
||||
app.common.logging/with-context clojure.core/do}
|
||||
|
||||
:hooks
|
||||
{:analyze-call
|
||||
{app.common.data/export hooks.export/export
|
||||
{app.common.data.macros/export hooks.export/export
|
||||
potok.core/reify hooks.export/potok-reify
|
||||
cljs.core/specify! hooks.export/clojure-specify
|
||||
app.util.services/defmethod hooks.export/service-defmethod
|
||||
}}
|
||||
|
||||
@@ -17,12 +21,16 @@
|
||||
{:exclude-files
|
||||
["data_readers.clj"
|
||||
"app/util/perf.cljs"
|
||||
"app/common/logging.cljc"
|
||||
"app/common/exceptions.cljc"]}
|
||||
|
||||
:linters
|
||||
{:unsorted-required-namespaces
|
||||
{:level :warning}
|
||||
|
||||
:potok/reify-type
|
||||
{:level :error}
|
||||
|
||||
:unresolved-namespace
|
||||
{:level :warning
|
||||
:exclude [data_readers]}
|
||||
|
||||
@@ -10,15 +10,34 @@
|
||||
sname])]
|
||||
{:node result}))
|
||||
|
||||
(def registry (atom {}))
|
||||
|
||||
(defn potok-reify
|
||||
[{:keys [:node]}]
|
||||
[{:keys [:node :filename] :as params}]
|
||||
(let [[rnode rtype & other] (:children node)
|
||||
result (api/list-node
|
||||
(into [(api/token-node (symbol "deftype"))
|
||||
(api/token-node (gensym (name (:k rtype))))
|
||||
(api/vector-node [])]
|
||||
other))]
|
||||
{:node result}))
|
||||
rsym (symbol (str "event-type-" (name (:k rtype))))
|
||||
reg (get @registry filename #{})]
|
||||
(when-not (:namespaced? rtype)
|
||||
(let [{:keys [:row :col]} (meta rtype)]
|
||||
(api/reg-finding! {:message "ptk/reify type should be namespaced"
|
||||
:type :potok/reify-type
|
||||
:row row
|
||||
:col col})))
|
||||
|
||||
(if (contains? reg rsym)
|
||||
(let [{:keys [:row :col]} (meta rtype)]
|
||||
(api/reg-finding! {:message (str "duplicate type: " (name (:k rtype)))
|
||||
:type :potok/reify-type
|
||||
:row row
|
||||
:col col}))
|
||||
(swap! registry update filename (fnil conj #{}) rsym))
|
||||
|
||||
(let [result (api/list-node
|
||||
(into [(api/token-node (symbol "deftype"))
|
||||
(api/token-node rsym)
|
||||
(api/vector-node [])]
|
||||
other))]
|
||||
{:node result})))
|
||||
|
||||
(defn clojure-specify
|
||||
[{:keys [:node]}]
|
||||
@@ -32,18 +51,26 @@
|
||||
|
||||
(defn service-defmethod
|
||||
[{:keys [:node]}]
|
||||
(let [[rnode rtype & other] (:children node)
|
||||
(let [[rnode rtype ?meta & other] (:children node)
|
||||
rsym (gensym (name (:k rtype)))
|
||||
result (api/list-node
|
||||
[(api/token-node (symbol "do"))
|
||||
(api/list-node
|
||||
[(api/token-node (symbol "declare"))
|
||||
(api/token-node rsym)])
|
||||
(if (= :map (:tag ?meta))
|
||||
(api/list-node
|
||||
[(api/token-node (symbol "reset-meta!"))
|
||||
(api/token-node rsym)
|
||||
?meta])
|
||||
(api/list-node
|
||||
[(api/token-node (symbol "comment"))
|
||||
(api/token-node rsym)]))
|
||||
(api/list-node
|
||||
(into [(api/token-node (symbol "defmethod"))
|
||||
(api/token-node rsym)
|
||||
rtype]
|
||||
other))])]
|
||||
(cons ?meta other)))])]
|
||||
;; (prn "==============" rtype (into {} ?meta))
|
||||
;; (prn (api/sexpr result))
|
||||
{:node result}))
|
||||
|
||||
|
||||
|
||||
51
.github/ISSUE_TEMPLATE/bug_report.md
vendored
51
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -8,49 +8,48 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Actual behavior**
|
||||
|
||||
A clear and concise description of what happens instead; what the bug is.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: (e.g. iOS)
|
||||
- Browser (e.g. chrome, safari)
|
||||
- Version (e.g. 22)
|
||||
- OS (e.g. iOS):
|
||||
- Browser & version (e.g. Chrome 89.0):
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: (e.g. iPhone6)
|
||||
- OS: (e.g. iOS8.1)
|
||||
- Browser (e.g. stock browser, safari)
|
||||
- Version (e.g. 22)
|
||||
- Device & model (e.g. iPhone 6):
|
||||
- OS & version (e.g. iOS 8.1):
|
||||
- Browser & version (e.g. stock browser 22):
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
Specify if using SAAS (https://design.penpot.app) or self-hosted instance.
|
||||
- Host (e.g. https://design.penpot.app, local instance):
|
||||
|
||||
If self-hosted instance, add OS and runtime information to help explain your problem.
|
||||
*If self-hosted:*
|
||||
- OS Version (e.g. Ubuntu 16.04):
|
||||
- Docker / Docker-compose version (e.g. Docker version 18.03.0-ce, build 0520e24):
|
||||
- Image version (e.g. Alpine):
|
||||
|
||||
- OS Version: (e.g. Ubuntu 16.04)
|
||||
Docker commands or docker-compose file (if possible and if proceed.x):
|
||||
```
|
||||
|
||||
Also provide Docker commands or docker-compose file if possible and if proceed.x
|
||||
|
||||
- Docker / Docker-compose Version: (e.g. Docker version 18.03.0-ce, build 0520e24)
|
||||
- Image (e.g. alpine)
|
||||
|
||||
**Frontend Stack Trace (if self-hosted)**
|
||||
```
|
||||
|
||||
Frontend Stack Trace:
|
||||
<details>
|
||||
|
||||
```
|
||||
@@ -59,8 +58,7 @@ Also provide Docker commands or docker-compose file if possible and if proceed.x
|
||||
|
||||
</details>
|
||||
|
||||
**Backend Stack Trace (if self-hosted)**
|
||||
|
||||
Backend Stack Trace:
|
||||
<details>
|
||||
|
||||
```
|
||||
@@ -69,5 +67,6 @@ Also provide Docker commands or docker-compose file if possible and if proceed.x
|
||||
|
||||
</details>
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
**Additional context:**
|
||||
|
||||
Any other context about the problem.
|
||||
|
||||
71
.gitignore
vendored
71
.gitignore
vendored
@@ -1,41 +1,54 @@
|
||||
figwheel_server.log
|
||||
*jar
|
||||
*-init.clj
|
||||
*.jar
|
||||
*.penpot
|
||||
*.orig
|
||||
.calva
|
||||
.clj-kondo
|
||||
.cpcache
|
||||
.lein-deps-sum
|
||||
.lein-failures
|
||||
.lein-repl-history
|
||||
.lein-plugins/
|
||||
.repl
|
||||
.lein-repl-history
|
||||
.lsp
|
||||
.nrepl-port
|
||||
.cpcache
|
||||
.nyc_output
|
||||
.rebel_readline_history
|
||||
/vendor/**/target
|
||||
/cd.md
|
||||
node_modules
|
||||
/backend/target/
|
||||
/backend/resources/public/media
|
||||
/backend/resources/public/assets
|
||||
.repl
|
||||
/.clj-kondo/.cache
|
||||
/_dump
|
||||
/backend/-
|
||||
/backend/assets/
|
||||
/backend/dist/
|
||||
/backend/logs/
|
||||
/backend/-
|
||||
/telemetry/
|
||||
/frontend/npm-debug.log
|
||||
/frontend/target/
|
||||
/frontend/dist/
|
||||
/frontend/out/
|
||||
/frontend/.shadow-cljs
|
||||
/frontend/resources/public/*
|
||||
/frontend/resources/fonts/experiments
|
||||
/exporter/target
|
||||
/exporter/.shadow-cljs
|
||||
/docker/images/bundle*
|
||||
/common/.shadow-cljs
|
||||
/common/target
|
||||
/.clj-kondo/.cache
|
||||
/backend/resources/public/assets
|
||||
/backend/resources/public/media
|
||||
/backend/target/
|
||||
/bundle*
|
||||
/media
|
||||
/cd.md
|
||||
/clj-profiler/
|
||||
/common/.shadow-cljs
|
||||
/common/coverage
|
||||
/common/target
|
||||
/deploy
|
||||
/docker/images/bundle*
|
||||
/exporter/.shadow-cljs
|
||||
/exporter/target
|
||||
/frontend/.shadow-cljs
|
||||
/frontend/package-lock.json
|
||||
/frontend/cypress/videos/*/
|
||||
/frontend/cypress/fixtures/validuser.json
|
||||
/frontend/dist/
|
||||
/frontend/npm-debug.log
|
||||
/frontend/out/
|
||||
/frontend/resources/fonts/experiments
|
||||
/frontend/resources/public/*
|
||||
/frontend/target/
|
||||
/frontend/cypress/videos/*/
|
||||
/media
|
||||
/telemetry/
|
||||
/vendor/**/target
|
||||
/vendor/svgclean/bundle*.js
|
||||
/web
|
||||
/_dump
|
||||
/vendor/svgclean/bundle*.js
|
||||
clj-profiler/
|
||||
figwheel_server.log
|
||||
node_modules
|
||||
|
||||
663
CHANGES.md
663
CHANGES.md
@@ -1,17 +1,571 @@
|
||||
# CHANGELOG #
|
||||
|
||||
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
## :rocket: Next
|
||||
|
||||
### :boom: Breaking changes
|
||||
### :sparkles: New features
|
||||
|
||||
### :bug: Bugs fixed
|
||||
### :arrow_up: Deps updates
|
||||
### :boom: Breaking changes
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
## 1.13.2-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Improved performance when out of focus mode
|
||||
- Improved performance for thumbnail generation
|
||||
- Fix problem with out of sync thumbnails
|
||||
|
||||
## 1.13.1-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with text positioning
|
||||
- Fix issue with thumbnail generation before fonts loading
|
||||
- Fix unable to hide artboards
|
||||
- Fix problem with fonts cache causing hanging in certain pages
|
||||
|
||||
## 1.13.0-beta
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
- We've changed the behaviour of the border-radius so it works as CSS that [has some limits](https://www.w3.org/TR/css-backgrounds-3/#corner-overlap).
|
||||
- Now exported text are SVG's native `text` tag instead of paths. This could break when opening the file depending on your engine. Some SVG's may require fonts to be installed at system level.
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Search and filter layers [Taiga #2564](https://tree.taiga.io/project/penpot/us/2564)
|
||||
- Exporting big files flow [Taiga #2218](https://tree.taiga.io/project/penpot/us/2218)
|
||||
- Multiexport from main menu [Taiga #520](https://tree.taiga.io/project/penpot/us/28541)
|
||||
- Multiexport assets (aka bulk export) [Taiga #520](https://tree.taiga.io/project/penpot/us/520)
|
||||
- Set the artboard layer fixed at the top side of the layers [Taiga #2636](https://tree.taiga.io/project/penpot/us/2636)
|
||||
- Set an artboard as the file thumbnail [Taiga #1526](https://tree.taiga.io/project/penpot/us/1526)
|
||||
- Social login redesign [Taiga #2974](https://tree.taiga.io/project/penpot/task/2974)
|
||||
- Add border radius to artboards [Taiga #2056](https://tree.taiga.io/project/penpot/us/2056)
|
||||
- Allow send multiple team invitations at once [Taiga #2798](https://tree.taiga.io/project/penpot/us/2798)
|
||||
- Persist color palette and color picker across refresh [Taiga #1660](https://tree.taiga.io/project/penpot/issue/1660)
|
||||
- Ability to add multiple strokes to a shape [Taiga #2778](https://tree.taiga.io/project/penpot/us/2778)
|
||||
- Scroll to selected size in font size selector [Taiga #2825](https://tree.taiga.io/project/penpot/us/2825)
|
||||
- Add new invitations section [Taiga #2797](https://tree.taiga.io/project/penpot/us/2797)
|
||||
- Ability to add multiple fills to a shape [Taiga #1394](https://tree.taiga.io/project/penpot/us/1394)
|
||||
- Team members redesign [Taiga #2283](https://tree.taiga.io/project/penpot/us/2283)
|
||||
- New focus mode in workspace [Taiga #2748](https://tree.taiga.io/project/penpot/us/2748)
|
||||
- Changed text shapes to be displayed as natives SVG text elements [Taiga #2759](https://tree.taiga.io/project/penpot/us/2759)
|
||||
- Texts now can have strokes, multiple fills and can be used as masks
|
||||
- Add the ability to specify the attribute for retrieve the email on OIDC integration [#1460](https://github.com/penpot/penpot/issues/1460)
|
||||
- Allow registration with invitation token when registration is disabled
|
||||
- Add the ability to disable standard, password login [Taiga #2999](https://tree.taiga.io/project/penpot/us/2999)
|
||||
- Don't stop SVG import when an image cannot be imported [#1531](https://github.com/penpot/penpot/issues/1531)
|
||||
- Show Penpot color in Safari tab bar [#1803](https://github.com/penpot/penpot/issues/1803)
|
||||
- Added option to disable snap to pixel and improved behaviour for sub-pixel drawing [#2552](https://tree.taiga.io/project/penpot/us/2552)
|
||||
- Delete guides while supr on hover [#2823](https://tree.taiga.io/project/penpot/us/2823)
|
||||
- Opt-in subscription on on-premise instances [#2772](https://tree.taiga.io/project/penpot/us/2772)
|
||||
- Optimizations in frame thumbnails [#3147](https://tree.taiga.io/project/penpot/us/3147)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix typo in viewer comment section [Taiga #3401](https://tree.taiga.io/project/penpot/issue/3401)
|
||||
- Do not show team-up modal for users already on a team [Taiga #3311](https://tree.taiga.io/project/penpot/issue/3311)
|
||||
- Constraints are not well assigned when default and multiselection [Taiga #3069](https://tree.taiga.io/project/penpot/issue/3069)
|
||||
- Duplicate artboards create new flows if needed [Taiga #2221](https://tree.taiga.io/project/penpot/issue/2221)
|
||||
- Round the size values on handoff to two decimals [Taiga #3227](https://tree.taiga.io/project/penpot/issue/3227)
|
||||
- Fix paste shapes while editing text [Taiga #2396](https://tree.taiga.io/project/penpot/issue/2396)
|
||||
- Fix blend modes ignored in component updates [Taiga #2626](https://tree.taiga.io/project/penpot/issue/2626)
|
||||
- Fix internal error when hoverin over shape [Taiga #3237](https://tree.taiga.io/project/penpot/issue/3237)
|
||||
- Fix mouse leave in handoff close overlay animation breaks [Taiga #3173](https://tree.taiga.io/project/penpot/issue/3173)
|
||||
- Fix different behaviour during image drag [Taiga #2279](https://tree.taiga.io/project/penpot/issue/2279)
|
||||
- Fix hidden file name on import [Taiga #3172](https://tree.taiga.io/project/penpot/issue/3172)
|
||||
- Fix unneccessary scrollbars at the color list [Taiga #3211](https://tree.taiga.io/project/penpot/issue/3211)
|
||||
- "Show in exports" is showing in multiselections [Taiga #3194](https://tree.taiga.io/project/penpot/issue/3194)
|
||||
- Edit file name navigates to the file workspace [Taiga #3183](https://tree.taiga.io/project/penpot/issue/3183)
|
||||
- Fix scroll into view behind fixed element [Taiga #3170](https://tree.taiga.io/project/penpot/issue/3170)
|
||||
- Fix sidebar icon in viewer mode [Taiga #3184](https://tree.taiga.io/project/penpot/issue/3184)
|
||||
- Fix send to back several shapes at a time [Taiga #3077](https://tree.taiga.io/project/penpot/issue/3077)
|
||||
- Fix duplicate multi selected elements [Taiga #3155](https://tree.taiga.io/project/penpot/issue/3155)
|
||||
- Fix add fills to artboard modify children [Taiga #3151](https://tree.taiga.io/project/penpot/issue/3151)
|
||||
- Avoid numeric inputs to allow big numbers [Taiga #2858](https://tree.taiga.io/project/penpot/issue/2858)
|
||||
- Fix component contex menu size [Taiga #2480](https://tree.taiga.io/project/penpot/issue/2480)
|
||||
- Add shadow to artboard make it lose the fill [Taiga #3139](https://tree.taiga.io/project/penpot/issue/3139)
|
||||
- Avoid numeric inputs to change its value without focusing them [Taiga #3140](https://tree.taiga.io/project/penpot/issue/3140)
|
||||
- Fix comments modal when changing pages [Taiga #2597](https://tree.taiga.io/project/penpot/issue/2508)
|
||||
- Copy paste inside a text layer leaves pasted text transparent [Taiga #3096](https://tree.taiga.io/project/penpot/issue/3096)
|
||||
- On dashboard enter on empty search refresh the page [Taiga #2597](https://tree.taiga.io/project/penpot/issue/2597)
|
||||
- Pencil cursor changes when activated [Taiga #2276](https://tree.taiga.io/project/penpot/issue/2276)
|
||||
- Fix icon placement in Mixed message [Taiga #3037](https://tree.taiga.io/project/penpot/issue/3037)
|
||||
- Fix scroll in comment section [Taiga #3068](https://tree.taiga.io/project/penpot/issue/3068)
|
||||
- Remove a decimal sets value to 0 [Taiga #3059](https://tree.taiga.io/project/penpot/issue/3054)
|
||||
- Go to style library file to edit in a new tab [Taiga #2639](https://tree.taiga.io/project/penpot/issue/2639)
|
||||
- Inner shadow with border not working properly [Taiga #2883](https://tree.taiga.io/project/penpot/issue/2883)
|
||||
- Fix ellipsis in long page names [Taiga #2962](https://tree.taiga.io/project/penpot/issue/2962)
|
||||
- Fix color palette animation [Taiga #2852](https://tree.taiga.io/project/penpot/issue/2852)
|
||||
- Fix display code icon on preview hover [Taiga #2838](https://tree.taiga.io/project/penpot/us/2838)
|
||||
- Fix crash on iOS when displaying viewer [#1522](https://github.com/penpot/penpot/issues/1522)
|
||||
- Fix problem when importing a SVG with text [#1532](https://github.com/penpot/penpot/issues/1532)
|
||||
- Fix problem when adding shadows to imported text [#Taiga 3057](https://tree.taiga.io/project/penpot/issue/3057)
|
||||
- Fix problem when importing SVG's with uses with overriding properties [#Taiga 2884](https://tree.taiga.io/project/penpot/issue/2884)
|
||||
- Fix inconsistency with radius in SVG an CSS [#1587](https://github.com/penpot/penpot/issues/1587)
|
||||
- Fix clickable area in layers [#1680](https://github.com/penpot/penpot/issues/1680)
|
||||
- Fix problems with trackpad zoom and scroll in MacOS [#1161](https://github.com/penpot/penpot/issues/1161)
|
||||
- Fix problem with copy/paste in Safari [#1209](https://github.com/penpot/penpot/issues/1209)
|
||||
- Fix paste ordering for frames not being respected [Taiga #3097](https://tree.taiga.io/project/penpot/issue/3097)
|
||||
- Improved command support for MacOS [Taiga #2789](https://tree.taiga.io/project/penpot/issue/2789)
|
||||
- Fix shift+2 shortcut in MacOS with non-english keyboards [Taiga #3038](https://tree.taiga.io/project/penpot/issue/3038)
|
||||
- Some fixes to SVG imports [Taiga #3122](https://tree.taiga.io/project/penpot/issue/3122) [#1720](https://github.com/penpot/penpot/issues/1720) [Taiga #2884](https://tree.taiga.io/project/penpot/issue/2884)
|
||||
- Fix drag guides to delete target area [#1679](https://github.com/penpot/penpot/issues/1679)
|
||||
- Fix undo when rotating groups [Taiga #3136](https://tree.taiga.io/project/penpot/issue/3136)
|
||||
- Fix component name in sidebar widget [Taiga #3144](https://tree.taiga.io/project/penpot/issue/3144)
|
||||
- Fix resize rotated shape with top&down constraints [Taiga #3167](https://tree.taiga.io/project/penpot/issue/3167)
|
||||
- Fix multi user not working [Taiga #3195](https://tree.taiga.io/project/penpot/issue/3195)
|
||||
- Fix guides are not duplicated with the artboard [Taiga #3072](https://tree.taiga.io/project/penpot/issue/3072)
|
||||
- Fix problem when changing group size with decimal values [Taiga #3203](https://tree.taiga.io/project/penpot/issue/3203)
|
||||
- Fix error when drawing curves with only one point [Taiga #3282](https://tree.taiga.io/project/penpot/issue/3282)
|
||||
- Fix issue with paste ordering sometimes not being respected [Taiga #3268](https://tree.taiga.io/project/penpot/issue/3268)
|
||||
- Fix problem when export/importing guides attached to frame [#1838](https://github.com/penpot/penpot/issues/1838)
|
||||
- Fix problem when resizing a group with texts with auto-width/height [#3171](https://tree.taiga.io/project/penpot/issue/3171)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
## 1.12.4-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix crash on iOS when displaying viewer [#1522](https://github.com/penpot/penpot/issues/1522)
|
||||
- Fix problems with trackpad zoom and scroll in MacOS [#1161](https://github.com/penpot/penpot/issues/1161)
|
||||
- Fix problem with copy/paste in Safari [#1209](https://github.com/penpot/penpot/issues/1209)
|
||||
- Improved command support for MacOS [Taiga #2789](https://tree.taiga.io/project/penpot/issue/2789)
|
||||
- Fix shift+2 shortcut in MacOS with non-english keyboards [Taiga #3038](https://tree.taiga.io/project/penpot/issue/3038)
|
||||
|
||||
## 1.12.3-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix issue with shift+select to deselect shapes [Taiga #3154](https://tree.taiga.io/project/penpot/issue/3154)
|
||||
- Fix issue with drag-select shapes [Taiga #3165](https://tree.taiga.io/project/penpot/issue/3165)
|
||||
- Fix issue on password persistence after registration process on private instances
|
||||
|
||||
## 1.12.2-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix issue with guides over shape handlers [Taiga #3032](https://tree.taiga.io/project/penpot/issue/3032)
|
||||
- Fix problem with shift+ctrl+click to select [#1671](https://github.com/penpot/penpot/issues/1671)
|
||||
- Fix ellipsis in long page names [Taiga #2962](https://tree.taiga.io/project/penpot/issue/2962)
|
||||
|
||||
## 1.12.1-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix length of names in sidebar [Taiga #2962](https://tree.taiga.io/project/penpot/issue/2962)
|
||||
- Fix issues on loki integration
|
||||
|
||||
|
||||
## 1.12.0-beta
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Open feedback in a new window [Taiga #2901](https://tree.taiga.io/project/penpot/us/2901)
|
||||
- Improve usage of file menu [Taiga #2853](https://tree.taiga.io/project/penpot/us/2853)
|
||||
- Rotation to snap to 15º intervals with shift [Taiga #2437](https://tree.taiga.io/project/penpot/issue/2437)
|
||||
- Support border radius and stroke properties for images [Taiga #497](https://tree.taiga.io/project/penpot/us/497)
|
||||
- Disallow using same password as user email [Taiga #2454](https://tree.taiga.io/project/penpot/us/2454)
|
||||
- Add configurable nudge amount [Taiga #910](https://tree.taiga.io/project/penpot/us/910)
|
||||
- Add stroke properties for image shapes [Taiga #497](https://tree.taiga.io/project/penpot/us/497)
|
||||
- On user settings, hide the theme selector as long as we only have one theme [Taiga #2610](https://tree.taiga.io/project/penpot/us/2610)
|
||||
- Automatically open comments from dashboard notifications [Taiga #2605](https://tree.taiga.io/project/penpot/us/2605)
|
||||
- Enhance the behaviour of the artboards list on view mode [Taiga #2634](https://tree.taiga.io/project/penpot/us/2634)
|
||||
- Add recent used fonts in font selection widget [Taiga #1381](https://tree.taiga.io/project/penpot/us/1381)
|
||||
- Allow to align items relative to groups [Taiga #2533](https://tree.taiga.io/project/penpot/us/2533)
|
||||
- Scroll bars [Taiga #2550](https://tree.taiga.io/project/penpot/task/2550)
|
||||
- Add select layer option to context menu [Taiga #2474](https://tree.taiga.io/project/penpot/us/2474)
|
||||
- Guides [Taiga #290](https://tree.taiga.io/project/penpot/us/290)
|
||||
- Improve file menu by adding semantically groups [Github #1203](https://github.com/penpot/penpot/issues/1203)
|
||||
- Add update components in bulk option in context menu [Taiga #1975](https://tree.taiga.io/project/penpot/us/1975)
|
||||
- Create first E2E tests [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608), [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608)
|
||||
- Redesign of workspace toolbars [Taiga #2319](https://tree.taiga.io/project/penpot/us/2319)
|
||||
- Graphic Tablet usability improvements [Taiga #1913](https://tree.taiga.io/project/penpot/us/1913)
|
||||
- Improved mouse collision detection for groups and text shapes [Taiga #2452](https://tree.taiga.io/project/penpot/us/2452), [Taiga #2453](https://tree.taiga.io/project/penpot/us/2453)
|
||||
- Add support for alternative S3 storage providers and all aws regions [#1267](https://github.com/penpot/penpot/issues/1267)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fixed ungroup typography when editing it [Taiga #2391](https://tree.taiga.io/project/penpot/issue/2391)
|
||||
- Fixed error when trying to post an empty comment [Taiga #2603](https://tree.taiga.io/project/penpot/issue/2603)
|
||||
- Fixed missing translation strings [Taiga #2786](https://tree.taiga.io/project/penpot/issue/2786)
|
||||
- Fixed color palette outside viewport [Taiga #2715](https://tree.taiga.io/project/penpot/issue/2715)
|
||||
- Fixed missing translate string [Taiga #2780](https://tree.taiga.io/project/penpot/issue/2780)
|
||||
- Fixed handoff shadow type text [Taiga #2717](https://tree.taiga.io/project/penpot/issue/2717)
|
||||
- Fixed components get "dirty" marker when moved [Taiga #2764](https://tree.taiga.io/project/penpot/issue/2764)
|
||||
- Fixed cannot align objects in a group that is not part of a frame [Taiga #2762](https://tree.taiga.io/project/penpot/issue/2762)
|
||||
- Fix problem with double click on exit path editing [Taiga #2906](https://tree.taiga.io/project/penpot/issue/2906)
|
||||
- Fixed alignment of layers with children [Taiga #2862](https://tree.taiga.io/project/penpot/issue/2862)
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- Cleanup unused static images (by @rhcarvalho) [#1561](https://github.com/penpot/penpot/pull/1561)
|
||||
- Compress static images to save space (by @rhcarvalho) [#1562](https://github.com/penpot/penpot/pull/1562)
|
||||
|
||||
## 1.11.2-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix issue on handling empty content on boolean shapes
|
||||
- Fix race condition issue on component renaming
|
||||
- Handle EOF errors on writting streamed response
|
||||
- Handle EOF errors on websocket send/ping methods
|
||||
- Disable parallel upload of file media on import (causes too much
|
||||
contention on the rlimit subsistem that does not works as expected
|
||||
on high load).
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add health check endpoint on API
|
||||
- Increase default max connection pool size to 60
|
||||
- Reduce resource usage of the error reporter.
|
||||
|
||||
## 1.11.1-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix issue related to default http host config value.
|
||||
- Fix issue on rendering frames on firefox.
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update nodejs version to 16.13.1 on docker images.
|
||||
|
||||
## 1.11.0-beta
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add an option to hide artboards names on the viewport [Taiga #2034](https://tree.taiga.io/project/penpot/issue/2034)
|
||||
- Limit pasted object position to container boundaries [Taiga #2449](https://tree.taiga.io/project/penpot/us/2449)
|
||||
- Add new options for zoom widget in workspace and viewer mode [Taiga #896](https://tree.taiga.io/project/penpot/us/896)
|
||||
- Allow decimals on stroke width and positions [Taiga #2035](https://tree.taiga.io/project/penpot/issue/2035)
|
||||
- Ability to ignore background when exporting an artboard [Taiga #1395](https://tree.taiga.io/project/penpot/us/1395)
|
||||
- Show color hex or name on hover [Taiga #2413](https://tree.taiga.io/project/penpot/us/2413)
|
||||
- Add shortcut to create artboard from selected objects [Taiga #2412](https://tree.taiga.io/project/penpot/us/2412)
|
||||
- Add shortcut for opacity [Taiga #2442](https://tree.taiga.io/project/penpot/us/2442)
|
||||
- Setting fill automatically for new texts [Taiga #2441](https://tree.taiga.io/project/penpot/us/2441)
|
||||
- Add shortcut to move action [Github #1213](https://github.com/penpot/penpot/issues/1213)
|
||||
- Add alt as mod key to add stroke color from library menu [Taiga #2207](https://tree.taiga.io/project/penpot/us/2207)
|
||||
- Add detach in bulk option to context menu [Taiga #2210](https://tree.taiga.io/project/penpot/us/2210)
|
||||
- Add penpot look and feel to multiuser cursors [Taiga #1387](https://tree.taiga.io/project/penpot/us/1387)
|
||||
- Add actions to go to main component context menu option [Taiga #2053](https://tree.taiga.io/project/penpot/us/2053)
|
||||
- Add contrast between component select color and shape select color [Taiga #2121](https://tree.taiga.io/project/penpot/issue/2121)
|
||||
- Add animations in interactions [Taiga #2244](https://tree.taiga.io/project/penpot/us/2244)
|
||||
- Add performance improvements on .penpot file import process [Taiga #2497](https://tree.taiga.io/project/penpot/us/2497)
|
||||
- On team settings set color of members count to black [Taiga #2607](https://tree.taiga.io/project/penpot/us/2607)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix remove gradient if any when applying color from library [Taiga #2299](https://tree.taiga.io/project/penpot/issue/2299)
|
||||
- Fix Enter as key action to exit edit path [Taiga #2444](https://tree.taiga.io/project/penpot/issue/2444)
|
||||
- Fix add fill color from palette to groups and components [Taiga #2313](https://tree.taiga.io/project/penpot/issue/2313)
|
||||
- Fix default project name in all languages [Taiga #2280](https://tree.taiga.io/project/penpot/issue/2280)
|
||||
- Fix line-height and letter-spacing inputs to allow negative values [Taiga #2381](https://tree.taiga.io/project/penpot/issue/2381)
|
||||
- Fix typo in Handoff tooltip [Taiga #2428](https://tree.taiga.io/project/penpot/issue/2428)
|
||||
- Fix crash when pressing Shift+1 on empty file [#1435](https://github.com/penpot/penpot/issues/1435)
|
||||
- Fix masked group resize strange behavior [Taiga #2317](https://tree.taiga.io/project/penpot/issue/2317)
|
||||
- Fix problems when exporting all artboards [Taiga #2234](https://tree.taiga.io/project/penpot/issue/2234)
|
||||
- Fix problems with team management [#1353](https://github.com/penpot/penpot/issues/1353)
|
||||
- Fix problem when importing in shared libraries [#1362](https://github.com/penpot/penpot/issues/1362)
|
||||
- Fix problem with join nodes [#1422](https://github.com/penpot/penpot/issues/1422)
|
||||
- After team onboarding importing a file will import into the team drafts [Taiga #2408](https://tree.taiga.io/project/penpot/issue/2408)
|
||||
- Fix problem exporting shapes from handoff mode [Taiga #2386](https://tree.taiga.io/project/penpot/issue/2386)
|
||||
- Fix lock/hide elements in context menu when multiples shapes selected [Taiga #2340](https://tree.taiga.io/project/penpot/issue/2340)
|
||||
- Fix problem with booleans [Taiga #2356](https://tree.taiga.io/project/penpot/issue/2356)
|
||||
- Fix line-height/letter-spacing inputs behaviour [Taiga #2331](https://tree.taiga.io/project/penpot/issue/2331)
|
||||
- Fix dotted style in strokes [Taiga #2312](https://tree.taiga.io/project/penpot/issue/2312)
|
||||
- Fix problem when resizing texts inside groups [Taiga #2310](https://tree.taiga.io/project/penpot/issue/2310)
|
||||
- Fix problem with multiple exports [Taiga #2468](https://tree.taiga.io/project/penpot/issue/2468)
|
||||
- Allow import to continue from recoverable failures [#1412](https://github.com/penpot/penpot/issues/1412)
|
||||
- Improved behaviour on text options when not text is selected [Taiga #2390](https://tree.taiga.io/project/penpot/issue/2390)
|
||||
- Fix decimal numbers in export viewbox [Taiga #2290](https://tree.taiga.io/project/penpot/issue/2290)
|
||||
- Right click over artboard name to open its menu [Taiga #1679](https://tree.taiga.io/project/penpot/issue/1679)
|
||||
- Make the default session cookue use SameSite=Lax instead of Strict (causes some issues in latest versions of Chrome)
|
||||
- Fix "open in new tab" on dashboard [Taiga #2235](https://tree.taiga.io/project/penpot/issue/2355)
|
||||
- Changing pages while comments activated will not close the panel [#1350](https://github.com/penpot/penpot/issues/1350)
|
||||
- Fix navigate comments in right sidebar [Taiga #2163](https://tree.taiga.io/project/penpot/issue/2163)
|
||||
- Fix keep name of component equal to the shape name [Taiga #2341](https://tree.taiga.io/project/penpot/issue/2341)
|
||||
- Fix lossing changes when changing selection and an input was already changed [Taiga #2329](https://tree.taiga.io/project/penpot/issue/2329), [Taiga #2330](https://tree.taiga.io/project/penpot/issue/2330)
|
||||
- Fix blur input field when click on viewport [Taiga #2164](https://tree.taiga.io/project/penpot/issue/2164)
|
||||
- Fix default page id in workspace [Taiga #2205](https://tree.taiga.io/project/penpot/issue/2205)
|
||||
- Fix problem when importing a file with grids [Taiga #2314](https://tree.taiga.io/project/penpot/issue/2314)
|
||||
- Fix problem with imported svgs with filters [Taiga #2478](https://tree.taiga.io/project/penpot/issue/2478)
|
||||
- Fix issues when updating selrect in paths [Taiga #2366](https://tree.taiga.io/project/penpot/issue/2366)
|
||||
- Fix scroll jumps in handoff mode [Taiga #2383](https://tree.taiga.io/project/penpot/issue/2383)
|
||||
- Fix handoff text with opacity [Taiga #2384](https://tree.taiga.io/project/penpot/issue/2384)
|
||||
- Restored rules color [Taiga #2460](https://tree.taiga.io/project/penpot/issue/2460)
|
||||
- Fix thumbnail not taking frame blending mode [Taiga #2301](https://tree.taiga.io/project/penpot/issue/2301)
|
||||
- Fix import/export with SVG edge cases [Taiga #2389](https://tree.taiga.io/project/penpot/issue/2389)
|
||||
- Avoid modifying component when moving into a group [Taiga #2534](https://tree.taiga.io/project/penpot/issue/2534)
|
||||
- Show correctly group types label in handoff [Taiga #2482](https://tree.taiga.io/project/penpot/issue/2482)
|
||||
- Display view mode buttons always centered in viewer [#Taiga 2466](https://tree.taiga.io/project/penpot/issue/2466)
|
||||
- Fix default profile image generation issue [Taiga #2601](https://tree.taiga.io/project/penpot/issue/2601)
|
||||
- Fix edit blur attributes for multiselection [Taiga #2625](https://tree.taiga.io/project/penpot/issue/2625)
|
||||
- Fix auto hide header in viewer full screen [Taiga #2632](https://tree.taiga.io/project/penpot/issue/2632)
|
||||
- Fix zoom in/out after fit or fill [Taiga #2630](https://tree.taiga.io/project/penpot/issue/2630)
|
||||
- Normalize zoom levels in workspace and viewer [Taiga #2631](https://tree.taiga.io/project/penpot/issue/2631)
|
||||
- Avoid empty names in projects, files and pages [Taiga #2594](https://tree.taiga.io/project/penpot/issue/2594)
|
||||
- Fix "move to" menu when duplicated team or project names [Taiga #2655](https://tree.taiga.io/project/penpot/issue/2655)
|
||||
- Fix ungroup a component leaves an asterisk in layers [Taiga #2694](https://tree.taiga.io/project/penpot/issue/2694)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update devenv docker image dependencies
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- Spelling fixes (by @jsoref) [#1340](https://github.com/penpot/penpot/pull/1340)
|
||||
- Explain folders in components (by @candideu) [Penpot-docs #42](https://github.com/penpot/penpot-docs/pull/42)
|
||||
- Readability improvements of user guide (by @PaulSchulz) [Penpot-docs #50](https://github.com/penpot/penpot-docs/pull/50)
|
||||
|
||||
## 1.10.4-beta
|
||||
|
||||
### :sparkles: Enhacements
|
||||
|
||||
- Allow parametrice file snapshoting interval
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix issue on :mov-object change impl
|
||||
- Minor fix on how file changes log is persisted
|
||||
- Fix many issues on error reporting
|
||||
|
||||
## 1.10.3-beta
|
||||
|
||||
### :sparkles: Enhacements
|
||||
|
||||
- Make all logging asynchronous, this avoid some overhead on jetty threads at cost of logging latency.
|
||||
- Increase default session time to 15 days.
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix unexpected exception on saving pages with default grids [#2409](https://tree.taiga.io/project/penpot/issue/2409)
|
||||
- Fix react warnings on setting size 1 on row and column grids.
|
||||
- Fix minor issues on ZMQ logging listener (used in error reporting service)
|
||||
- Remove "ALPHA" from the code.
|
||||
- Fix value and nil handling on numeric-input component. This fixes many issues related to typography, components, etc. renaming.
|
||||
- Fix NPE on email complains processing.
|
||||
- Fix white page after leaving a team.
|
||||
- Fix missing leave team button outside members page.
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update log4j2 dependency.
|
||||
|
||||
## 1.10.2-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix corner case issues with media file uploads.
|
||||
- Fix issue with default page grids validation.
|
||||
- Fix issue related to some raceconditions on workspace navigation events.
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update log4j2 dependency.
|
||||
|
||||
## 1.10.1-beta
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problems with team management [#1353](https://github.com/penpot/penpot/issues/1353)
|
||||
|
||||
## 1.10.0-beta
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
- The initial project / data mechanism (not documented) has been
|
||||
disabled. Is the mechanism used for creating initial project on user
|
||||
signup. With the new onboarding approach, this subsystem is no
|
||||
longer needed and is disabled.
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Allow ungroup groups in bulk [Taiga #2211](https://tree.taiga.io/project/penpot/us/2211)
|
||||
- Enhance corner radius behavior [Taiga #2190](https://tree.taiga.io/project/penpot/issue/2190)
|
||||
- Allow preserve scroll position in interactions [Taiga #2250](https://tree.taiga.io/project/penpot/us/2250)
|
||||
- Add new onboarding modals.
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with exporting before the document is saved [Taiga #2189](https://tree.taiga.io/project/penpot/issue/2189)
|
||||
- Fix undo stacking when changing color from color-picker [Taiga #2191](https://tree.taiga.io/project/penpot/issue/2191)
|
||||
- Fix pages dropdown in viewer [Taiga #2087](https://tree.taiga.io/project/penpot/issue/2087)
|
||||
- Fix problem when exporting texts with gradients or opacity [Taiga #2200](https://tree.taiga.io/project/penpot/issue/2200)
|
||||
- Fix problem with view mode comments [Taiga #2226](https://tree.taiga.io/project/penpot/issue/2226)
|
||||
- Disallow to create a component when already has one [Taiga #2237](https://tree.taiga.io/project/penpot/issue/2237)
|
||||
- Add ellipsis in long labels for input fields [Taiga #2224](https://tree.taiga.io/project/penpot/issue/2224)
|
||||
- Fix problem with text rendering on export [Taiga #2223](https://tree.taiga.io/project/penpot/issue/2223)
|
||||
- Fix problem when flattening booleans losing styles [Taiga #2217](https://tree.taiga.io/project/penpot/issue/2217)
|
||||
- Add shortcuts to boolean icons popups [Taiga #2220](https://tree.taiga.io/project/penpot/issue/2220)
|
||||
- Fix a worker error when transforming a rectangle into path
|
||||
- Fix max/min values for opacity fields [Taiga #2183](https://tree.taiga.io/project/penpot/issue/2183)
|
||||
- Fix viewer comment position when zoom applied [Taiga #2240](https://tree.taiga.io/project/penpot/issue/2240)
|
||||
- Remove change style on hover for options [Taiga #2172](https://tree.taiga.io/project/penpot/issue/2172)
|
||||
- Fix problem in viewer with dropdowns when comments active [#1303](https://github.com/penpot/penpot/issues/1303)
|
||||
- Add placeholder to create shareable link
|
||||
- Fix project files count not refreshing correctly after import [Taiga #2216](https://tree.taiga.io/project/penpot/issue/2216)
|
||||
- Remove button after import process finish [Taiga #2215](https://tree.taiga.io/project/penpot/issue/2215)
|
||||
- Fix problem with styles in the viewer [Taiga #2467](https://tree.taiga.io/project/penpot/issue/2467)
|
||||
- Fix default state in viewer [Taiga #2465](https://tree.taiga.io/project/penpot/issue/2465)
|
||||
- Fix division by zero in bool operation [Taiga #2349](https://tree.taiga.io/project/penpot/issue/2349)
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- To the translation community for the hard work on making penpot
|
||||
available on so many languages.
|
||||
- Guide to integrate with Azure Directory (by @skrzyneckik) [Penpot-docs #33](https://github.com/penpot/penpot-docs/pull/33)
|
||||
- Improve libraries section readability (by @PaulSchulz) [Penpot-docs #39](https://github.com/penpot/penpot-docs/pull/39)
|
||||
|
||||
## 1.9.0-alpha
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
- Some stroke-caps can change behaviour.
|
||||
- Text display bug fix could potentially make some texts jump a line.
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add boolean shapes: intersections, unions, difference and exclusions[Taiga #748](https://tree.taiga.io/project/penpot/us/748)
|
||||
- Add advanced prototyping [Taiga #244](https://tree.taiga.io/project/penpot/us/244)
|
||||
- Add multiple flows [Taiga #2091](https://tree.taiga.io/project/penpot/us/2091)
|
||||
- Change order of the teams menu so it's in the joined time order.
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Enhance duplicating prototype connections behaviour [Taiga #2093](https://tree.taiga.io/project/penpot/us/2093)
|
||||
- Ignore constraints in horizontal or vertical flip [Taiga #2038](https://tree.taiga.io/project/penpot/issue/2038)
|
||||
- Fix color and typographies refs lost when duplicated file [Taiga #2165](https://tree.taiga.io/project/penpot/issue/2165)
|
||||
- Fix problem with overflow dropdown on stroke-cap [#1216](https://github.com/penpot/penpot/issues/1216)
|
||||
- Fix menu context for single element nested in components [#1186](https://github.com/penpot/penpot/issues/1186)
|
||||
- Fix error screen when operations over comments fail [#1219](https://github.com/penpot/penpot/issues/1219)
|
||||
- Fix undo problem when changing typography/color from library [#1230](https://github.com/penpot/penpot/issues/1230)
|
||||
- Fix problem with text margin while rendering [#1231](https://github.com/penpot/penpot/issues/1231)
|
||||
- Fix problem with masked texts on exporting [Taiga #2116](https://tree.taiga.io/project/penpot/issue/2116)
|
||||
- Fix text editor enter behaviour with centered texts [Taiga #2126](https://tree.taiga.io/project/penpot/issue/2126)
|
||||
- Fix residual stroke on imported svg [Taiga #2125](https://tree.taiga.io/project/penpot/issue/2125)
|
||||
- Add links for terms of service and privacy policy in register checkbox [Taiga #2020](https://tree.taiga.io/project/penpot/issue/2020)
|
||||
- Allow three character hex and web colors in color picker hex input [#1184](https://github.com/penpot/penpot/issues/1184)
|
||||
- Allow lowercase search for fonts [#1180](https://github.com/penpot/penpot/issues/1180)
|
||||
- Fix group renaming problem [Taiga #1969](https://tree.taiga.io/project/penpot/issue/1969)
|
||||
- Fix export group with shadows on children [Taiga #2036](https://tree.taiga.io/project/penpot/issue/2036)
|
||||
- Fix zoom context menu in viewer [Taiga #2041](https://tree.taiga.io/project/penpot/issue/2041)
|
||||
- Fix stroke caps adjustments in relation with stroke size [Taiga #2123](https://tree.taiga.io/project/penpot/issue/2123)
|
||||
- Fix problem duplicating paths [Taiga #2147](https://tree.taiga.io/project/penpot/issue/2147)
|
||||
- Fix problem inheriting attributes from SVG root when importing [Taiga #2124](https://tree.taiga.io/project/penpot/issue/2124)
|
||||
- Fix problem with lines and inside/outside stroke [Taiga #2146](https://tree.taiga.io/project/penpot/issue/2146)
|
||||
- Add stroke width in selection calculation [Taiga #2146](https://tree.taiga.io/project/penpot/issue/2146)
|
||||
- Fix shift+wheel to horizontal scrolling in MacOS [#1217](https://github.com/penpot/penpot/issues/1217)
|
||||
- Fix path stroke is not working properly with high thickness [Taiga #2154](https://tree.taiga.io/project/penpot/issue/2154)
|
||||
- Fix bug with transformation operations [Taiga #2155](https://tree.taiga.io/project/penpot/issue/2155)
|
||||
- Fix bug in firefox when a text box is inside a mask [Taiga #2152](https://tree.taiga.io/project/penpot/issue/2152)
|
||||
- Fix problem with stroke inside/outside [Taiga #2186](https://tree.taiga.io/project/penpot/issue/2186)
|
||||
- Fix masks export area [Taiga #2189](https://tree.taiga.io/project/penpot/issue/2189)
|
||||
- Fix paste in place in artboards [Taiga #2188](https://tree.taiga.io/project/penpot/issue/2188)
|
||||
- Fix font size input stuck on selection change [Taiga #2184](https://tree.taiga.io/project/penpot/issue/2184)
|
||||
- Fix stroke cut on shapes export [Taiga #2171](https://tree.taiga.io/project/penpot/issue/2171)
|
||||
- Fix no color when boolean with an SVG [Taiga #2193](https://tree.taiga.io/project/penpot/issue/2193)
|
||||
- Fix unlink color styles at strokes [Taiga #2206](https://tree.taiga.io/project/penpot/issue/2206)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- To the translation community for the hard work on making penpot
|
||||
available on so many languages.
|
||||
|
||||
## 1.8.4-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem importing components [Taiga #2151](https://tree.taiga.io/project/penpot/issue/2151)
|
||||
|
||||
## 1.8.3-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Adds progress report to importing process.
|
||||
|
||||
## 1.8.2-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with masking images in viewer [#1238](https://github.com/penpot/penpot/issues/1238)
|
||||
|
||||
## 1.8.1-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix project renaming issue (and some other related to the same underlying bug)
|
||||
- Fix internal exception on audit log persistence layer.
|
||||
- Set proper environment variable on docker images for chrome executable.
|
||||
- Fix internal metrics on websocket connections.
|
||||
|
||||
## 1.8.0-alpha
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
- This release includes a new approach for handling share links, and
|
||||
this feature is incompatible with the previous one. This means that
|
||||
all the public share links generated previously will stop working.
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add tooltips to color picker tabs [Taiga #1814](https://tree.taiga.io/project/penpot/us/1814)
|
||||
- Add styling to the end point of any open paths [Taiga #1107](https://tree.taiga.io/project/penpot/us/1107)
|
||||
- Allow to zoom with ctrl + middle button [Taiga #1428](https://tree.taiga.io/project/penpot/us/1428)
|
||||
- Auto placement of duplicated objects [Taiga #1386](https://tree.taiga.io/project/penpot/us/1386)
|
||||
- Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883)
|
||||
- Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895)
|
||||
- Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374)
|
||||
- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047)
|
||||
- New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799)
|
||||
- Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921)
|
||||
- Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823)
|
||||
- Use space + mouse drag to pan, instead of only space [Taiga #1800](https://tree.taiga.io/project/penpot/us/1800)
|
||||
- Allow navigate through pages on the viewer [Taiga #1550](https://tree.taiga.io/project/penpot/us/1550)
|
||||
- Allow create share links with specific pages [Taiga #1844](https://tree.taiga.io/project/penpot/us/1844)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Prevent adding numeric suffix to layer names when not needed [Taiga #1929](https://tree.taiga.io/project/penpot/us/1929)
|
||||
- Prevent deleting or moving the drafts project [Taiga #1935](https://tree.taiga.io/project/penpot/issue/1935)
|
||||
- Fix problem with zoom and selection [Taiga #1919](https://tree.taiga.io/project/penpot/issue/1919)
|
||||
- Fix problem with borders on shape export [#1092](https://github.com/penpot/penpot/issues/1092)
|
||||
- Fix thumbnail cropping issue [Taiga #1964](https://tree.taiga.io/project/penpot/issue/1964)
|
||||
- Fix repeated fetch on file selection [Taiga #1933](https://tree.taiga.io/project/penpot/issue/1933)
|
||||
- Fix rename typography on text options [Taiga #1963](https://tree.taiga.io/project/penpot/issue/1963)
|
||||
- Fix problems with order in groups [Taiga #1960](https://tree.taiga.io/project/penpot/issue/1960)
|
||||
- Fix SVG components preview [#1134](https://github.com/penpot/penpot/issues/1134)
|
||||
- Fix group renaming problem [Taiga #1969](https://tree.taiga.io/project/penpot/issue/1969)
|
||||
- Fix problem with import broken images links [#1197](https://github.com/penpot/penpot/issues/1197)
|
||||
- Fix problem while moving imported SVG's [#1199](https://github.com/penpot/penpot/issues/1199)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- eduayme [#1129](https://github.com/penpot/penpot/pull/1129)
|
||||
|
||||
## 1.7.4-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix demo user creation (self-hosted only)
|
||||
- Add better ldap response validation and reporting (self-hosted only)
|
||||
|
||||
## 1.7.3-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix font uploading issue on Windows.
|
||||
|
||||
## 1.7.2-alpha
|
||||
|
||||
@@ -21,8 +575,8 @@
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Add scroll bar to Teams menu [Taiga #1894](https://tree.taiga.io/project/penpot/issue/1894).
|
||||
- Fix repeated names when duplicating artboards or groups [Taiga #1892](https://tree.taiga.io/project/penpot/issue/1892).
|
||||
- Add scroll bar to Teams menu [Taiga #1894](https://tree.taiga.io/project/penpot/issue/1894)
|
||||
- Fix repeated names when duplicating artboards or groups [Taiga #1892](https://tree.taiga.io/project/penpot/issue/1892)
|
||||
- Fix properly messages lifecycle on navigate.
|
||||
- Fix handling repeated names on duplicate object trees.
|
||||
- Fix group naming on group creation.
|
||||
@@ -32,12 +586,10 @@
|
||||
|
||||
- Update frontend build tooling.
|
||||
|
||||
### :boom: Breaking changes
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- soultipsy [#1100](https://github.com/penpot/penpot/pull/1100)
|
||||
|
||||
|
||||
## 1.7.1-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -47,19 +599,18 @@
|
||||
- Fix issue on undo page deletion.
|
||||
- Fix some issues related to constraints.
|
||||
|
||||
|
||||
## 1.7.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Allow nested asset groups [Taiga #1716](https://tree.taiga.io/project/penpot/us/1716).
|
||||
- Allow to ungroup assets [Taiga #1719](https://tree.taiga.io/project/penpot/us/1719).
|
||||
- Allow to rename assets groups [Taiga #1721](https://tree.taiga.io/project/penpot/us/1721).
|
||||
- Component constraints (left, right, left and right, center, scale...) [Taiga #1125](https://tree.taiga.io/project/penpot/us/1125).
|
||||
- Export elements to PDF [Taiga #519](https://tree.taiga.io/project/penpot/us/519).
|
||||
- Memorize collapse state of assets in panel [Taiga #1718](https://tree.taiga.io/project/penpot/us/1718).
|
||||
- Headers button sets and menus review [Taiga #1663](https://tree.taiga.io/project/penpot/us/1663).
|
||||
- Preserve components if possible, when pasted into a different file [Taiga #1063](https://tree.taiga.io/project/penpot/issue/1063).
|
||||
- Allow nested asset groups [Taiga #1716](https://tree.taiga.io/project/penpot/us/1716)
|
||||
- Allow to ungroup assets [Taiga #1719](https://tree.taiga.io/project/penpot/us/1719)
|
||||
- Allow to rename assets groups [Taiga #1721](https://tree.taiga.io/project/penpot/us/1721)
|
||||
- Component constraints (left, right, left and right, center, scale...) [Taiga #1125](https://tree.taiga.io/project/penpot/us/1125)
|
||||
- Export elements to PDF [Taiga #519](https://tree.taiga.io/project/penpot/us/519)
|
||||
- Memorize collapse state of assets in panel [Taiga #1718](https://tree.taiga.io/project/penpot/us/1718)
|
||||
- Headers button sets and menus review [Taiga #1663](https://tree.taiga.io/project/penpot/us/1663)
|
||||
- Preserve components if possible, when pasted into a different file [Taiga #1063](https://tree.taiga.io/project/penpot/issue/1063)
|
||||
- Add the ability to offload file data to a cheaper storage when file becomes inactive.
|
||||
- Import/Export Penpot files from dashboard.
|
||||
- Double click won't make a shape a path until you change a node [Taiga #1796](https://tree.taiga.io/project/penpot/us/1796)
|
||||
@@ -68,25 +619,20 @@
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Process numeric input changes only if the value actually changed.
|
||||
- Remove unnecesary redirect from history when user goes to workspace from dashboard [Taiga #1820](https://tree.taiga.io/project/penpot/issue/1820).
|
||||
- Detach shapes from deleted assets [Taiga #1850](https://tree.taiga.io/project/penpot/issue/1850).
|
||||
- Fix tooltip position on view application [Taiga #1819](https://tree.taiga.io/project/penpot/issue/1819).
|
||||
- Fix dashboard navigation on moving file to other team [Taiga #1817](https://tree.taiga.io/project/penpot/issue/1817).
|
||||
- Fix workspace header presence styles and invalid link [Taiga #1813](https://tree.taiga.io/project/penpot/issue/1813).
|
||||
- Fix color-input wrong behavior (on workspace page color) [Taiga #1795](https://tree.taiga.io/project/penpot/issue/1795).
|
||||
- Fix file contextual menu in shared libraries at dashboard [Taiga #1865](https://tree.taiga.io/project/penpot/issue/1865).
|
||||
- Remove unnecessary redirect from history when user goes to workspace from dashboard [Taiga #1820](https://tree.taiga.io/project/penpot/issue/1820)
|
||||
- Detach shapes from deleted assets [Taiga #1850](https://tree.taiga.io/project/penpot/issue/1850)
|
||||
- Fix tooltip position on view application [Taiga #1819](https://tree.taiga.io/project/penpot/issue/1819)
|
||||
- Fix dashboard navigation on moving file to other team [Taiga #1817](https://tree.taiga.io/project/penpot/issue/1817)
|
||||
- Fix workspace header presence styles and invalid link [Taiga #1813](https://tree.taiga.io/project/penpot/issue/1813)
|
||||
- Fix color-input wrong behavior (on workspace page color) [Taiga #1795](https://tree.taiga.io/project/penpot/issue/1795)
|
||||
- Fix file contextual menu in shared libraries at dashboard [Taiga #1865](https://tree.taiga.io/project/penpot/issue/1865)
|
||||
- Fix problem with color picker and fonts [#1049](https://github.com/penpot/penpot/issues/1049)
|
||||
- Fix negative values in blur [Taiga #1815](https://tree.taiga.io/project/penpot/issue/1815)
|
||||
- Fix problem when editing color in group [Taiga #1816](https://tree.taiga.io/project/penpot/issue/1816)
|
||||
- Fix resize/rotate with mouse buttons different than left [#1060](https://github.com/penpot/penpot/issues/1060)
|
||||
- Fix header partialy visible on fullscreen viewer mode [Taiga #1875](https://tree.taiga.io/project/penpot/issue/1875)
|
||||
- Fix header partially visible on fullscreen viewer mode [Taiga #1875](https://tree.taiga.io/project/penpot/issue/1875)
|
||||
- Fix dynamic alignment enabled with hidden objects [#1063](https://github.com/penpot/penpot/issues/1063)
|
||||
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
### :boom: Breaking changes
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
## 1.6.5-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -97,8 +643,8 @@
|
||||
|
||||
### :sparkles: Minor improvements
|
||||
|
||||
- Decrease default bulk buffers on storage tasks.
|
||||
- Reduce file_change preserve interval to 24h.
|
||||
- Decrease default bulk buffers on storage tasks.
|
||||
- Reduce file_change preserve interval to 24h.
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -111,7 +657,6 @@
|
||||
- Properly handle nil values on `update-shapes` function.
|
||||
- Replace frame term usage by artboard on viewer app.
|
||||
|
||||
|
||||
## 1.6.3-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -138,42 +683,39 @@
|
||||
- Minor fix on previous commit.
|
||||
- Minor improvements on svg uploading on libraries.
|
||||
|
||||
|
||||
## 1.6.1-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Add safety check on reg-objects change impl.
|
||||
- Fix custom fonts embbedding issue.
|
||||
- Fix custom fonts embedding issue.
|
||||
- Fix dashboard ordering issue.
|
||||
- Fix problem when creating a component with empty data.
|
||||
- Fix problem with moving shapes into frames.
|
||||
- Fix problems with mov-objects.
|
||||
- Fix unexpected excetion related to rounding integers.
|
||||
- Fix unexpected exception related to rounding integers.
|
||||
- Fix wrong type usage on libraries changes.
|
||||
- Improve editor lifecycle management.
|
||||
- Make the navigation async by default.
|
||||
|
||||
|
||||
## 1.6.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add improved workspace font selector [Taiga US #292](https://tree.taiga.io/project/penpot/us/292).
|
||||
- Add improved workspace font selector [Taiga US #292](https://tree.taiga.io/project/penpot/us/292)
|
||||
- Add option to interactively scale text [Taiga #1527](https://tree.taiga.io/project/penpot/us/1527)
|
||||
- Add performance improvements on dashboard data loading.
|
||||
- Add performance improvements to indexes handling on workspace.
|
||||
- Add the ability to upload/use custom fonts (and automatically generate all needed webfonts) [Taiga US #292](https://tree.taiga.io/project/penpot/us/292).
|
||||
- Add the ability to upload/use custom fonts (and automatically generate all needed webfonts) [Taiga US #292](https://tree.taiga.io/project/penpot/us/292)
|
||||
- Transform shapes to path on double click
|
||||
- Translate automatic names of new files and projects.
|
||||
- Use shift instead of ctrl/cmd to keep aspect ratio [Taiga 1697](https://tree.taiga.io/project/penpot/issue/1697).
|
||||
- New translations: Portuguese (Brazil) and Romanias.
|
||||
|
||||
- Use shift instead of ctrl/cmd to keep aspect ratio [Taiga 1697](https://tree.taiga.io/project/penpot/issue/1697)
|
||||
- New translations: Portuguese (Brazil) and Romanias.
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Remove interactions when the destination artboard is deleted [Taiga #1656](https://tree.taiga.io/project/penpot/issue/1656).
|
||||
- Fix problem with fonts that ends with numbers [#940](https://github.com/penpot/penpot/issues/940).
|
||||
- Remove interactions when the destination artboard is deleted [Taiga #1656](https://tree.taiga.io/project/penpot/issue/1656)
|
||||
- Fix problem with fonts that ends with numbers [#940](https://github.com/penpot/penpot/issues/940)
|
||||
- Fix problem with imported SVG on editing paths [#971](https://github.com/penpot/penpot/issues/971)
|
||||
- Fix problem with color picker positioning
|
||||
- Fix order on color palette [#961](https://github.com/penpot/penpot/issues/961)
|
||||
@@ -187,7 +729,6 @@
|
||||
- Update exporter dependencies (puppeteer), that fixes some unexpected exceptions.
|
||||
- Update string manipulation library.
|
||||
|
||||
|
||||
### :boom: Breaking changes
|
||||
|
||||
- The OIDC setting `PENPOT_OIDC_SCOPES` has changed the default semantics. Before this
|
||||
@@ -198,7 +739,6 @@
|
||||
|
||||
- Translations: Portuguese (Brazil) and Romanias.
|
||||
|
||||
|
||||
## 1.5.4-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -206,7 +746,6 @@
|
||||
- Fix issues on group rendering.
|
||||
- Fix problem with text editing auto-height [Taiga #1683](https://tree.taiga.io/project/penpot/issue/1683)
|
||||
|
||||
|
||||
## 1.5.3-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -230,7 +769,6 @@
|
||||
- Increase default team invitation token expiration to 48h.
|
||||
- Fix wrong error message when an expired token is used.
|
||||
|
||||
|
||||
## 1.5.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
@@ -276,7 +814,6 @@
|
||||
- madmath03 (by [Monogramm](https://github.com/Monogramm)) [#807](https://github.com/penpot/penpot/pull/807)
|
||||
- zzkt [#814](https://github.com/penpot/penpot/pull/814)
|
||||
|
||||
|
||||
## 1.4.1-alpha
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -288,7 +825,6 @@
|
||||
- Fix incorrect state management of user lang selection.
|
||||
- Fix email validation usability issue on team invitation lightbox.
|
||||
|
||||
|
||||
## 1.4.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
@@ -297,7 +833,7 @@
|
||||
- Add http caching layer on top of Query RPC.
|
||||
- Add layer opacity and blend mode to shapes [Taiga #937](https://tree.taiga.io/project/penpot/us/937)
|
||||
- Add more chinese translations [#726](https://github.com/penpot/penpot/pull/726)
|
||||
- Add native support for text-direction (RTL, LTR & auto).
|
||||
- Add native support for text-direction (RTL, LTR & auto)
|
||||
- Add several enhancements in shape selection [Taiga #1195](https://tree.taiga.io/project/penpot/us/1195)
|
||||
- Add thumbnail in memory caching mechanism.
|
||||
- Add turkish translation strings [#759](https://github.com/penpot/penpot/pull/759), [#794](https://github.com/penpot/penpot/pull/794)
|
||||
@@ -305,13 +841,12 @@
|
||||
- Hide viewer navbar on fullscreen [Taiga 1375](https://tree.taiga.io/project/penpot/us/1375)
|
||||
- Import SVG will create Penpot's shapes [Taiga #1006](https://tree.taiga.io/project/penpot/us/1066)
|
||||
- Improve french translations [#731](https://github.com/penpot/penpot/pull/731)
|
||||
- Reimplement workspace presence (remove database state).
|
||||
- Reimplement workspace presence (remove database state)
|
||||
- Remember last visited team when you re-enter the application [Taiga #1376](https://tree.taiga.io/project/penpot/us/1376)
|
||||
- Rename artboard with double click on the title [Taiga #1392](https://tree.taiga.io/project/penpot/us/1392)
|
||||
- Replace Slate-Editor with DraftJS [Taiga #1346](https://tree.taiga.io/project/penpot/us/1346)
|
||||
- Set proper page title [Taiga #1377](https://tree.taiga.io/project/penpot/us/1377)
|
||||
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Disable buttons in view mode for users without permissions [Taiga #1328](https://tree.taiga.io/project/penpot/issue/1328)
|
||||
@@ -352,15 +887,13 @@
|
||||
|
||||
- The LDAP configuration variables interpolation starts using `:`
|
||||
(example `:username`) instead of `$`. The main reason is avoid
|
||||
unnecesary conflict with bash interpolation.
|
||||
|
||||
unnecessary conflict with bash interpolation.
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update backend to JDK16.
|
||||
- Update exporter nodejs to v14.16.0
|
||||
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- iblueer [#726](https://github.com/penpot/penpot/pull/726)
|
||||
@@ -368,27 +901,25 @@
|
||||
- girafic [#748](https://github.com/penpot/penpot/pull/748)
|
||||
- mbrksntrk [#794](https://github.com/penpot/penpot/pull/794)
|
||||
|
||||
|
||||
## 1.3.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Add emailcatcher and ldap test containers to devenv. [#506](https://github.com/penpot/penpot/pull/506)
|
||||
- Add major refactor of internal pubsub/redis code; improves scalability and performance [#640](https://github.com/penpot/penpot/pull/640)
|
||||
- Add more chinese transtions [#687](https://github.com/penpot/penpot/pull/687)
|
||||
- Add more chinese translations [#687](https://github.com/penpot/penpot/pull/687)
|
||||
- Add more presets for artboard [#654](https://github.com/penpot/penpot/pull/654)
|
||||
- Add optional loki integration [#645](https://github.com/penpot/penpot/pull/645)
|
||||
- Add proper http session lifecycle handling.
|
||||
- Allow to set border radius of each rect corner individually
|
||||
- Bounce & Complaint handling [#635](https://github.com/penpot/penpot/pull/635)
|
||||
- Disable groups interactions when holding "Ctrl" key (deep selection)
|
||||
- New action in context menu to "edit" some shapes (binded to key "Enter")
|
||||
|
||||
- New action in context menu to "edit" some shapes (bound to key "Enter")
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Add more improvements to french translation strings [#591](https://github.com/penpot/penpot/pull/591)
|
||||
- Add some missing database indexes (mainly improves performance on large databases on file-update rpc method, and some background tasks).
|
||||
- Add some missing database indexes (mainly improves performance on large databases on file-update rpc method, and some background tasks)
|
||||
- Disables filters in masking elements (issue with Firefox rendering)
|
||||
- Drawing tool will have priority over resize/rotate handlers [Taiga #1225](https://tree.taiga.io/project/penpot/issue/1225)
|
||||
- Fix broken bounding box on editing paths [Taiga #1254](https://tree.taiga.io/project/penpot/issue/1254)
|
||||
@@ -402,16 +933,14 @@
|
||||
- Have language change notification written in the new language [Taiga #1205](https://tree.taiga.io/project/penpot/issue/1205)
|
||||
- Hide register screen when registration is disabled [#598](https://github.com/penpot/penpot/issues/598)
|
||||
- Properly handle errors on github, gitlab and ldap auth backends.
|
||||
- Properly mark profile auth backend (on first register/ auth with 3rd party auth provider).
|
||||
- Properly mark profile auth backend (on first register/ auth with 3rd party auth provider)
|
||||
- Refactor LDAP auth backend.
|
||||
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- girafic [#538](https://github.com/penpot/penpot/pull/654)
|
||||
- arkhi [#591](https://github.com/penpot/penpot/pull/591)
|
||||
|
||||
|
||||
## 1.2.0-alpha
|
||||
|
||||
### :sparkles: New features
|
||||
@@ -426,7 +955,6 @@
|
||||
- Show a pixel grid when zoom greater than 800% [#519](https://github.com/penpot/penpot/discussions/519)
|
||||
- Fix behavior of select all command when there are objects outside frames [Taiga #1209](https://tree.taiga.io/project/penpot/issue/1209)
|
||||
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix 404 when access shared link [#615](https://github.com/penpot/penpot/issues/615)
|
||||
@@ -462,7 +990,6 @@
|
||||
- Improved MacOS shortcuts and helpers
|
||||
- Small changes to shape creation
|
||||
|
||||
|
||||
## 1.0.0-alpha
|
||||
|
||||
Initial release
|
||||
|
||||
@@ -19,9 +19,9 @@ If you found a bug, please report it, as far as possible with:
|
||||
- a browser and the browser version used
|
||||
- a dev tools console exception stack trace (if it is available)
|
||||
|
||||
If you found a bug that you consider better discuse in private (for
|
||||
If you found a bug that you consider better discuss in private (for
|
||||
example: security bugs), consider first send an email to
|
||||
`info@penpot.app`.
|
||||
`support@penpot.app`.
|
||||
|
||||
**We don't have formal bug bounty program for security reports; this
|
||||
is an open source application and your contribution will be recognized
|
||||
@@ -54,7 +54,7 @@ We will use the `easy fix` mark for tag for indicate issues that are
|
||||
easy for beginners.
|
||||
|
||||
|
||||
## Commit Message Guidelines ##
|
||||
## Commit Guidelines ##
|
||||
|
||||
We have very precise rules over how our git commit messages can be formatted.
|
||||
|
||||
@@ -78,7 +78,6 @@ Where type is:
|
||||
- :ambulance: `:ambulance:` a commit that fixes critical bug
|
||||
- :books: `:books:` a commit that improves or adds documentation
|
||||
- :construction: `:construction:`: a wip commit
|
||||
- :construction_worker: `:construction_worker:` a commit with CI related stuff
|
||||
- :boom: `:boom:` a commit with breaking changes
|
||||
- :wrench: `:wrench:` a commit for config updates
|
||||
- :zap: `:zap:` a commit with performance improvements
|
||||
@@ -91,12 +90,25 @@ More info:
|
||||
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
||||
- https://gist.github.com/rxaviers/7360908
|
||||
|
||||
The subject should be:
|
||||
Each commit should have:
|
||||
|
||||
- Use the imperative mood.
|
||||
- Capitalize the first letter.
|
||||
- Don't put a period at the end of the subject line.
|
||||
- Put a blank line between the subject line and the body.
|
||||
- A concise subject using imperative mood.
|
||||
- The subject should have capitalized the first letter, without period
|
||||
at the end and no larger than 65 characters.
|
||||
- A blank line between the subject line and the body.
|
||||
- An entry on the CHANGES.md file if applicable, referencing the
|
||||
github or taiga issue/user-story using the these same rules.
|
||||
|
||||
Examples of good commit messags:
|
||||
|
||||
- :bug: Fix unexpected error on launching modal
|
||||
- :bug: Set proper error message on generic error
|
||||
- :sparkles: Enable new modal for profile
|
||||
- :zap: Improve performance of dashboard navigation
|
||||
- :wrench: Update default backend configuration
|
||||
- :books: Add more documentation for authentication process
|
||||
- :ambulance: Fix critical bug on user registration process
|
||||
- :tada: Add new approach for user registration
|
||||
|
||||
|
||||
## Code of conduct ##
|
||||
|
||||
@@ -70,9 +70,9 @@ You can ask and answer questions, have open-ended conversations, and follow alon
|
||||
|
||||
✉️ [Mail us](mailto:info@penpot.app)
|
||||
|
||||
💬 [Github discussions](https://github.com/penpot/penpot/discussions)
|
||||
💬 [GitHub discussions](https://github.com/penpot/penpot/discussions)
|
||||
|
||||
🐞 [Github issues](mailto:info@penpot.apphttps://github.com/penpot/penpot/issues)
|
||||
🐞 [GitHub issues](https://github.com/penpot/penpot/issues)
|
||||
|
||||
✍️️ [Gitter](https://gitter.im/penpot/community)
|
||||
|
||||
@@ -81,7 +81,7 @@ You can ask and answer questions, have open-ended conversations, and follow alon
|
||||
You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.
|
||||
Would you like to know more about Penpot? We recommend you to visit our youtube channel and learn more about the functionalities and possibilities of Penpot with our video tutorials.
|
||||
|
||||
🎞️ [Youtube channel](https://www.youtube.com/channel/UCAqS8G72uv9P5HG1IfgnQ9g)
|
||||
🎞️ [YouTube channel](https://www.youtube.com/channel/UCAqS8G72uv9P5HG1IfgnQ9g)
|
||||
|
||||
## License ##
|
||||
|
||||
|
||||
36
backend/build.clj
Normal file
36
backend/build.clj
Normal file
@@ -0,0 +1,36 @@
|
||||
(ns build
|
||||
(:refer-clojure :exclude [compile])
|
||||
(:require
|
||||
[clojure.tools.build.api :as b]
|
||||
[clojure.java.io]))
|
||||
|
||||
(def class-dir "target/classes")
|
||||
(def basis (b/create-basis {:project "deps.edn"}))
|
||||
(def jar-file "target/penpot.jar")
|
||||
|
||||
(defn clean [_]
|
||||
(b/delete {:path "target"}))
|
||||
|
||||
(defn jar [_]
|
||||
(b/copy-dir
|
||||
{:src-dirs ["src" "resources"]
|
||||
:target-dir class-dir})
|
||||
|
||||
(b/compile-clj
|
||||
{:basis basis
|
||||
:src-dirs ["src"]
|
||||
:class-dir class-dir})
|
||||
|
||||
(b/uber
|
||||
{:class-dir class-dir
|
||||
:uber-file jar-file
|
||||
:main 'clojure.main
|
||||
:exclude [#"goog.*" #"^javasist.*"]
|
||||
:basis basis}))
|
||||
|
||||
(defn compile [_]
|
||||
(b/javac
|
||||
{:src-dirs ["dev/java"]
|
||||
:class-dir class-dir
|
||||
:basis basis
|
||||
:javac-opts ["-source" "11" "-target" "11"]}))
|
||||
@@ -1,78 +1,77 @@
|
||||
{
|
||||
;; :mvn/repos
|
||||
;; {"central" {:url "https://repo1.maven.org/maven2/"}
|
||||
;; "clojars" {:url "https://clojars.org/repo"}
|
||||
;; "jcenter" {:url "https://jcenter.bintray.com/"}
|
||||
;; }
|
||||
:deps
|
||||
{penpot/common
|
||||
{:local/root "../common"}
|
||||
{:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.10.3"}
|
||||
org.clojure/core.async {:mvn/version "1.5.648"}
|
||||
|
||||
;; Logging
|
||||
org.zeromq/jeromq {:mvn/version "0.5.2"}
|
||||
|
||||
com.taoensso/nippy {:mvn/version "3.1.1"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.4.9-5"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.2-2"}
|
||||
org.clojure/data.fressian {:mvn/version "1.0.0"}
|
||||
|
||||
;; NOTE: don't upgrade to latest version, breaking change is
|
||||
;; introduced on 0.10.0 that suffixes counters with _total if they
|
||||
;; are not already has this suffix.
|
||||
io.prometheus/simpleclient {:mvn/version "0.9.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.9.0"}
|
||||
io.prometheus/simpleclient_jetty {:mvn/version "0.9.0"
|
||||
io.prometheus/simpleclient {:mvn/version "0.15.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.15.0"}
|
||||
io.prometheus/simpleclient_jetty {:mvn/version "0.15.0"
|
||||
:exclusions [org.eclipse.jetty/jetty-server
|
||||
org.eclipse.jetty/jetty-servlet]}
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.9.0"}
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.15.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "6.1.2.RELEASE"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.2"}
|
||||
io.lettuce/lettuce-core {:mvn/version "6.1.6.RELEASE"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
|
||||
info.sunng/ring-jetty9-adapter {:mvn/version "0.15.1"}
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.2.659"}
|
||||
metosin/reitit-ring {:mvn/version "0.5.13"}
|
||||
org.postgresql/postgresql {:mvn/version "42.2.20"}
|
||||
com.zaxxer/HikariCP {:mvn/version "4.0.3"}
|
||||
funcool/yetti {:git/tag "v9.1" :git/sha "63f35d9"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.2.772"}
|
||||
metosin/reitit-core {:mvn/version "0.5.16"}
|
||||
org.postgresql/postgresql {:mvn/version "42.3.3"}
|
||||
com.zaxxer/HikariCP {:mvn/version "5.0.1"}
|
||||
funcool/datoteka {:mvn/version "2.0.0"}
|
||||
|
||||
buddy/buddy-core {:mvn/version "1.10.1"}
|
||||
buddy/buddy-hashers {:mvn/version "1.8.1"}
|
||||
buddy/buddy-sign {:mvn/version "3.4.1"}
|
||||
buddy/buddy-hashers {:mvn/version "1.8.158"}
|
||||
buddy/buddy-sign {:mvn/version "3.4.333"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.13.1"}
|
||||
org.jsoup/jsoup {:mvn/version "1.14.3"}
|
||||
org.im4java/im4java {:mvn/version "1.4.0"}
|
||||
org.lz4/lz4-java {:mvn/version "1.7.1"}
|
||||
org.lz4/lz4-java {:mvn/version "1.8.0"}
|
||||
|
||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||
integrant/integrant {:mvn/version "0.8.0"}
|
||||
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.16.62"}}
|
||||
io.sentry/sentry {:mvn/version "5.6.1"}
|
||||
|
||||
:paths ["src" "resources"]
|
||||
dawran6/emoji {:mvn/version "0.1.5"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.11.0"}
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.17.136"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{com.bhauman/rebel-readline {:mvn/version "RELEASE"}
|
||||
org.clojure/tools.namespace {:mvn/version "RELEASE"}
|
||||
org.clojure/test.check {:mvn/version "RELEASE"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "0.5.0"}
|
||||
|
||||
criterium/criterium {:mvn/version "RELEASE"}
|
||||
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
||||
org.clojure/data.csv {:mvn/version "RELEASE"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
|
||||
mockery/mockery {:mvn/version "RELEASE"}}
|
||||
:extra-paths ["test" "dev"]}
|
||||
|
||||
:fn-fixtures
|
||||
{:exec-fn app.cli.fixtures/run
|
||||
:args {}}
|
||||
|
||||
:kaocha
|
||||
{:extra-deps {lambdaisland/kaocha {:mvn/version "1.0.829"}}
|
||||
:main-opts ["-m" "kaocha.runner"]}
|
||||
:build
|
||||
{:extra-deps
|
||||
{io.github.clojure/tools.build {:git/tag "v0.7.7" :git/sha "1474ad6"}}
|
||||
:ns-default build}
|
||||
|
||||
:test
|
||||
{:extra-deps {io.github.cognitect-labs/test-runner
|
||||
{:git/url "https://github.com/cognitect-labs/test-runner.git"
|
||||
:sha "705ad25bbf0228b1c38d0244a36001c2987d7337"}}
|
||||
{:extra-paths ["test"]
|
||||
:extra-deps
|
||||
{io.github.cognitect-labs/test-runner
|
||||
{:git/tag "v0.5.0" :git/sha "b3fd0d2"}}
|
||||
:exec-fn cognitect.test-runner.api/test}
|
||||
|
||||
:outdated
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
|
||||
(ns user
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.perf :as perf]
|
||||
[app.common.transit :as t]
|
||||
[app.config :as cfg]
|
||||
[app.main :as main]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.fressian :as fres]
|
||||
[app.util.json :as json]
|
||||
[app.util.time :as dt]
|
||||
[app.util.transit :as t]
|
||||
[clj-async-profiler.core :as prof]
|
||||
[clojure.contrib.humanize :as hum]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.pprint :refer [pprint print-table]]
|
||||
[clojure.repl :refer :all]
|
||||
@@ -22,31 +28,14 @@
|
||||
[clojure.test :as test]
|
||||
[clojure.tools.namespace.repl :as repl]
|
||||
[clojure.walk :refer [macroexpand-all]]
|
||||
[criterium.core :refer [quick-bench bench with-progress-reporting]]
|
||||
[datoteka.core]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defonce system nil)
|
||||
|
||||
;; --- Benchmarking Tools
|
||||
|
||||
(defmacro run-quick-bench
|
||||
[& exprs]
|
||||
`(with-progress-reporting (quick-bench (do ~@exprs) :verbose)))
|
||||
|
||||
(defmacro run-quick-bench'
|
||||
[& exprs]
|
||||
`(quick-bench (do ~@exprs)))
|
||||
|
||||
(defmacro run-bench
|
||||
[& exprs]
|
||||
`(with-progress-reporting (bench (do ~@exprs) :verbose)))
|
||||
|
||||
(defmacro run-bench'
|
||||
[& exprs]
|
||||
`(bench (do ~@exprs)))
|
||||
|
||||
;; --- Development Stuff
|
||||
|
||||
(defn- run-tests
|
||||
@@ -91,7 +80,16 @@
|
||||
|
||||
(defn compression-bench
|
||||
[data]
|
||||
(print-table
|
||||
[{:v1 (alength (blob/encode data {:version 1}))
|
||||
:v2 (alength (blob/encode data {:version 2}))
|
||||
:v3 (alength (blob/encode data {:version 3}))}]))
|
||||
(let [humanize (fn [v] (hum/filesize v :binary true :format " %.4f "))]
|
||||
(print-table
|
||||
[{:v1 (humanize (alength (blob/encode data {:version 1})))
|
||||
:v2 (humanize (alength (blob/encode data {:version 2})))
|
||||
:v3 (humanize (alength (blob/encode data {:version 3})))
|
||||
:v4 (humanize (alength (blob/encode data {:version 4})))
|
||||
}])))
|
||||
|
||||
(defonce debug-tap
|
||||
(do
|
||||
(add-tap #(locking debug-tap
|
||||
(prn "tap debug:" %)))
|
||||
1))
|
||||
|
||||
101
backend/resources/api-doc.css
Normal file
101
backend/resources/api-doc.css
Normal file
@@ -0,0 +1,101 @@
|
||||
* {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 900px;
|
||||
width: 900px;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rpc-doc-content {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* border: 1px solid red; */
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.rpc-doc-content > h2:not(:first-child) {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
|
||||
.rpc-items {
|
||||
list-style: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.rpc-item {
|
||||
/* border: 1px solid red; */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rpc-item:not(:last-child) {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.rpc-row-info {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: #eeeeee;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.rpc-row-info > *:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.rpc-row-info > * {
|
||||
/* border: 1px solid green; */
|
||||
}
|
||||
|
||||
.rpc-row-info > .type {
|
||||
font-weight: bold;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.rpc-row-info > .name {
|
||||
width: 280px;
|
||||
/* font-weight: bold; */
|
||||
}
|
||||
|
||||
.rpc-row-info > .tags > .tag > span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rpc-row-detail {
|
||||
padding: 5px 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
27
backend/resources/api-doc.js
Normal file
27
backend/resources/api-doc.js
Normal file
@@ -0,0 +1,27 @@
|
||||
(function() {
|
||||
document.addEventListener("DOMContentLoaded", function(event) {
|
||||
const rows = document.querySelectorAll(".rpc-row-info");
|
||||
|
||||
const onRowClick = (event) => {
|
||||
const target = event.currentTarget;
|
||||
for (let node of rows) {
|
||||
if (node !== target) {
|
||||
node.nextElementSibling.classList.add("hidden");
|
||||
} else {
|
||||
const sibling = target.nextElementSibling;
|
||||
|
||||
if (sibling.classList.contains("hidden")) {
|
||||
sibling.classList.remove("hidden");
|
||||
} else {
|
||||
sibling.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let node of rows) {
|
||||
node.addEventListener("click", onRowClick);
|
||||
}
|
||||
|
||||
});
|
||||
})();
|
||||
80
backend/resources/api-doc.tmpl
Normal file
80
backend/resources/api-doc.tmpl
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Builtin API Documentation - Penpot</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
|
||||
<style>
|
||||
{% include "api-doc.css" %}
|
||||
</style>
|
||||
<script>
|
||||
{% include "api-doc.js" %}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Penpot API Documentation</h1>
|
||||
</header>
|
||||
<section class="rpc-doc-content">
|
||||
|
||||
<h2>RPC QUERY METHODS:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in query-methods %}
|
||||
<li class="rpc-item">
|
||||
<div class="rpc-row-info">
|
||||
{# <div class="type">{{item.type}}</div> #}
|
||||
<div class="name">{{item.name}}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">
|
||||
<span>Auth:</span>
|
||||
<span>{% if item.auth %}YES{% else %}NO{% endif %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpc-row-detail hidden">
|
||||
{% if item.docs %}
|
||||
<h3>DOCSTRING:</h3>
|
||||
<p>{{item.docs}}</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>SPEC EXPLAIN:</h3>
|
||||
<pre>{{item.spec}}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>RPC MUTATION METHODS:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in mutation-methods %}
|
||||
<li class="rpc-item">
|
||||
<div class="rpc-row-info">
|
||||
{# <div class="type">{{item.type}}</div> #}
|
||||
<div class="name">{{item.name}}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">
|
||||
<span>Auth:</span>
|
||||
<span>{% if item.auth %}YES{% else %}NO{% endif %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpc-row-detail hidden">
|
||||
{% if item.docs %}
|
||||
<h3>DOCSTRING:</h3>
|
||||
<p>{{item.docs}}</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>SPEC EXPLAIN:</h3>
|
||||
<pre>{{item.spec}}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
|
||||
<mj-text>
|
||||
Thanks for signing up for your Penpot account! Please verify your
|
||||
email using the link below adn get started building mockups and
|
||||
email using the link below and get started building mockups and
|
||||
prototypes today!
|
||||
</mj-text>
|
||||
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
|
||||
|
||||
@@ -1 +1 @@
|
||||
[PENPOT FEEDBACK]: {{subject|abbreviate:19}} (from {{email}})
|
||||
[PENPOT FEEDBACK]: {{subject}}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Thanks for signing up for your Penpot account! Please verify your email using the link below adn get started building mockups and prototypes today!</div>
|
||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">Thanks for signing up for your Penpot account! Please verify your email using the link below and get started building mockups and prototypes today!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -465,4 +465,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Hello {{name}}!
|
||||
|
||||
Thanks for signing up for your Penpot account! Please verify your email using the
|
||||
link below adn get started building mockups and prototypes today!
|
||||
link below and get started building mockups and prototypes today!
|
||||
|
||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>penpot - error report {{id}}</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
|
||||
<style>
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
pre {
|
||||
margin: 0px;
|
||||
}
|
||||
* {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
/* width: 100%; */
|
||||
/* border: 1px solid red; */
|
||||
}
|
||||
|
||||
.table-key {
|
||||
font-weight: 600;
|
||||
width: 60px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.table-val {
|
||||
font-weight: 200;
|
||||
color: #333;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.multiline {
|
||||
margin-top: 15px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.multiline .table-key {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px dashed #dddddd;
|
||||
/* padding: 4px; */
|
||||
width: unset;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="table">
|
||||
<div class="table-row">
|
||||
<div class="table-key" title="Error ID">ERID: </div>
|
||||
<div class="table-val">{{id}}</div>
|
||||
</div>
|
||||
{% if profile-id %}
|
||||
<div class="table-row">
|
||||
<div class="table-key" title="Profile ID">PFID: </div>
|
||||
<div class="table-val">{{profile-id}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user-agent %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">UAGT: </div>
|
||||
<div class="table-val">{{user-agent}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if frontend-version %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">FVER: </div>
|
||||
<div class="table-val">{{frontend-version}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-row">
|
||||
<div class="table-key">BVER: </div>
|
||||
<div class="table-val">{{version}}</div>
|
||||
</div>
|
||||
|
||||
{% if host %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">HOST: </div>
|
||||
<div class="table-val">{{host}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tenant %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">ENV: </div>
|
||||
<div class="table-val">{{tenant}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if public-uri %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">PURI: </div>
|
||||
<div class="table-val">{{public-uri}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if type %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">TYPE: </div>
|
||||
<div class="table-val">{{type}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if code %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">CODE: </div>
|
||||
<div class="table-val">{{code}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">CLSS: </div>
|
||||
<div class="table-val">{{error.class}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">HINT: </div>
|
||||
<div class="table-val">{{error.message}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if method %}
|
||||
<div class="table-row">
|
||||
<div class="table-key">PATH: </div>
|
||||
<div class="table-val">{{method|upper}} {{path}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if explain %}
|
||||
<div>(<a href="#explain">go to explain</a>)</div>
|
||||
{% endif %}
|
||||
{% if data %}
|
||||
<div>(<a href="#edata">go to edata</a>)</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div>(<a href="#trace">go to trace</a>)</div>
|
||||
{% endif %}
|
||||
|
||||
{% if params %}
|
||||
<div id="params" class="table-row multiline">
|
||||
<div class="table-key">PARAMS: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{params}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if explain %}
|
||||
<div id="explain" class="table-row multiline">
|
||||
<div class="table-key">EXPLAIN: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{explain}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data %}
|
||||
<div id="edata" class="table-row multiline">
|
||||
<div class="table-key">EDATA: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{data}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div id="trace" class="table-row multiline">
|
||||
<div class="table-key">TRACE:</div>
|
||||
<div class="table-val">
|
||||
<pre>{{error.trace}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Configuration status="info" monitorInterval="30">
|
||||
<Appenders>
|
||||
<Console name="console" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] [%t] %level{length=1} %logger{36} - %msg%n"/>
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"/>
|
||||
</Console>
|
||||
|
||||
<RollingFile name="main" fileName="logs/main.log" filePattern="logs/main-%i.log">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Configuration status="info" monitorInterval="60">
|
||||
<Appenders>
|
||||
<Console name="console" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] [%t] %level{length=1} %logger{36} - %msg%n"/>
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
|
||||
|
||||
18
backend/resources/templates/base.tmpl
Normal file
18
backend/resources/templates/base.tmpl
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
|
||||
<style>
|
||||
{% include "templates/styles.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
32
backend/resources/templates/debug.tmpl
Normal file
32
backend/resources/templates/debug.tmpl
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
Debug Main Page
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<h1>Debug INDEX:</h1>
|
||||
<div>[<a href="/dbg/error">ERRORS</a>]</div>
|
||||
</nav>
|
||||
<main class="index">
|
||||
<section>
|
||||
<h2>Download file data:</h2>
|
||||
<desc>Given an FILE-ID, downloads the file data as file. The file data is encoded using transit.</desc>
|
||||
<form method="get" action="/dbg/file/data">
|
||||
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
|
||||
<input type="hidden" name="download" value="1" />
|
||||
<input type="submit" value="Download" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Upload File Data:</h2>
|
||||
<desc>Create a new file on your draft projects using the file downloaded from the previous section.</desc>
|
||||
<form method="post" enctype="multipart/form-data" action="/dbg/file/data">
|
||||
<input type="file" name="file" value="" />
|
||||
<input type="submit" value="Upload" />
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
18
backend/resources/templates/error-list.tmpl
Normal file
18
backend/resources/templates/error-list.tmpl
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
penpot - error list
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<h1>Latest error reports:</h1>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
<ul>
|
||||
{% for item in items %}
|
||||
<li><a href="/dbg/error/{{item.id}}">{{item.created-at}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</main>
|
||||
{% endblock %}
|
||||
98
backend/resources/templates/error-report.tmpl
Normal file
98
backend/resources/templates/error-report.tmpl
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
penpot - error report {{id}}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error">⮜</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
<div>[<a href="#params">request params</a>]</div>
|
||||
{% if data %}
|
||||
<div>[<a href="#edata">error data</a>]</div>
|
||||
{% endif %}
|
||||
{% if spec-explain %}
|
||||
<div>[<a href="#spec-explain">spec explain</a>]</div>
|
||||
{% endif %}
|
||||
{% if spec-problems %}
|
||||
<div>[<a href="#spec-problems">spec problems</a>]</div>
|
||||
{% endif %}
|
||||
{% if spec-value %}
|
||||
<div>[<a href="#spec-value">spec value</a>]</div>
|
||||
{% endif %}
|
||||
|
||||
{% if trace %}
|
||||
<div>[<a href="#trace">error trace</a>]</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </div>
|
||||
|
||||
<div class="table-val">
|
||||
<h1>{{hint}}</h1>
|
||||
</div>
|
||||
|
||||
<div class="table-val">
|
||||
<pre>{{context}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if params %}
|
||||
<div class="table-row multiline">
|
||||
<div id="params" class="table-key">REQUEST PARAMS: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{params}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data %}
|
||||
<div class="table-row multiline">
|
||||
<div id="edata" class="table-key">ERROR DATA: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{data}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if spec-explain %}
|
||||
<div class="table-row multiline">
|
||||
<div id="spec-explain" class="table-key">SPEC EXPLAIN: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{spec-explain}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if spec-problems %}
|
||||
<div class="table-row multiline">
|
||||
<div id="spec-problems" class="table-key">SPEC PROBLEMS: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{spec-problems}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if spec-value %}
|
||||
<div class="table-row multiline">
|
||||
<div id="spec-value" class="table-key">SPEC VALUE: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{spec-value}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if trace %}
|
||||
<div class="table-row multiline">
|
||||
<div id="trace" class="table-key">TRACE:</div>
|
||||
<div class="table-val">
|
||||
<pre>{{trace}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
150
backend/resources/templates/styles.css
Normal file
150
backend/resources/templates/styles.css
Normal file
@@ -0,0 +1,150 @@
|
||||
* {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
desc {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
input[type=text], input[type=submit] {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 5px 20px;
|
||||
display: flex;
|
||||
background: #e3e3e3;
|
||||
}
|
||||
|
||||
nav > h1 {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
nav > div {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav > div:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
padding-bottom: 15px;
|
||||
/* width: 100%; */
|
||||
/* border: 1px solid red; */
|
||||
}
|
||||
|
||||
.table-key {
|
||||
font-weight: 600;
|
||||
width: 60px;
|
||||
padding: 4px;
|
||||
|
||||
padding-top: 40px;
|
||||
margin-top: -40px;
|
||||
}
|
||||
|
||||
.table-val {
|
||||
font-weight: 200;
|
||||
color: #333;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.multiline {
|
||||
margin-top: 15px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.multiline .table-key {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px dashed #dddddd;
|
||||
/* padding: 4px; */
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.index {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.index > section {
|
||||
padding: 10px;
|
||||
background-color: #e3e3e3;
|
||||
}
|
||||
|
||||
.index > section:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.index > section > h2 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.horizontal-list {
|
||||
margin: 20px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.horizontal-list ul {
|
||||
display: flex;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
height: calc(100vh - 75px);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.horizontal-list li {
|
||||
list-style: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
line-height: 18px;
|
||||
min-width: 210px;
|
||||
margin: 0px 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.horizontal-list li:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.horizontal-list li > a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1,79 +1,21 @@
|
||||
#!/usr/bin/env bb
|
||||
#!/usr/bin/env bash
|
||||
|
||||
;; 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) UXBOX Labs SL
|
||||
CURRENT_VERSION=$1;
|
||||
|
||||
(ns build
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.pprint :refer [pprint]]
|
||||
[babashka.fs :as fs]
|
||||
[babashka.process :refer [$ check]]))
|
||||
set -ex
|
||||
|
||||
(defn split-cp
|
||||
[data]
|
||||
(str/split data #":"))
|
||||
rm -rf target;
|
||||
mkdir -p target/classes;
|
||||
mkdir -p target/dist;
|
||||
echo "$CURRENT_VERSION" > target/classes/version.txt;
|
||||
cp ../CHANGES.md target/classes/changelog.md;
|
||||
|
||||
(def classpath
|
||||
(->> ($ clojure -Spath)
|
||||
(check)
|
||||
(:out)
|
||||
(slurp)
|
||||
(split-cp)
|
||||
(map str/trim)))
|
||||
clojure -T:build jar;
|
||||
mv target/penpot.jar target/dist/penpot.jar
|
||||
cp scripts/run.template.sh target/dist/run.sh;
|
||||
cp scripts/manage.template.sh target/dist/manage.sh;
|
||||
chmod +x target/dist/run.sh;
|
||||
chmod +x target/dist/manage.sh;
|
||||
|
||||
(def classpath-jars
|
||||
(let [xfm (filter #(str/ends-with? % ".jar"))]
|
||||
(into #{} xfm classpath)))
|
||||
|
||||
(def classpath-paths
|
||||
(let [xfm (comp (remove #(str/ends-with? % ".jar"))
|
||||
(filter #(.isDirectory (io/file %))))]
|
||||
(into #{} xfm classpath)))
|
||||
|
||||
(def version
|
||||
(or (first *command-line-args*) "%version%"))
|
||||
|
||||
;; Clean previous dist
|
||||
(-> ($ rm -rf "./target/dist") check)
|
||||
|
||||
;; Create a new dist
|
||||
(-> ($ mkdir -p "./target/dist/deps") check)
|
||||
|
||||
;; Copy all jar deps into dist
|
||||
(run! (fn [item] (-> ($ cp ~item "./target/dist/deps/") check)) classpath-jars)
|
||||
|
||||
;; Create the application jar
|
||||
(spit "./target/dist/version.txt" version)
|
||||
|
||||
(-> ($ jar cvf "./target/dist/deps/app.jar" -C ~(first classpath-paths) ".") check)
|
||||
(-> ($ jar uvf "./target/dist/deps/app.jar" -C "./target/dist" "version.txt") check)
|
||||
(run! (fn [item]
|
||||
(-> ($ jar uvf "./target/dist/deps/app.jar" -C ~item ".") check))
|
||||
(rest classpath-paths))
|
||||
|
||||
;; Copy logging configuration
|
||||
(-> ($ cp "./resources/log4j2.xml" "./target/dist/") check)
|
||||
|
||||
;; Create classpath file
|
||||
(let [jars (->> (into ["app.jar"] classpath-jars)
|
||||
(map fs/file-name)
|
||||
(map #(fs/path "deps" %))
|
||||
(map str))]
|
||||
(spit "./target/dist/classpath" (str/join ":" jars)))
|
||||
|
||||
;; Copy run script template
|
||||
(-> ($ cp "./scripts/run.template.sh" "./target/dist/run.sh") check)
|
||||
|
||||
;; Copy run script template
|
||||
(-> ($ cp "./scripts/manage.template.sh" "./target/dist/manage.sh") check)
|
||||
|
||||
;; Add exec permisions to scripts.
|
||||
(-> ($ chmod +x "./target/dist/run.sh") check)
|
||||
(-> ($ chmod +x "./target/dist/manage.sh") check)
|
||||
|
||||
nil
|
||||
|
||||
@@ -16,4 +16,4 @@ if [ -f ./environ ]; then
|
||||
source ./environ
|
||||
fi
|
||||
|
||||
exec $JAVA_CMD $JVM_OPTS -classpath $(cat classpath) -Dlog4j2.configurationFile=./log4j2.xml clojure.main -m app.cli.manage "$@"
|
||||
exec $JAVA_CMD $JVM_OPTS -jar penpot.jar -m app.cli.manage "$@"
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_ASSERTS_ENABLED=true
|
||||
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"
|
||||
|
||||
export OPTIONS="-A:jmx-remote:dev -J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -J-Dlog4j2.configurationFile=log4j2-devenv.xml -J-Djdk.attach.allowAttachSelf -J-XX:+UseZGC -J-XX:ConcGCThreads=1 -J-XX:-OmitStackTraceInFastThrow -J-Xms50m -J-Xmx512m";
|
||||
# export OPTIONS="$OPTIONS -J-XX:+UnlockDiagnosticVMOptions";
|
||||
# export OPTIONS="$OPTIONS -J-XX:-TieredCompilation -J-XX:CompileThreshold=10000";
|
||||
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot"
|
||||
# export PENPOT_DATABASE_USERNAME="penpot"
|
||||
# export PENPOT_DATABASE_PASSWORD="penpot"
|
||||
# export PENPOT_DATABASE_READONLY=true
|
||||
|
||||
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot_pre"
|
||||
# export PENPOT_DATABASE_USERNAME="penpot_pre"
|
||||
# export PENPOT_DATABASE_PASSWORD="penpot_pre"
|
||||
|
||||
# export PENPOT_LOGGERS_LOKI_URI="http://172.17.0.1:3100/loki/api/v1/push"
|
||||
# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"
|
||||
|
||||
# Initialize MINIO config
|
||||
mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin
|
||||
mc admin user add penpot-s3 penpot-devenv penpot-devenv
|
||||
mc admin policy set penpot-s3 readwrite user=penpot-devenv
|
||||
mc mb penpot-s3/penpot -p
|
||||
|
||||
export AWS_ACCESS_KEY_ID=penpot-devenv
|
||||
export AWS_SECRET_ACCESS_KEY=penpot-devenv
|
||||
export PENPOT_ASSETS_STORAGE_BACKEND=assets-fs
|
||||
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
|
||||
export PENPOT_STORAGE_ASSETS_S3_REGION=eu-central-1
|
||||
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
|
||||
|
||||
export OPTIONS="
|
||||
-A:dev:jmx-remote \
|
||||
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
-J-Dlog4j2.configurationFile=log4j2-devenv.xml \
|
||||
-J-XX:+UseG1GC \
|
||||
-J-XX:-OmitStackTraceInFastThrow \
|
||||
-J-Xms50m -J-Xmx1024m \
|
||||
-J-Djdk.attach.allowAttachSelf \
|
||||
-J-XX:+UnlockDiagnosticVMOptions \
|
||||
-J-XX:+DebugNonSafepoints";
|
||||
|
||||
export OPTIONS_EVAL="nil"
|
||||
# export OPTIONS_EVAL="(set! *warn-on-reflection* true)"
|
||||
|
||||
@@ -17,4 +17,4 @@ if [ -f ./environ ]; then
|
||||
fi
|
||||
|
||||
set -x
|
||||
exec $JAVA_CMD $JVM_OPTS -classpath "$(cat classpath)" -Dlog4j2.configurationFile=./log4j2.xml "$@" clojure.main -m app.main
|
||||
exec $JAVA_CMD $JVM_OPTS "$@" -jar penpot.jar -m app.main
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_ASSERTS_ENABLED=true
|
||||
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"
|
||||
|
||||
set -ex
|
||||
|
||||
if [ ! -e ~/.fixtures-loaded ]; then
|
||||
echo "Loading fixtures..."
|
||||
clojure -Adev -X:fn-fixtures
|
||||
touch ~/.fixtures-loaded
|
||||
if [ "$1" = "--watch" ]; then
|
||||
echo "Start Watch..."
|
||||
|
||||
clojure -A:dev -M -m app.main &
|
||||
PID=$!
|
||||
|
||||
npx nodemon \
|
||||
--watch src \
|
||||
--watch ../common \
|
||||
--ext "clj" \
|
||||
--signal SIGKILL \
|
||||
--exec 'echo "(user/restart)" | nc -N localhost 6062'
|
||||
|
||||
kill -9 $PID
|
||||
else
|
||||
clojure -A:dev -M -m app.main
|
||||
fi
|
||||
|
||||
clojure -A:dev -M -m app.main
|
||||
|
||||
|
||||
|
||||
@@ -1,258 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.cli.fixtures
|
||||
"A initial fixtures."
|
||||
(:require
|
||||
[app.common.pages :as cp]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.rpc.mutations.profile :as profile]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.logging :as l]
|
||||
[buddy.hashers :as hashers]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defn- mk-uuid
|
||||
[prefix & args]
|
||||
(uuid/namespaced uuid/zero (apply str prefix (interpose "-" args))))
|
||||
|
||||
;; --- Profiles creation
|
||||
|
||||
(def password (hashers/derive "123123"))
|
||||
|
||||
(def preset-small
|
||||
{:num-teams 5
|
||||
:num-profiles 5
|
||||
:num-profiles-per-team 5
|
||||
:num-projects-per-team 5
|
||||
:num-files-per-project 5
|
||||
:num-draft-files-per-profile 10})
|
||||
|
||||
(defn- rng-ids
|
||||
[rng n max]
|
||||
(let [stream (->> (.longs rng 0 max)
|
||||
(.iterator)
|
||||
(iterator-seq))]
|
||||
(reduce (fn [acc item]
|
||||
(if (= (count acc) n)
|
||||
(reduced acc)
|
||||
(conj acc item)))
|
||||
#{}
|
||||
stream)))
|
||||
|
||||
(defn- rng-vec
|
||||
[rng vdata n]
|
||||
(let [ids (rng-ids rng n (count vdata))]
|
||||
(mapv #(nth vdata %) ids)))
|
||||
|
||||
(defn- rng-nth
|
||||
[rng vdata]
|
||||
(let [stream (->> (.longs rng 0 (count vdata))
|
||||
(.iterator)
|
||||
(iterator-seq))]
|
||||
(nth vdata (first stream))))
|
||||
|
||||
(defn- collect
|
||||
[f items]
|
||||
(reduce #(conj %1 (f %2)) [] items))
|
||||
|
||||
(defn- register-profile
|
||||
[conn params]
|
||||
(->> (#'profile/create-profile conn params)
|
||||
(#'profile/create-profile-relations conn)))
|
||||
|
||||
(defn impl-run
|
||||
[pool opts]
|
||||
(let [rng (java.util.Random. 1)]
|
||||
(letfn [(create-profile [conn index]
|
||||
(let [id (mk-uuid "profile" index)
|
||||
_ (l/info :action "create profile"
|
||||
:index index
|
||||
:id id)
|
||||
|
||||
prof (register-profile conn
|
||||
{:id id
|
||||
:fullname (str "Profile " index)
|
||||
:password "123123"
|
||||
:is-demo true
|
||||
:email (str "profile" index "@example.com")})
|
||||
team-id (:default-team-id prof)
|
||||
owner-id id]
|
||||
(let [project-ids (collect (partial create-project conn team-id owner-id)
|
||||
(range (:num-projects-per-team opts)))]
|
||||
(run! (partial create-files conn owner-id) project-ids))
|
||||
prof))
|
||||
|
||||
(create-profiles [conn]
|
||||
(l/info :action "create profiles")
|
||||
(collect (partial create-profile conn)
|
||||
(range (:num-profiles opts))))
|
||||
|
||||
(create-team [conn index]
|
||||
(let [id (mk-uuid "team" index)
|
||||
name (str "Team" index)]
|
||||
(l/info :action "create team"
|
||||
:index index
|
||||
:id id)
|
||||
(db/insert! conn :team {:id id
|
||||
:name name})
|
||||
id))
|
||||
|
||||
(create-teams [conn]
|
||||
(l/info :action "create teams")
|
||||
(collect (partial create-team conn)
|
||||
(range (:num-teams opts))))
|
||||
|
||||
(create-file [conn owner-id project-id index]
|
||||
(let [id (mk-uuid "file" project-id index)
|
||||
name (str "file" index)
|
||||
data (cp/make-file-data id)]
|
||||
(l/info :action "create file"
|
||||
:index index
|
||||
:id id)
|
||||
(db/insert! conn :file
|
||||
{:id id
|
||||
:data (blob/encode data)
|
||||
:project-id project-id
|
||||
:name name})
|
||||
(db/insert! conn :file-profile-rel
|
||||
{:file-id id
|
||||
:profile-id owner-id
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true})
|
||||
id))
|
||||
|
||||
(create-files [conn owner-id project-id]
|
||||
(l/info :action "create files")
|
||||
(run! (partial create-file conn owner-id project-id)
|
||||
(range (:num-files-per-project opts))))
|
||||
|
||||
(create-project [conn team-id owner-id index]
|
||||
(let [id (if index
|
||||
(mk-uuid "project" team-id index)
|
||||
(mk-uuid "project" team-id))
|
||||
name (if index
|
||||
(str "project " index)
|
||||
"Drafts")
|
||||
is-default (nil? index)]
|
||||
(l/info :action "create project"
|
||||
:index index
|
||||
:id id)
|
||||
(db/insert! conn :project
|
||||
{:id id
|
||||
:team-id team-id
|
||||
:is-default is-default
|
||||
:name name})
|
||||
(db/insert! conn :project-profile-rel
|
||||
{:project-id id
|
||||
:profile-id owner-id
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true})
|
||||
id))
|
||||
|
||||
(create-projects [conn team-id profile-ids]
|
||||
(l/info :action "create projects")
|
||||
(let [owner-id (rng-nth rng profile-ids)
|
||||
project-ids (conj
|
||||
(collect (partial create-project conn team-id owner-id)
|
||||
(range (:num-projects-per-team opts)))
|
||||
(create-project conn team-id owner-id nil))]
|
||||
(run! (partial create-files conn owner-id) project-ids)))
|
||||
|
||||
(assign-profile-to-team [conn team-id owner? profile-id]
|
||||
(db/insert! conn :team-profile-rel
|
||||
{:team-id team-id
|
||||
:profile-id profile-id
|
||||
:is-owner owner?
|
||||
:is-admin true
|
||||
:can-edit true}))
|
||||
|
||||
(setup-team [conn team-id profile-ids]
|
||||
(l/info :action "setup team"
|
||||
:team-id team-id
|
||||
:profile-ids (pr-str profile-ids))
|
||||
(assign-profile-to-team conn team-id true (first profile-ids))
|
||||
(run! (partial assign-profile-to-team conn team-id false)
|
||||
(rest profile-ids))
|
||||
(create-projects conn team-id profile-ids))
|
||||
|
||||
(assign-teams-and-profiles [conn teams profiles]
|
||||
(l/info :action "assign teams and profiles")
|
||||
(loop [team-id (first teams)
|
||||
teams (rest teams)]
|
||||
(when-not (nil? team-id)
|
||||
(let [n-profiles-team (:num-profiles-per-team opts)
|
||||
selected-profiles (rng-vec rng profiles n-profiles-team)]
|
||||
(setup-team conn team-id selected-profiles)
|
||||
(recur (first teams)
|
||||
(rest teams))))))
|
||||
|
||||
(create-draft-file [conn owner index]
|
||||
(let [owner-id (:id owner)
|
||||
id (mk-uuid "file" "draft" owner-id index)
|
||||
name (str "file" index)
|
||||
project-id (:default-project-id owner)
|
||||
data (cp/make-file-data id)]
|
||||
|
||||
(l/info :action "create draft file"
|
||||
:index index
|
||||
:id id)
|
||||
(db/insert! conn :file
|
||||
{:id id
|
||||
:data (blob/encode data)
|
||||
:project-id project-id
|
||||
:name name})
|
||||
(db/insert! conn :file-profile-rel
|
||||
{:file-id id
|
||||
:profile-id owner-id
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true})
|
||||
id))
|
||||
|
||||
(create-draft-files [conn profile]
|
||||
(run! (partial create-draft-file conn profile)
|
||||
(range (:num-draft-files-per-profile opts))))
|
||||
]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [profiles (create-profiles conn)
|
||||
teams (create-teams conn)]
|
||||
(assign-teams-and-profiles conn teams (map :id profiles))
|
||||
(run! (partial create-draft-files conn) profiles))))))
|
||||
|
||||
(defn run-in-system
|
||||
[system preset]
|
||||
(let [pool (:app.db/pool system)
|
||||
preset (if (map? preset)
|
||||
preset
|
||||
(case preset
|
||||
(nil "small" :small) preset-small
|
||||
;; "medium" preset-medium
|
||||
;; "big" preset-big
|
||||
preset-small))]
|
||||
(impl-run pool preset)))
|
||||
|
||||
(defn run
|
||||
[{:keys [preset] :or {preset :small}}]
|
||||
(let [config (select-keys main/system-config
|
||||
[:app.db/pool
|
||||
:app.telemetry/migrations
|
||||
:app.migrations/migrations
|
||||
:app.migrations/all
|
||||
:app.metrics/metrics])
|
||||
_ (ig/load-namespaces config)
|
||||
system (-> (ig/prep config)
|
||||
(ig/init))]
|
||||
(try
|
||||
(run-in-system system preset)
|
||||
(catch Exception e
|
||||
(l/error :hint "unhandled exception" :cause e))
|
||||
(finally
|
||||
(ig/halt! system)))))
|
||||
@@ -7,11 +7,11 @@
|
||||
(ns app.cli.manage
|
||||
"A manage cli api."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.rpc.mutations.profile :as profile]
|
||||
[app.rpc.queries.profile :refer [retrieve-profile-data-by-email]]
|
||||
[app.util.logging :as l]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.cli :refer [parse-opts]]
|
||||
[integrant.core :as ig])
|
||||
@@ -140,7 +140,6 @@
|
||||
indicating the action the program should take and the options provided."
|
||||
[args]
|
||||
(let [{:keys [options arguments errors summary] :as opts} (parse-opts args cli-options)]
|
||||
;; (pp/pprint opts)
|
||||
(cond
|
||||
(:help options) ; help => exit OK with usage summary
|
||||
{:exit-message (usage summary) :ok? true}
|
||||
|
||||
@@ -1,129 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.cli.migrate-media
|
||||
(:require
|
||||
[app.common.media :as cm]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.storage :as sto]
|
||||
[app.util.logging :as l]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.core :as fs]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(declare migrate-profiles)
|
||||
(declare migrate-teams)
|
||||
(declare migrate-file-media)
|
||||
|
||||
(defn run-in-system
|
||||
[system]
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(let [system (assoc system ::conn conn)]
|
||||
(migrate-profiles system)
|
||||
(migrate-teams system)
|
||||
(migrate-file-media system))
|
||||
system))
|
||||
|
||||
(defn run
|
||||
[]
|
||||
(let [config (select-keys main/system-config
|
||||
[:app.db/pool
|
||||
:app.migrations/migrations
|
||||
:app.metrics/metrics
|
||||
:app.storage.s3/backend
|
||||
:app.storage.db/backend
|
||||
:app.storage.fs/backend
|
||||
:app.storage/storage])]
|
||||
(ig/load-namespaces config)
|
||||
(try
|
||||
(-> (ig/prep config)
|
||||
(ig/init)
|
||||
(run-in-system)
|
||||
(ig/halt!))
|
||||
(catch Exception e
|
||||
(l/error :hint "unhandled exception" :cause e)))))
|
||||
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
(defn migrate-profiles
|
||||
[{:keys [::conn] :as system}]
|
||||
(letfn [(retrieve-profiles [conn]
|
||||
(->> (db/exec! conn ["select * from profile"])
|
||||
(filter #(not (str/empty? (:photo %))))
|
||||
(seq)))]
|
||||
(let [base (fs/path (cf/get :storage-fs-old-directory))
|
||||
storage (-> (:app.storage/storage system)
|
||||
(assoc :conn conn))]
|
||||
(doseq [profile (retrieve-profiles conn)]
|
||||
(let [path (fs/path (:photo profile))
|
||||
full (-> (fs/join base path)
|
||||
(fs/normalize))
|
||||
ext (fs/ext path)
|
||||
mtype (cm/format->mtype (keyword ext))
|
||||
obj (sto/put-object storage {:content (sto/content full)
|
||||
:content-type mtype})]
|
||||
(db/update! conn :profile
|
||||
{:photo-id (:id obj)}
|
||||
{:id (:id profile)}))))))
|
||||
|
||||
(defn migrate-teams
|
||||
[{:keys [::conn] :as system}]
|
||||
(letfn [(retrieve-teams [conn]
|
||||
(->> (db/exec! conn ["select * from team"])
|
||||
(filter #(not (str/empty? (:photo %))))
|
||||
(seq)))]
|
||||
(let [base (fs/path (cf/get :storage-fs-old-directory))
|
||||
storage (-> (:app.storage/storage system)
|
||||
(assoc :conn conn))]
|
||||
(doseq [team (retrieve-teams conn)]
|
||||
(let [path (fs/path (:photo team))
|
||||
full (-> (fs/join base path)
|
||||
(fs/normalize))
|
||||
ext (fs/ext path)
|
||||
mtype (cm/format->mtype (keyword ext))
|
||||
obj (sto/put-object storage {:content (sto/content full)
|
||||
:content-type mtype})]
|
||||
(db/update! conn :team
|
||||
{:photo-id (:id obj)}
|
||||
{:id (:id team)}))))))
|
||||
|
||||
|
||||
|
||||
(defn migrate-file-media
|
||||
[{:keys [::conn] :as system}]
|
||||
(letfn [(retrieve-media-objects [conn]
|
||||
(->> (db/exec! conn ["select fmo.id, fmo.path, fth.path as thumbnail_path
|
||||
from file_media_object as fmo
|
||||
join file_media_thumbnail as fth on (fth.media_object_id = fmo.id)"])
|
||||
(seq)))]
|
||||
(let [base (fs/path (cf/get :storage-fs-old-directory))
|
||||
storage (-> (:app.storage/storage system)
|
||||
(assoc :conn conn))]
|
||||
(doseq [mobj (retrieve-media-objects conn)]
|
||||
(let [img-path (fs/path (:path mobj))
|
||||
thm-path (fs/path (:thumbnail-path mobj))
|
||||
img-path (-> (fs/join base img-path)
|
||||
(fs/normalize))
|
||||
thm-path (-> (fs/join base thm-path)
|
||||
(fs/normalize))
|
||||
img-ext (fs/ext img-path)
|
||||
thm-ext (fs/ext thm-path)
|
||||
|
||||
img-mtype (cm/format->mtype (keyword img-ext))
|
||||
thm-mtype (cm/format->mtype (keyword thm-ext))
|
||||
|
||||
img-obj (sto/put-object storage {:content (sto/content img-path)
|
||||
:content-type img-mtype})
|
||||
thm-obj (sto/put-object storage {:content (sto/content thm-path)
|
||||
:content-type thm-mtype})]
|
||||
|
||||
(db/update! conn :file-media-object
|
||||
{:media-id (:id img-obj)
|
||||
:thumbnail-id (:id thm-obj)}
|
||||
{:id (:id mobj)}))))))
|
||||
@@ -10,6 +10,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.flags :as flags]
|
||||
[app.common.spec :as us]
|
||||
[app.common.version :as v]
|
||||
[app.util.time :as dt]
|
||||
@@ -40,36 +41,29 @@
|
||||
data))
|
||||
|
||||
(def defaults
|
||||
{:http-server-port 6060
|
||||
:host "devenv"
|
||||
:tenant "dev"
|
||||
{
|
||||
:database-uri "postgresql://postgres/penpot"
|
||||
:database-username "penpot"
|
||||
:database-password "penpot"
|
||||
|
||||
:default-blob-version 3
|
||||
:default-blob-version 4
|
||||
:loggers-zmq-uri "tcp://localhost:45556"
|
||||
|
||||
:asserts-enabled false
|
||||
:file-change-snapshot-every 5
|
||||
:file-change-snapshot-timeout "3h"
|
||||
|
||||
:public-uri "http://localhost:3449"
|
||||
:redis-uri "redis://redis/0"
|
||||
:host "localhost"
|
||||
:tenant "main"
|
||||
|
||||
:redis-uri "redis://redis/0"
|
||||
:srepl-host "127.0.0.1"
|
||||
:srepl-port 6062
|
||||
|
||||
:assets-storage-backend :assets-fs
|
||||
:storage-assets-fs-directory "assets"
|
||||
|
||||
:feedback-destination "info@example.com"
|
||||
:feedback-enabled false
|
||||
|
||||
:assets-path "/internal/assets/"
|
||||
|
||||
:rlimits-password 10
|
||||
:rlimits-image 2
|
||||
|
||||
:smtp-enabled false
|
||||
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
||||
:smtp-default-from "Penpot <no-reply@example.com>"
|
||||
|
||||
@@ -79,10 +73,6 @@
|
||||
:profile-bounce-max-age (dt/duration {:days 7})
|
||||
:profile-bounce-threshold 10
|
||||
|
||||
:allow-demo-users true
|
||||
:registration-enabled true
|
||||
|
||||
:telemetry-enabled false
|
||||
:telemetry-uri "https://telemetry.penpot.app/"
|
||||
|
||||
:ldap-user-query "(|(uid=:username)(mail=:username))"
|
||||
@@ -92,27 +82,42 @@
|
||||
:ldap-attrs-photo "jpegPhoto"
|
||||
|
||||
;; a server prop key where initial project is stored.
|
||||
:initial-project-skey "initial-project"
|
||||
})
|
||||
:initial-project-skey "initial-project"})
|
||||
|
||||
(s/def ::audit-enabled ::us/boolean)
|
||||
(s/def ::audit-archive-enabled ::us/boolean)
|
||||
(s/def ::audit-archive-uri ::us/string)
|
||||
(s/def ::audit-archive-gc-enabled ::us/boolean)
|
||||
(s/def ::audit-archive-gc-max-age ::dt/duration)
|
||||
(s/def ::flags ::us/set-of-keywords)
|
||||
|
||||
;; DEPRECATED PROPERTIES
|
||||
(s/def ::registration-enabled ::us/boolean)
|
||||
(s/def ::smtp-enabled ::us/boolean)
|
||||
(s/def ::telemetry-enabled ::us/boolean)
|
||||
(s/def ::asserts-enabled ::us/boolean)
|
||||
;; END DEPRECATED
|
||||
|
||||
(s/def ::audit-log-archive-uri ::us/string)
|
||||
(s/def ::audit-log-gc-max-age ::dt/duration)
|
||||
|
||||
(s/def ::admins ::us/set-of-str)
|
||||
(s/def ::file-change-snapshot-every ::us/integer)
|
||||
(s/def ::file-change-snapshot-timeout ::dt/duration)
|
||||
|
||||
(s/def ::default-executor-parallelism ::us/integer)
|
||||
(s/def ::blocking-executor-parallelism ::us/integer)
|
||||
(s/def ::worker-executor-parallelism ::us/integer)
|
||||
|
||||
(s/def ::secret-key ::us/string)
|
||||
(s/def ::allow-demo-users ::us/boolean)
|
||||
(s/def ::asserts-enabled ::us/boolean)
|
||||
(s/def ::assets-path ::us/string)
|
||||
(s/def ::authenticated-cookie-domain ::us/string)
|
||||
(s/def ::database-password (s/nilable ::us/string))
|
||||
(s/def ::database-uri ::us/string)
|
||||
(s/def ::database-username (s/nilable ::us/string))
|
||||
(s/def ::database-readonly ::us/boolean)
|
||||
(s/def ::database-min-pool-size ::us/integer)
|
||||
(s/def ::database-max-pool-size ::us/integer)
|
||||
|
||||
(s/def ::default-blob-version ::us/integer)
|
||||
(s/def ::error-report-webhook ::us/string)
|
||||
(s/def ::feedback-destination ::us/string)
|
||||
(s/def ::feedback-enabled ::us/boolean)
|
||||
(s/def ::feedback-token ::us/string)
|
||||
(s/def ::user-feedback-destination ::us/string)
|
||||
(s/def ::github-client-id ::us/string)
|
||||
(s/def ::github-client-secret ::us/string)
|
||||
(s/def ::gitlab-base-uri ::us/string)
|
||||
@@ -129,8 +134,15 @@
|
||||
(s/def ::oidc-scopes ::us/set-of-str)
|
||||
(s/def ::oidc-roles ::us/set-of-str)
|
||||
(s/def ::oidc-roles-attr ::us/keyword)
|
||||
(s/def ::oidc-email-attr ::us/keyword)
|
||||
(s/def ::oidc-name-attr ::us/keyword)
|
||||
(s/def ::host ::us/string)
|
||||
(s/def ::http-server-port ::us/integer)
|
||||
(s/def ::http-server-host ::us/string)
|
||||
(s/def ::http-server-max-body-size ::us/integer)
|
||||
(s/def ::http-server-max-multipart-body-size ::us/integer)
|
||||
(s/def ::http-server-io-threads ::us/integer)
|
||||
(s/def ::http-server-worker-threads ::us/integer)
|
||||
(s/def ::http-session-idle-max-age ::dt/duration)
|
||||
(s/def ::http-session-updater-batch-max-age ::dt/duration)
|
||||
(s/def ::http-session-updater-batch-max-size ::us/integer)
|
||||
@@ -158,12 +170,12 @@
|
||||
(s/def ::public-uri ::us/string)
|
||||
(s/def ::redis-uri ::us/string)
|
||||
(s/def ::registration-domain-whitelist ::us/set-of-str)
|
||||
(s/def ::registration-enabled ::us/boolean)
|
||||
(s/def ::rlimits-image ::us/integer)
|
||||
(s/def ::rlimits-password ::us/integer)
|
||||
(s/def ::rlimit-font ::us/integer)
|
||||
(s/def ::rlimit-file-update ::us/integer)
|
||||
(s/def ::rlimit-image ::us/integer)
|
||||
(s/def ::rlimit-password ::us/integer)
|
||||
(s/def ::smtp-default-from ::us/string)
|
||||
(s/def ::smtp-default-reply-to ::us/string)
|
||||
(s/def ::smtp-enabled ::us/boolean)
|
||||
(s/def ::smtp-host ::us/string)
|
||||
(s/def ::smtp-password (s/nilable ::us/string))
|
||||
(s/def ::smtp-port ::us/integer)
|
||||
@@ -177,31 +189,42 @@
|
||||
(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 ::telemetry-enabled ::us/boolean)
|
||||
(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)
|
||||
|
||||
(s/def ::sentry-trace-sample-rate ::us/number)
|
||||
(s/def ::sentry-attach-stack-trace ::us/boolean)
|
||||
(s/def ::sentry-debug ::us/boolean)
|
||||
(s/def ::sentry-dsn ::us/string)
|
||||
|
||||
(s/def ::config
|
||||
(s/keys :opt-un [::secret-key
|
||||
::flags
|
||||
::admins
|
||||
::allow-demo-users
|
||||
::audit-enabled
|
||||
::audit-archive-enabled
|
||||
::audit-archive-uri
|
||||
::audit-archive-gc-enabled
|
||||
::audit-archive-gc-max-age
|
||||
::asserts-enabled
|
||||
::audit-log-archive-uri
|
||||
::audit-log-gc-max-age
|
||||
::authenticated-cookie-domain
|
||||
::database-password
|
||||
::database-uri
|
||||
::database-username
|
||||
::database-readonly
|
||||
::database-min-pool-size
|
||||
::database-max-pool-size
|
||||
::default-blob-version
|
||||
::error-report-webhook
|
||||
::feedback-destination
|
||||
::feedback-enabled
|
||||
::feedback-token
|
||||
::default-executor-parallelism
|
||||
::blocking-executor-parallelism
|
||||
::worker-executor-parallelism
|
||||
::file-change-snapshot-every
|
||||
::file-change-snapshot-timeout
|
||||
::user-feedback-destination
|
||||
::github-client-id
|
||||
::github-client-secret
|
||||
::gitlab-base-uri
|
||||
@@ -217,9 +240,16 @@
|
||||
::oidc-user-uri
|
||||
::oidc-scopes
|
||||
::oidc-roles-attr
|
||||
::oidc-email-attr
|
||||
::oidc-name-attr
|
||||
::oidc-roles
|
||||
::host
|
||||
::http-server-host
|
||||
::http-server-port
|
||||
::http-server-max-body-size
|
||||
::http-server-max-multipart-body-size
|
||||
::http-server-io-threads
|
||||
::http-server-worker-threads
|
||||
::http-session-idle-max-age
|
||||
::http-session-updater-batch-max-age
|
||||
::http-session-updater-batch-max-size
|
||||
@@ -247,8 +277,14 @@
|
||||
::redis-uri
|
||||
::registration-domain-whitelist
|
||||
::registration-enabled
|
||||
::rlimits-image
|
||||
::rlimits-password
|
||||
::rlimit-font
|
||||
::rlimit-file-update
|
||||
::rlimit-image
|
||||
::rlimit-password
|
||||
::sentry-dsn
|
||||
::sentry-debug
|
||||
::sentry-attach-stack-trace
|
||||
::sentry-trace-sample-rate
|
||||
::smtp-default-from
|
||||
::smtp-default-reply-to
|
||||
::smtp-enabled
|
||||
@@ -258,26 +294,34 @@
|
||||
::smtp-ssl
|
||||
::smtp-tls
|
||||
::smtp-username
|
||||
|
||||
::srepl-host
|
||||
::srepl-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
|
||||
::telemetry-with-taiga
|
||||
::tenant]))
|
||||
|
||||
(def default-flags
|
||||
[:enable-backend-api-doc
|
||||
:enable-secure-session-cookies])
|
||||
|
||||
(defn- parse-flags
|
||||
[config]
|
||||
(flags/parse flags/default
|
||||
default-flags
|
||||
(:flags config)))
|
||||
|
||||
(defn read-env
|
||||
[prefix]
|
||||
(let [prefix (str prefix "-")
|
||||
@@ -300,15 +344,18 @@
|
||||
(when (ex/ex-info? e)
|
||||
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;")
|
||||
(println "Error on validating configuration:")
|
||||
(println (:explain (ex-data e))
|
||||
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;")))
|
||||
(println (us/pretty-explain (ex-data e)))
|
||||
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"))
|
||||
(throw e))))
|
||||
|
||||
(def version (v/parse (or (some-> (io/resource "version.txt")
|
||||
(slurp)
|
||||
(str/trim))
|
||||
"%version%")))
|
||||
(def config (atom (read-config)))
|
||||
(def version
|
||||
(v/parse (or (some-> (io/resource "version.txt")
|
||||
(slurp)
|
||||
(str/trim))
|
||||
"%version%")))
|
||||
|
||||
(def ^:dynamic config (read-config))
|
||||
(def ^:dynamic flags (parse-flags config))
|
||||
|
||||
(def deletion-delay
|
||||
(dt/duration {:days 7}))
|
||||
@@ -316,9 +363,9 @@
|
||||
(defn get
|
||||
"A configuration getter. Helps code be more testable."
|
||||
([key]
|
||||
(c/get @config key))
|
||||
(c/get config key))
|
||||
([key default]
|
||||
(c/get @config key default)))
|
||||
(c/get config key default)))
|
||||
|
||||
;; Set value for all new threads bindings.
|
||||
(alter-var-root #'*assert* (constantly (get :asserts-enabled)))
|
||||
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db.sql :as sql]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.json :as json]
|
||||
[app.util.logging :as l]
|
||||
[app.util.migrations :as mg]
|
||||
[app.util.time :as dt]
|
||||
[clojure.java.io :as io]
|
||||
@@ -27,14 +27,16 @@
|
||||
com.zaxxer.hikari.HikariConfig
|
||||
com.zaxxer.hikari.HikariDataSource
|
||||
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.lang.AutoCloseable
|
||||
java.sql.Connection
|
||||
java.sql.Savepoint
|
||||
org.postgresql.PGConnection
|
||||
org.postgresql.geometric.PGpoint
|
||||
org.postgresql.jdbc.PgArray
|
||||
org.postgresql.largeobject.LargeObject
|
||||
org.postgresql.largeobject.LargeObjectManager
|
||||
org.postgresql.jdbc.PgArray
|
||||
org.postgresql.util.PGInterval
|
||||
org.postgresql.util.PGobject))
|
||||
|
||||
@@ -45,76 +47,104 @@
|
||||
;; Initialization
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare instrument-jdbc!)
|
||||
(declare apply-migrations!)
|
||||
|
||||
(s/def ::name keyword?)
|
||||
(s/def ::uri ::us/not-empty-string)
|
||||
(s/def ::min-pool-size ::us/integer)
|
||||
(s/def ::max-pool-size ::us/integer)
|
||||
(s/def ::connection-timeout ::us/integer)
|
||||
(s/def ::max-size ::us/integer)
|
||||
(s/def ::min-size ::us/integer)
|
||||
(s/def ::migrations map?)
|
||||
(s/def ::name keyword?)
|
||||
(s/def ::password ::us/string)
|
||||
(s/def ::read-only ::us/boolean)
|
||||
(s/def ::uri ::us/not-empty-string)
|
||||
(s/def ::username ::us/string)
|
||||
(s/def ::validation-timeout ::us/integer)
|
||||
|
||||
(defmethod ig/pre-init-spec ::pool [_]
|
||||
(s/keys :req-un [::uri ::name ::min-pool-size ::max-pool-size ::migrations ::mtx/metrics]))
|
||||
(s/keys :req-un [::uri ::name
|
||||
::min-size
|
||||
::max-size
|
||||
::connection-timeout
|
||||
::validation-timeout]
|
||||
:opt-un [::migrations
|
||||
::username
|
||||
::password
|
||||
::mtx/metrics
|
||||
::read-only]))
|
||||
|
||||
(defmethod ig/prep-key ::pool
|
||||
[_ cfg]
|
||||
(merge {:name :main
|
||||
:min-size 0
|
||||
:max-size 30
|
||||
:connection-timeout 10000
|
||||
:validation-timeout 10000
|
||||
:idle-timeout 120000 ; 2min
|
||||
:max-lifetime 1800000 ; 30m
|
||||
:read-only false}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(defmethod ig/init-key ::pool
|
||||
[_ {:keys [migrations metrics] :as cfg}]
|
||||
(l/info :action "initialize connection pool"
|
||||
:name (d/name (:name cfg))
|
||||
:uri (:uri cfg))
|
||||
(instrument-jdbc! (:registry metrics))
|
||||
[_ {:keys [migrations name read-only] :as cfg}]
|
||||
(l/info :hint "initialize connection pool"
|
||||
:name (d/name name)
|
||||
:uri (:uri cfg)
|
||||
:read-only read-only
|
||||
:with-credentials (and (contains? cfg :username)
|
||||
(contains? cfg :password))
|
||||
:min-size (:min-size cfg)
|
||||
:max-size (:max-size cfg))
|
||||
|
||||
(let [pool (create-pool cfg)]
|
||||
(when (seq migrations)
|
||||
(with-open [conn ^AutoCloseable (open pool)]
|
||||
(mg/setup! conn)
|
||||
(doseq [[name steps] migrations]
|
||||
(mg/migrate! conn {:name (d/name name) :steps steps}))))
|
||||
(when-not read-only
|
||||
(some->> (seq migrations) (apply-migrations! pool)))
|
||||
pool))
|
||||
|
||||
(defmethod ig/halt-key! ::pool
|
||||
[_ pool]
|
||||
(.close ^HikariDataSource pool))
|
||||
|
||||
(defn- instrument-jdbc!
|
||||
[registry]
|
||||
(mtx/instrument-vars!
|
||||
[#'next.jdbc/execute-one!
|
||||
#'next.jdbc/execute!]
|
||||
{:registry registry
|
||||
:type :counter
|
||||
:name "database_query_total"
|
||||
:help "An absolute counter of database queries."}))
|
||||
(defn- apply-migrations!
|
||||
[pool migrations]
|
||||
(with-open [conn ^AutoCloseable (open pool)]
|
||||
(mg/setup! conn)
|
||||
(doseq [[name steps] migrations]
|
||||
(mg/migrate! conn {:name (d/name name) :steps steps}))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; API & Impl
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def initsql
|
||||
(str "SET statement_timeout = 120000;\n"
|
||||
"SET idle_in_transaction_session_timeout = 120000;"))
|
||||
(str "SET statement_timeout = 300000;\n"
|
||||
"SET idle_in_transaction_session_timeout = 300000;"))
|
||||
|
||||
(defn- create-datasource-config
|
||||
[{:keys [metrics] :as cfg}]
|
||||
(let [dburi (:uri cfg)
|
||||
username (:username cfg)
|
||||
password (:password cfg)
|
||||
config (HikariConfig.)
|
||||
mtf (PrometheusMetricsTrackerFactory. (:registry metrics))]
|
||||
[{:keys [metrics uri] :as cfg}]
|
||||
(let [config (HikariConfig.)]
|
||||
(doto config
|
||||
(.setJdbcUrl (str "jdbc:" dburi))
|
||||
(.setPoolName (d/name (:name cfg)))
|
||||
(.setJdbcUrl (str "jdbc:" uri))
|
||||
(.setPoolName (d/name (:name cfg)))
|
||||
(.setAutoCommit true)
|
||||
(.setReadOnly false)
|
||||
(.setConnectionTimeout 8000) ;; 8seg
|
||||
(.setValidationTimeout 8000) ;; 8seg
|
||||
(.setIdleTimeout 120000) ;; 2min
|
||||
(.setMaxLifetime 1800000) ;; 30min
|
||||
(.setMinimumIdle (:min-pool-size cfg 0))
|
||||
(.setMaximumPoolSize (:max-pool-size cfg 30))
|
||||
(.setMetricsTrackerFactory mtf)
|
||||
(.setReadOnly (:read-only cfg))
|
||||
(.setConnectionTimeout (:connection-timeout cfg))
|
||||
(.setValidationTimeout (:validation-timeout cfg))
|
||||
(.setIdleTimeout (:idle-timeout cfg))
|
||||
(.setMaxLifetime (:max-lifetime cfg))
|
||||
(.setMinimumIdle (:min-size cfg))
|
||||
(.setMaximumPoolSize (:max-size cfg))
|
||||
(.setConnectionInitSql initsql)
|
||||
(.setInitializationFailTimeout -1))
|
||||
(when username (.setUsername config username))
|
||||
(when password (.setPassword config password))
|
||||
|
||||
;; When metrics namespace is provided
|
||||
(when metrics
|
||||
(->> (:registry metrics)
|
||||
(PrometheusMetricsTrackerFactory.)
|
||||
(.setMetricsTrackerFactory config)))
|
||||
|
||||
(some->> ^String (:username cfg) (.setUsername config))
|
||||
(some->> ^String (:password cfg) (.setPassword config))
|
||||
|
||||
config))
|
||||
|
||||
(defn pool?
|
||||
@@ -123,11 +153,15 @@
|
||||
|
||||
(s/def ::pool pool?)
|
||||
|
||||
(defn pool-closed?
|
||||
(defn closed?
|
||||
[pool]
|
||||
(.isClosed ^HikariDataSource pool))
|
||||
|
||||
(defn- create-pool
|
||||
(defn read-only?
|
||||
[pool]
|
||||
(.isReadOnly ^HikariDataSource pool))
|
||||
|
||||
(defn create-pool
|
||||
[cfg]
|
||||
(let [dsc (create-datasource-config cfg)]
|
||||
(jdbc-dt/read-as-instant)
|
||||
@@ -199,21 +233,21 @@
|
||||
([ds table params opts]
|
||||
(exec-one! ds
|
||||
(sql/insert table params opts)
|
||||
(assoc opts :return-keys true))))
|
||||
(merge {:return-keys true} opts))))
|
||||
|
||||
(defn insert-multi!
|
||||
([ds table cols rows] (insert-multi! ds table cols rows nil))
|
||||
([ds table cols rows opts]
|
||||
(exec! ds
|
||||
(sql/insert-multi table cols rows opts)
|
||||
(assoc opts :return-keys true))))
|
||||
(merge {:return-keys true} opts))))
|
||||
|
||||
(defn update!
|
||||
([ds table params where] (update! ds table params where nil))
|
||||
([ds table params where opts]
|
||||
(exec-one! ds
|
||||
(sql/update table params where opts)
|
||||
(assoc opts :return-keys true))))
|
||||
(merge {:return-keys true} opts))))
|
||||
|
||||
(defn delete!
|
||||
([ds table params] (delete! ds table params nil))
|
||||
@@ -231,9 +265,9 @@
|
||||
(defn get-by-params
|
||||
([ds table params]
|
||||
(get-by-params ds table params nil))
|
||||
([ds table params {:keys [uncheked] :or {uncheked false} :as opts}]
|
||||
([ds table params {:keys [check-not-found] :or {check-not-found true} :as opts}]
|
||||
(let [res (exec-one! ds (sql/select table params opts))]
|
||||
(when (and (not uncheked) (or (not res) (is-deleted? res)))
|
||||
(when (and check-not-found (or (not res) (is-deleted? res)))
|
||||
(ex/raise :type :not-found
|
||||
:table table
|
||||
:hint "database object not found"))
|
||||
@@ -267,13 +301,28 @@
|
||||
(instance? PGpoint v))
|
||||
|
||||
(defn pgarray?
|
||||
[v]
|
||||
(instance? PgArray v))
|
||||
([v] (instance? PgArray v))
|
||||
([v type]
|
||||
(and (instance? PgArray v)
|
||||
(= type (.getBaseTypeName ^PgArray v)))))
|
||||
|
||||
(defn pgarray-of-uuid?
|
||||
[v]
|
||||
(and (pgarray? v) (= "uuid" (.getBaseTypeName ^PgArray v))))
|
||||
|
||||
(defn decode-pgarray
|
||||
([v] (into [] (.getArray ^PgArray v)))
|
||||
([v in] (into in (.getArray ^PgArray v)))
|
||||
([v in xf] (into in xf (.getArray ^PgArray v))))
|
||||
|
||||
(defn pgarray->set
|
||||
[v]
|
||||
(set (.getArray ^PgArray v)))
|
||||
|
||||
(defn pgarray->vector
|
||||
[v]
|
||||
(vec (.getArray ^PgArray v)))
|
||||
|
||||
(defn pgpoint
|
||||
[p]
|
||||
(PGpoint. (:x p) (:y p)))
|
||||
@@ -285,7 +334,6 @@
|
||||
(.createArrayOf conn ^String type (into-array Object objects))
|
||||
(.createArrayOf conn ^String type objects))))
|
||||
|
||||
|
||||
(defn decode-pgpoint
|
||||
[^PGpoint v]
|
||||
(gpt/point (.-x v) (.-y v)))
|
||||
@@ -331,7 +379,7 @@
|
||||
val (.getValue o)]
|
||||
(if (or (= typ "json")
|
||||
(= typ "jsonb"))
|
||||
(json/decode-str val)
|
||||
(json/read val)
|
||||
val)))
|
||||
|
||||
(defn decode-transit-pgobject
|
||||
@@ -367,16 +415,7 @@
|
||||
[data]
|
||||
(doto (org.postgresql.util.PGobject.)
|
||||
(.setType "jsonb")
|
||||
(.setValue (json/encode-str data))))
|
||||
|
||||
(defn pgarray->set
|
||||
[v]
|
||||
(set (.getArray ^PgArray v)))
|
||||
|
||||
(defn pgarray->vector
|
||||
[v]
|
||||
(vec (.getArray ^PgArray v)))
|
||||
|
||||
(.setValue (json/write-str data))))
|
||||
|
||||
;; --- Locks
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
(ns app.emails
|
||||
"Main api for send emails."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cfg]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.util.emails :as emails]
|
||||
[app.util.logging :as l]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
@@ -54,10 +55,10 @@
|
||||
(defn allow-send-emails?
|
||||
[conn profile]
|
||||
(when-not (:is-muted profile false)
|
||||
(let [complaint-threshold (cfg/get :profile-complaint-threshold)
|
||||
complaint-max-age (cfg/get :profile-complaint-max-age)
|
||||
bounce-threshold (cfg/get :profile-bounce-threshold)
|
||||
bounce-max-age (cfg/get :profile-bounce-max-age)
|
||||
(let [complaint-threshold (cf/get :profile-complaint-threshold)
|
||||
complaint-max-age (cf/get :profile-complaint-max-age)
|
||||
bounce-threshold (cf/get :profile-bounce-threshold)
|
||||
bounce-max-age (cf/get :profile-bounce-max-age)
|
||||
|
||||
{:keys [complaints bounces] :as result}
|
||||
(db/exec-one! conn [sql:profile-complaint-report
|
||||
@@ -66,8 +67,8 @@
|
||||
(:id profile)
|
||||
(db/interval bounce-max-age)])]
|
||||
|
||||
(and (< complaints complaint-threshold)
|
||||
(< bounces bounce-threshold)))))
|
||||
(and (< (or complaints 0) complaint-threshold)
|
||||
(< (or bounces 0) bounce-threshold)))))
|
||||
|
||||
(defn has-complaint-reports?
|
||||
([conn email] (has-complaint-reports? conn email nil))
|
||||
@@ -140,19 +141,17 @@
|
||||
|
||||
(declare send-console!)
|
||||
|
||||
(s/def ::username ::cfg/smtp-username)
|
||||
(s/def ::password ::cfg/smtp-password)
|
||||
(s/def ::tls ::cfg/smtp-tls)
|
||||
(s/def ::ssl ::cfg/smtp-ssl)
|
||||
(s/def ::host ::cfg/smtp-host)
|
||||
(s/def ::port ::cfg/smtp-port)
|
||||
(s/def ::default-reply-to ::cfg/smtp-default-reply-to)
|
||||
(s/def ::default-from ::cfg/smtp-default-from)
|
||||
(s/def ::enabled ::cfg/smtp-enabled)
|
||||
(s/def ::username ::cf/smtp-username)
|
||||
(s/def ::password ::cf/smtp-password)
|
||||
(s/def ::tls ::cf/smtp-tls)
|
||||
(s/def ::ssl ::cf/smtp-ssl)
|
||||
(s/def ::host ::cf/smtp-host)
|
||||
(s/def ::port ::cf/smtp-port)
|
||||
(s/def ::default-reply-to ::cf/smtp-default-reply-to)
|
||||
(s/def ::default-from ::cf/smtp-default-from)
|
||||
|
||||
(defmethod ig/pre-init-spec ::sendmail-handler [_]
|
||||
(s/keys :req-un [::enabled]
|
||||
:opt-un [::username
|
||||
(s/keys :opt-un [::username
|
||||
::password
|
||||
::tls
|
||||
::ssl
|
||||
@@ -164,19 +163,28 @@
|
||||
(defmethod ig/init-key ::sendmail-handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [props] :as task}]
|
||||
(if (:enabled cfg)
|
||||
(emails/send! cfg props)
|
||||
(send-console! cfg props))))
|
||||
(let [enabled? (or (contains? cf/flags :smtp)
|
||||
(cf/get :smtp-enabled)
|
||||
(:enabled task))]
|
||||
(when enabled?
|
||||
(emails/send! cfg props))
|
||||
|
||||
(when (contains? cf/flags :log-emails)
|
||||
(send-console! cfg props)))))
|
||||
|
||||
(defn- send-console!
|
||||
[cfg email]
|
||||
(let [baos (java.io.ByteArrayOutputStream.)
|
||||
mesg (emails/smtp-message cfg email)]
|
||||
(.writeTo mesg baos)
|
||||
(let [out (with-out-str
|
||||
(println "email console dump:")
|
||||
(println "******** start email" (:id email) "**********")
|
||||
(println (.toString baos))
|
||||
(println "******** end email "(:id email) "**********"))]
|
||||
(l/info :email out))))
|
||||
[_ email]
|
||||
(let [body (:body email)
|
||||
out (with-out-str
|
||||
(println "email console dump:")
|
||||
(println "******** start email" (:id email) "**********")
|
||||
(pp/pprint (dissoc email :body))
|
||||
(if (string? body)
|
||||
(println body)
|
||||
(println (->> body
|
||||
(filter #(= "text/plain" (:type %)))
|
||||
(map :content)
|
||||
first)))
|
||||
(println "******** end email" (:id email) "**********"))]
|
||||
(l/info ::l/raw out)))
|
||||
|
||||
|
||||
@@ -7,153 +7,176 @@
|
||||
(ns app.http
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.http.doc :as doc]
|
||||
[app.http.errors :as errors]
|
||||
[app.http.middleware :as middleware]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.logging :as l]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[reitit.ring :as rr]
|
||||
[ring.adapter.jetty9 :as jetty])
|
||||
(:import
|
||||
org.eclipse.jetty.server.Server
|
||||
org.eclipse.jetty.server.handler.ErrorHandler
|
||||
org.eclipse.jetty.server.handler.StatisticsHandler))
|
||||
[reitit.core :as r]
|
||||
[reitit.middleware :as rr]
|
||||
[yetti.adapter :as yt]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(declare router-handler)
|
||||
(declare wrap-router)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP SERVER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::handler fn?)
|
||||
(s/def ::router some?)
|
||||
(s/def ::ws (s/map-of ::us/string fn?))
|
||||
(s/def ::port ::us/integer)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::port integer?)
|
||||
(s/def ::host string?)
|
||||
(s/def ::name string?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::server [_]
|
||||
(s/keys :req-un [::port]
|
||||
:opt-un [::ws ::name ::mtx/metrics ::router ::handler]))
|
||||
(s/def ::max-body-size integer?)
|
||||
(s/def ::max-multipart-body-size integer?)
|
||||
(s/def ::io-threads integer?)
|
||||
(s/def ::worker-threads integer?)
|
||||
|
||||
(defmethod ig/prep-key ::server
|
||||
[_ cfg]
|
||||
(merge {:name "http"} (d/without-nils cfg)))
|
||||
(merge {:name "http"
|
||||
:port 6060
|
||||
:host "0.0.0.0"
|
||||
:max-body-size (* 1024 1024 30) ; 30 MiB
|
||||
:max-multipart-body-size (* 1024 1024 120)} ; 120 MiB
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(defmethod ig/pre-init-spec ::server [_]
|
||||
(s/and
|
||||
(s/keys :req-un [::port ::host ::name ::max-body-size ::max-multipart-body-size]
|
||||
:opt-un [::router ::handler ::io-threads ::worker-threads ::wrk/executor])
|
||||
(fn [cfg]
|
||||
(or (contains? cfg :router)
|
||||
(contains? cfg :handler)))))
|
||||
|
||||
(defmethod ig/init-key ::server
|
||||
[_ {:keys [handler router ws port name metrics] :as opts}]
|
||||
(l/info :msg "starting http server" :port port :name name)
|
||||
(let [pre-start (fn [^Server server]
|
||||
(let [handler (doto (ErrorHandler.)
|
||||
(.setShowStacks true)
|
||||
(.setServer server))]
|
||||
(.setErrorHandler server ^ErrorHandler handler)
|
||||
(when metrics
|
||||
(let [stats (StatisticsHandler.)]
|
||||
(.setHandler ^StatisticsHandler stats (.getHandler server))
|
||||
(.setHandler server stats)
|
||||
(mtx/instrument-jetty! (:registry metrics) stats)))))
|
||||
|
||||
options (merge
|
||||
{:port port
|
||||
:h2c? true
|
||||
:join? false
|
||||
:allow-null-path-info true
|
||||
:configurator pre-start}
|
||||
(when (seq ws)
|
||||
{:websockets ws}))
|
||||
|
||||
handler (cond
|
||||
(fn? handler) handler
|
||||
(some? router) (router-handler router)
|
||||
:else (ex/raise :type :internal
|
||||
:code :invalid-argument
|
||||
:hint "Missing `handler` or `router` option."))
|
||||
|
||||
server (jetty/run-jetty handler options)]
|
||||
(assoc opts :server server)))
|
||||
[_ {:keys [handler router port name host] :as cfg}]
|
||||
(l/info :hint "starting http server" :port port :host host :name name)
|
||||
(let [options {:http/port port
|
||||
:http/host host
|
||||
:http/max-body-size (:max-body-size cfg)
|
||||
:http/max-multipart-body-size (:max-multipart-body-size cfg)
|
||||
:xnio/io-threads (:io-threads cfg)
|
||||
:xnio/worker-threads (:worker-threads cfg)
|
||||
:xnio/dispatch (:executor cfg)
|
||||
:ring/async true}
|
||||
handler (if (some? router)
|
||||
(wrap-router router)
|
||||
handler)
|
||||
server (yt/server handler (d/without-nils options))]
|
||||
(assoc cfg :server (yt/start! server))))
|
||||
|
||||
(defmethod ig/halt-key! ::server
|
||||
[_ {:keys [server name port] :as opts}]
|
||||
(l/info :msg "stoping http server"
|
||||
:name name
|
||||
:port port)
|
||||
(jetty/stop-server server))
|
||||
[_ {:keys [server name port] :as cfg}]
|
||||
(l/info :msg "stoping http server" :name name :port port)
|
||||
(yt/stop! server))
|
||||
|
||||
(defn- router-handler
|
||||
(defn- not-found-handler
|
||||
[_ respond _]
|
||||
(respond (yrs/response 404)))
|
||||
|
||||
(defn- wrap-router
|
||||
[router]
|
||||
(let [handler (rr/ring-handler router
|
||||
(rr/routes
|
||||
(rr/create-resource-handler {:path "/"})
|
||||
(rr/create-default-handler))
|
||||
{:middleware [middleware/server-timing]})]
|
||||
(fn [request]
|
||||
(try
|
||||
(handler request)
|
||||
(catch Throwable e
|
||||
(try
|
||||
(let [cdata (errors/get-error-context request e)]
|
||||
(l/update-thread-context! cdata)
|
||||
(l/error :hint "unhandled exception"
|
||||
:message (ex-message e)
|
||||
:error-id (str (:id cdata))
|
||||
:cause e))
|
||||
{:status 500 :body "internal server error"}
|
||||
(catch Throwable e
|
||||
(l/error :hint "unhandled exception"
|
||||
:message (ex-message e)
|
||||
:cause e)
|
||||
{:status 500 :body "internal server error"})))))))
|
||||
(letfn [(handler [request respond raise]
|
||||
(if-let [match (r/match-by-path router (yrq/path request))]
|
||||
(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))]
|
||||
(handler request respond raise))
|
||||
(not-found-handler request respond raise)))
|
||||
|
||||
(on-error [cause request respond]
|
||||
(let [{:keys [body] :as response} (errors/handle cause request)]
|
||||
(respond
|
||||
(cond-> response
|
||||
(map? body)
|
||||
(-> (update :headers assoc "content-type" "application/transit+json")
|
||||
(assoc :body (t/encode-str body {:type :json-verbose})))))))]
|
||||
|
||||
(fn [request respond _]
|
||||
(try
|
||||
(handler request respond #(on-error % request respond))
|
||||
(catch Throwable cause
|
||||
(on-error cause request respond))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Http Main Handler (Router)
|
||||
;; HTTP ROUTER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::rpc map?)
|
||||
(s/def ::session map?)
|
||||
(s/def ::oauth map?)
|
||||
(s/def ::storage map?)
|
||||
(s/def ::assets map?)
|
||||
(s/def ::feedback fn?)
|
||||
(s/def ::ws fn?)
|
||||
(s/def ::audit-handler fn?)
|
||||
(s/def ::debug map?)
|
||||
(s/def ::awsns-handler fn?)
|
||||
(s/def ::session map?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::router [_]
|
||||
(s/keys :req-un [::rpc ::session ::mtx/metrics ::oauth ::storage ::assets ::feedback]))
|
||||
(s/keys :req-un [::rpc ::mtx/metrics ::ws ::oauth ::storage ::assets
|
||||
::session ::feedback ::awsns-handler ::debug ::audit-handler]))
|
||||
|
||||
(defmethod ig/init-key ::router
|
||||
[_ {:keys [session rpc oauth metrics assets feedback] :as cfg}]
|
||||
[_ {:keys [ws session rpc oauth metrics assets feedback debug] :as cfg}]
|
||||
(rr/router
|
||||
[["/metrics" {:get (:handler metrics)}]
|
||||
["/assets" {:middleware [[middleware/format-response-body]
|
||||
[middleware/errors errors/handle]
|
||||
[middleware/cookies]
|
||||
(:middleware session)]}
|
||||
["/by-id/:id" {:get (:objects-handler assets)}]
|
||||
["/by-file-media-id/:id" {:get (:file-objects-handler assets)}]
|
||||
["/by-file-media-id/:id/thumbnail" {:get (:file-thumbnails-handler assets)}]]
|
||||
[["" {:middleware [[middleware/server-timing]
|
||||
[middleware/format-response]
|
||||
[middleware/params]
|
||||
[middleware/parse-request]
|
||||
[middleware/errors errors/handle]
|
||||
[middleware/restrict-methods]]}
|
||||
["/metrics" {:handler (:handler metrics)}]
|
||||
["/assets" {:middleware [(:middleware session)]}
|
||||
["/by-id/:id" {:handler (:objects-handler assets)}]
|
||||
["/by-file-media-id/:id" {:handler (:file-objects-handler assets)}]
|
||||
["/by-file-media-id/:id/thumbnail" {:handler (:file-thumbnails-handler assets)}]]
|
||||
|
||||
["/dbg"
|
||||
["/error-by-id/:id" {:get (:error-report-handler cfg)}]]
|
||||
["/dbg" {:middleware [(:middleware session)]}
|
||||
["" {:handler (:index debug)}]
|
||||
["/changelog" {:handler (:changelog debug)}]
|
||||
["/error-by-id/:id" {:handler (:retrieve-error debug)}]
|
||||
["/error/:id" {:handler (:retrieve-error debug)}]
|
||||
["/error" {:handler (:retrieve-error-list debug)}]
|
||||
["/file/data" {:handler (:file-data debug)}]
|
||||
["/file/changes" {:handler (:retrieve-file-changes debug)}]]
|
||||
|
||||
["/webhooks"
|
||||
["/sns" {:post (:sns-webhook cfg)}]]
|
||||
["/webhooks"
|
||||
["/sns" {:handler (:awsns-handler cfg)
|
||||
:allowed-methods #{:post}}]]
|
||||
|
||||
["/api" {:middleware [
|
||||
;; Temporary disabled
|
||||
#_[middleware/etag]
|
||||
[middleware/format-response-body]
|
||||
[middleware/params]
|
||||
[middleware/multipart-params]
|
||||
[middleware/keyword-params]
|
||||
[middleware/parse-request-body]
|
||||
[middleware/errors errors/handle]
|
||||
[middleware/cookies]]}
|
||||
["/ws/notifications" {:middleware [(:middleware session)]
|
||||
:handler ws
|
||||
:allowed-methods #{:get}}]
|
||||
|
||||
["/feedback" {:middleware [(:middleware session)]
|
||||
:post feedback}]
|
||||
["/api" {:middleware [[middleware/cors]
|
||||
(:middleware session)]}
|
||||
["/health" {:handler (:health-check debug)}]
|
||||
["/_doc" {:handler (doc/handler rpc)
|
||||
:allowed-methods #{:get}}]
|
||||
["/feedback" {:handler feedback
|
||||
:allowed-methods #{:post}}]
|
||||
|
||||
["/auth/oauth/:provider" {:post (:handler oauth)}]
|
||||
["/auth/oauth/:provider/callback" {:get (:callback-handler oauth)}]
|
||||
["/auth/oauth/:provider" {:handler (:handler oauth)
|
||||
:allowed-methods #{:post}}]
|
||||
["/auth/oauth/:provider/callback" {:handler (:callback-handler oauth)
|
||||
:allowed-methods #{:get}}]
|
||||
|
||||
["/rpc" {:middleware [(:middleware session)]}
|
||||
["/query/:type" {:get (:query-handler rpc)
|
||||
:post (:query-handler rpc)}]
|
||||
["/mutation/:type" {:post (:mutation-handler rpc)}]]]]))
|
||||
["/audit/events" {:handler (:audit-handler cfg)
|
||||
:allowed-methods #{:post}}]
|
||||
|
||||
["/rpc"
|
||||
["/query/:type" {:handler (:query-handler rpc)}]
|
||||
["/mutation/:type" {:handler (:mutation-handler rpc)
|
||||
:allowed-methods #{:post}}]]]]]))
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
[app.metrics :as mtx]
|
||||
[app.storage :as sto]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(def ^:private cache-max-age
|
||||
(dt/duration {:hours 24}))
|
||||
@@ -32,66 +36,83 @@
|
||||
res))
|
||||
|
||||
(defn- get-file-media-object
|
||||
[{:keys [pool] :as storage} id]
|
||||
(let [id (coerce-id id)
|
||||
mobj (db/exec-one! pool ["select * from file_media_object where id=?" id])]
|
||||
(when-not mobj
|
||||
(ex/raise :type :not-found
|
||||
:hint "object does not found"))
|
||||
mobj))
|
||||
[{:keys [pool executor] :as storage} id]
|
||||
(px/with-dispatch executor
|
||||
(let [id (coerce-id id)
|
||||
mobj (db/exec-one! pool ["select * from file_media_object where id=?" id])]
|
||||
(when-not mobj
|
||||
(ex/raise :type :not-found
|
||||
:hint "object does not found"))
|
||||
mobj)))
|
||||
|
||||
(defn- serve-object
|
||||
"Helper function that returns the appropriate response depending on
|
||||
the storage object backend type."
|
||||
[{:keys [storage] :as cfg} obj]
|
||||
(let [mdata (meta obj)
|
||||
backend (sto/resolve-backend storage (:backend obj))]
|
||||
(case (:type backend)
|
||||
:db
|
||||
{:status 200
|
||||
:headers {"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}
|
||||
:body (sto/get-object-bytes storage obj)}
|
||||
(p/let [body (sto/get-object-bytes storage obj)]
|
||||
(yrs/response :status 200
|
||||
:body body
|
||||
:headers {"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}))
|
||||
|
||||
:s3
|
||||
(let [url (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||
{:status 307
|
||||
:headers {"location" (str url)
|
||||
"x-host" (:host url)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}
|
||||
:body ""})
|
||||
(p/let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||
(yrs/response :status 307
|
||||
:headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}))
|
||||
|
||||
:fs
|
||||
(let [purl (u/uri (:assets-path cfg))
|
||||
purl (u/join purl (sto/object->relative-path obj))]
|
||||
{:status 204
|
||||
:headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}
|
||||
:body ""}))))
|
||||
|
||||
(defn- generic-handler
|
||||
[{:keys [storage] :as cfg} _request id]
|
||||
(let [obj (sto/get-object storage id)]
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
{:status 404 :body ""})))
|
||||
(p/let [purl (u/uri (:assets-path cfg))
|
||||
purl (u/join purl (sto/object->relative-path obj))]
|
||||
(yrs/response :status 204
|
||||
:headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))})))))
|
||||
|
||||
(defn objects-handler
|
||||
[cfg request]
|
||||
(let [id (get-in request [:path-params :id])]
|
||||
(generic-handler cfg request (coerce-id id))))
|
||||
"Handler that servers storage objects by id."
|
||||
[{:keys [storage executor] :as cfg} request respond raise]
|
||||
(-> (px/with-dispatch executor
|
||||
(p/let [id (get-in request [:path-params :id])
|
||||
id (coerce-id id)
|
||||
obj (sto/get-object storage id)]
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
(yrs/response 404))))
|
||||
|
||||
(p/bind p/wrap)
|
||||
(p/then' respond)
|
||||
(p/catch raise)))
|
||||
|
||||
(defn- generic-handler
|
||||
"A generic handler helper/common code for file-media based handlers."
|
||||
[{:keys [storage] :as cfg} request kf]
|
||||
(p/let [id (get-in request [:path-params :id])
|
||||
mobj (get-file-media-object storage id)
|
||||
obj (sto/get-object storage (kf mobj))]
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
(yrs/response 404))))
|
||||
|
||||
(defn file-objects-handler
|
||||
[{:keys [storage] :as cfg} request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
mobj (get-file-media-object storage id)]
|
||||
(generic-handler cfg request (:media-id mobj))))
|
||||
"Handler that serves storage objects by file media id."
|
||||
[cfg request respond raise]
|
||||
(-> (generic-handler cfg request :media-id)
|
||||
(p/then respond)
|
||||
(p/catch raise)))
|
||||
|
||||
(defn file-thumbnails-handler
|
||||
[{:keys [storage] :as cfg} request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
mobj (get-file-media-object storage id)]
|
||||
(generic-handler cfg request (or (:thumbnail-id mobj) (:media-id mobj)))))
|
||||
|
||||
"Handler that serves storage objects by thumbnail-id and quick
|
||||
fallback to file-media-id if no thumbnail is available."
|
||||
[cfg request respond raise]
|
||||
(-> (generic-handler cfg request #(or (:thumbnail-id %) (:media-id %)))
|
||||
(p/then respond)
|
||||
(p/catch raise)))
|
||||
|
||||
;; --- Initialization
|
||||
|
||||
@@ -101,10 +122,16 @@
|
||||
(s/def ::signature-max-age ::dt/duration)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handlers [_]
|
||||
(s/keys :req-un [::storage ::mtx/metrics ::assets-path ::cache-max-age ::signature-max-age]))
|
||||
(s/keys :req-un [::storage
|
||||
::wrk/executor
|
||||
::mtx/metrics
|
||||
::assets-path
|
||||
::cache-max-age
|
||||
::signature-max-age]))
|
||||
|
||||
(defmethod ig/init-key ::handlers
|
||||
[_ cfg]
|
||||
{:objects-handler #(objects-handler cfg %)
|
||||
:file-objects-handler #(file-objects-handler cfg %)
|
||||
:file-thumbnails-handler #(file-thumbnails-handler cfg %)})
|
||||
{:objects-handler (partial objects-handler cfg)
|
||||
:file-objects-handler (partial file-objects-handler cfg)
|
||||
:file-thumbnails-handler (partial file-thumbnails-handler cfg)})
|
||||
|
||||
|
||||
@@ -8,33 +8,44 @@
|
||||
"AWS SNS webhook handler for bounces."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.util.http :as http]
|
||||
[app.util.logging :as l]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[jsonista.core :as j]))
|
||||
[jsonista.core :as j]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(declare parse-json)
|
||||
(declare handle-request)
|
||||
(declare parse-notification)
|
||||
(declare process-report)
|
||||
|
||||
(s/def ::http-client fn?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool]))
|
||||
(s/keys :req-un [::db/pool ::http-client]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [request]
|
||||
(let [body (parse-json (slurp (:body request)))
|
||||
[_ {:keys [executor] :as cfg}]
|
||||
(fn [request respond _]
|
||||
(let [data (slurp (:body request))]
|
||||
(px/run! executor #(handle-request cfg data))
|
||||
(respond (yrs/response 200)))))
|
||||
|
||||
(defn handle-request
|
||||
[{:keys [http-client] :as cfg} data]
|
||||
(try
|
||||
(let [body (parse-json data)
|
||||
mtype (get body "Type")]
|
||||
(cond
|
||||
(= mtype "SubscriptionConfirmation")
|
||||
(let [surl (get body "SubscribeURL")
|
||||
stopic (get body "TopicArn")]
|
||||
(l/info :action "subscription received" :topic stopic :url surl)
|
||||
(http/send! {:uri surl :method :post :timeout 10000}))
|
||||
(http-client {:uri surl :method :post :timeout 10000} {:sync? true}))
|
||||
|
||||
(= mtype "Notification")
|
||||
(when-let [message (parse-json (get body "Message"))]
|
||||
@@ -43,8 +54,11 @@
|
||||
|
||||
:else
|
||||
(l/warn :hint "unexpected data received"
|
||||
:report (pr-str body)))
|
||||
{:status 200 :body ""})))
|
||||
:report (pr-str body))))
|
||||
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected exception on awsns"
|
||||
:cause cause))))
|
||||
|
||||
(defn- parse-bounce
|
||||
[data]
|
||||
@@ -173,14 +187,14 @@
|
||||
|
||||
(defn- process-report
|
||||
[cfg {:keys [type profile-id] :as report}]
|
||||
(l/trace :action "procesing report" :report (pr-str report))
|
||||
(l/trace :action "processing report" :report (pr-str report))
|
||||
(cond
|
||||
;; In this case we receive a bounce/complaint notification without
|
||||
;; confirmed identity, we just emit a warning but do nothing about
|
||||
;; it because this is not a normal case. All notifications should
|
||||
;; come with profile identity.
|
||||
(nil? profile-id)
|
||||
(l/warn :msg "a notification without identity recevied from AWS"
|
||||
(l/warn :msg "a notification without identity received from AWS"
|
||||
:report (pr-str report))
|
||||
|
||||
(= "bounce" type)
|
||||
|
||||
30
backend/src/app/http/client.clj
Normal file
30
backend/src/app/http/client.clj
Normal file
@@ -0,0 +1,30 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.http.client
|
||||
"Http client abstraction layer."
|
||||
(:require
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http]))
|
||||
|
||||
(defmethod ig/pre-init-spec :app.http/client [_]
|
||||
(s/keys :req-un [::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key :app.http/client
|
||||
[_ {:keys [executor] :as cfg}]
|
||||
(let [client (http/build-client {:executor executor
|
||||
:connect-timeout 30000 ;; 10s
|
||||
:follow-redirects :always})]
|
||||
(with-meta
|
||||
(fn send
|
||||
([req] (send req {}))
|
||||
([req {:keys [response-type sync?] :or {response-type :string sync? false}}]
|
||||
(if sync?
|
||||
(http/send req {:client client :as response-type})
|
||||
(http/send-async req {:client client :as response-type}))))
|
||||
{::client client})))
|
||||
259
backend/src/app/http/debug.clj
Normal file
259
backend/src/app/http/debug.clj
Normal file
@@ -0,0 +1,259 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.http.debug
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.rpc.mutations.files :as m.files]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.template :as tmpl]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.core :as fs]
|
||||
[emoji.core :as emj]
|
||||
[fipp.edn :as fpp]
|
||||
[integrant.core :as ig]
|
||||
[markdown.core :as md]
|
||||
[markdown.transformers :as mdt]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
;; (selmer.parser/cache-off!)
|
||||
|
||||
(defn authorized?
|
||||
[pool {:keys [profile-id]}]
|
||||
(or (= "devenv" (cf/get :host))
|
||||
(let [profile (ex/ignoring (profile/retrieve-profile-data pool profile-id))
|
||||
admins (or (cf/get :admins) #{})]
|
||||
(contains? admins (:email profile)))))
|
||||
|
||||
(defn index
|
||||
[{:keys [pool]} request]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
(yrs/response :status 200
|
||||
:headers {"content-type" "text/html"}
|
||||
:body (-> (io/resource "templates/debug.tmpl")
|
||||
(tmpl/render {}))))
|
||||
|
||||
|
||||
(def sql:retrieve-range-of-changes
|
||||
"select revn, changes from file_change where file_id=? and revn >= ? and revn <= ? order by revn")
|
||||
|
||||
(def sql:retrieve-single-change
|
||||
"select revn, changes, data from file_change where file_id=? and revn = ?")
|
||||
|
||||
(defn prepare-response
|
||||
[{:keys [params] :as request} body filename]
|
||||
(when-not body
|
||||
(ex/raise :type :not-found
|
||||
:code :enpty-data
|
||||
:hint "empty response"))
|
||||
|
||||
(cond-> (yrs/response :status 200
|
||||
:body body
|
||||
:headers {"content-type" "application/transit+json"})
|
||||
(contains? params :download)
|
||||
(update :headers assoc "content-disposition" (str "attachment; filename=" filename))))
|
||||
|
||||
(defn- retrieve-file-data
|
||||
[{:keys [pool]} {:keys [params] :as request}]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
|
||||
(let [file-id (some-> (get-in request [:params :file-id]) uuid/uuid)
|
||||
revn (some-> (get-in request [:params :revn]) d/parse-integer)
|
||||
filename (str file-id)]
|
||||
(when-not file-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments))
|
||||
|
||||
(let [data (if (integer? revn)
|
||||
(some-> (db/exec-one! pool [sql:retrieve-single-change file-id revn]) :data)
|
||||
(some-> (db/get-by-id pool :file file-id) :data))]
|
||||
(if (contains? params :download)
|
||||
(-> (prepare-response request data filename)
|
||||
(update :headers assoc "content-type" "application/octet-stream"))
|
||||
(prepare-response request (some-> data blob/decode) filename)))))
|
||||
|
||||
(defn- upload-file-data
|
||||
[{:keys [pool]} {:keys [profile-id params] :as request}]
|
||||
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
|
||||
data (some-> params :file :path fs/slurp-bytes blob/decode)]
|
||||
|
||||
(if (and data project-id)
|
||||
(let [fname (str "imported-file-" (dt/now))
|
||||
file-id (try
|
||||
(uuid/uuid (-> params :file :filename))
|
||||
(catch Exception _ (uuid/next)))
|
||||
file (db/exec-one! pool (sql/select :file {:id file-id}))]
|
||||
(if file
|
||||
(db/update! pool :file
|
||||
{:data (blob/encode data)}
|
||||
{:id file-id})
|
||||
(m.files/create-file pool {:id file-id
|
||||
:name fname
|
||||
:project-id project-id
|
||||
:profile-id profile-id
|
||||
:data data}))
|
||||
(yrs/response 200 "OK"))
|
||||
(yrs/response 500 "ERROR"))))
|
||||
|
||||
(defn file-data
|
||||
[cfg request]
|
||||
(case (yrq/method request)
|
||||
:get (retrieve-file-data cfg request)
|
||||
:post (upload-file-data cfg request)
|
||||
(ex/raise :type :http
|
||||
:code :method-not-found)))
|
||||
|
||||
(defn retrieve-file-changes
|
||||
[{:keys [pool]} request]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
|
||||
(let [file-id (some-> (get-in request [:params :id]) uuid/uuid)
|
||||
revn (or (get-in request [:params :revn]) "latest")
|
||||
filename (str file-id)]
|
||||
|
||||
(when (or (not file-id) (not revn))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-arguments
|
||||
:hint "missing arguments"))
|
||||
|
||||
(cond
|
||||
(d/num-string? revn)
|
||||
(let [item (db/exec-one! pool [sql:retrieve-single-change file-id (d/parse-integer revn)])]
|
||||
(prepare-response request (some-> item :changes blob/decode vec) filename))
|
||||
|
||||
(str/includes? revn ":")
|
||||
(let [[start end] (->> (str/split revn #":")
|
||||
(map str/trim)
|
||||
(map d/parse-integer))
|
||||
items (db/exec! pool [sql:retrieve-range-of-changes file-id start end])]
|
||||
(prepare-response request
|
||||
(some->> items
|
||||
(map :changes)
|
||||
(map blob/decode)
|
||||
(mapcat identity)
|
||||
(vec))
|
||||
filename))
|
||||
:else
|
||||
(ex/raise :type :validation :code :invalid-arguments))))
|
||||
|
||||
|
||||
(defn retrieve-error
|
||||
[{:keys [pool]} request]
|
||||
(letfn [(parse-id [request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
id (us/uuid-conformer id)]
|
||||
(when (uuid? id)
|
||||
id)))
|
||||
|
||||
(retrieve-report [id]
|
||||
(ex/ignoring
|
||||
(some-> (db/get-by-id pool :server-error-report id) :content db/decode-transit-pgobject)))
|
||||
|
||||
(render-template [report]
|
||||
(let [context (dissoc report
|
||||
:trace :cause :params :data :spec-problems
|
||||
:spec-explain :spec-value :error :explain :hint)
|
||||
params {:context (with-out-str
|
||||
(fpp/pprint context {:width 200}))
|
||||
:hint (:hint report)
|
||||
:spec-explain (:spec-explain report)
|
||||
:spec-problems (:spec-problems report)
|
||||
:spec-value (:spec-value report)
|
||||
:data (:data report)
|
||||
:trace (or (:trace report)
|
||||
(some-> report :error :trace))
|
||||
:params (:params report)}]
|
||||
(-> (io/resource "templates/error-report.tmpl")
|
||||
(tmpl/render params))))]
|
||||
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
|
||||
(let [result (some-> (parse-id request)
|
||||
(retrieve-report)
|
||||
(render-template))]
|
||||
(if result
|
||||
(yrs/response :status 200
|
||||
:body result
|
||||
:headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"})
|
||||
(yrs/response 404 "not found")))))
|
||||
|
||||
(def sql:error-reports
|
||||
"select id, created_at from server_error_report order by created_at desc limit 100")
|
||||
|
||||
(defn retrieve-error-list
|
||||
[{:keys [pool]} request]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
(let [items (db/exec! pool [sql:error-reports])
|
||||
items (map #(update % :created-at dt/format-instant :rfc1123) items)]
|
||||
(yrs/response :status 200
|
||||
:body (-> (io/resource "templates/error-list.tmpl")
|
||||
(tmpl/render {:items items}))
|
||||
:headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"})))
|
||||
|
||||
(defn health-check
|
||||
"Mainly a task that performs a health check."
|
||||
[{:keys [pool]} _]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/exec-one! conn ["select count(*) as count from server_prop;"])
|
||||
(yrs/response 200 "OK")))
|
||||
|
||||
(defn changelog
|
||||
[_ _]
|
||||
(letfn [(transform-emoji [text state]
|
||||
[(emj/emojify text) state])
|
||||
(md->html [text]
|
||||
(md/md-to-html-string text :replacement-transformers (into [transform-emoji] mdt/transformer-vector)))]
|
||||
(if-let [clog (io/resource "changelog.md")]
|
||||
(yrs/response :status 200
|
||||
:headers {"content-type" "text/html; charset=utf-8"}
|
||||
:body (-> clog slurp md->html))
|
||||
(yrs/response :status 404 :body "NOT FOUND"))))
|
||||
|
||||
(defn- wrap-async
|
||||
[{:keys [executor] :as cfg} f]
|
||||
(fn [request respond raise]
|
||||
(-> (px/submit! executor #(f cfg request))
|
||||
(p/then respond)
|
||||
(p/catch raise))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::handlers [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::handlers
|
||||
[_ cfg]
|
||||
{:index (wrap-async cfg index)
|
||||
:health-check (wrap-async cfg health-check)
|
||||
:retrieve-file-changes (wrap-async cfg retrieve-file-changes)
|
||||
:retrieve-error (wrap-async cfg retrieve-error)
|
||||
:retrieve-error-list (wrap-async cfg retrieve-error-list)
|
||||
:file-data (wrap-async cfg file-data)
|
||||
:changelog (wrap-async cfg changelog)})
|
||||
54
backend/src/app/http/doc.clj
Normal file
54
backend/src/app/http/doc.clj
Normal file
@@ -0,0 +1,54 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.http.doc
|
||||
"API autogenerated documentation."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.config :as cf]
|
||||
[app.util.services :as sv]
|
||||
[app.util.template :as tmpl]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
[pretty-spec.core :as ps]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(defn get-spec-str
|
||||
[k]
|
||||
(with-out-str
|
||||
(ps/pprint (s/form k)
|
||||
{:ns-aliases {"clojure.spec.alpha" "s"
|
||||
"clojure.core.specs.alpha" "score"
|
||||
"clojure.core" nil}})))
|
||||
|
||||
(defn prepare-context
|
||||
[rpc]
|
||||
(letfn [(gen-doc [type [name f]]
|
||||
(let [mdata (meta f)]
|
||||
;; (prn name mdata)
|
||||
{:type (d/name type)
|
||||
:name (d/name name)
|
||||
:auth (:auth mdata true)
|
||||
:docs (::sv/docs mdata)
|
||||
:spec (get-spec-str (::sv/spec mdata))}))]
|
||||
{:query-methods
|
||||
(into []
|
||||
(map (partial gen-doc :query))
|
||||
(->> rpc :methods :query (sort-by first)))
|
||||
:mutation-methods
|
||||
(into []
|
||||
(map (partial gen-doc :mutation))
|
||||
(->> rpc :methods :mutation (sort-by first)))}))
|
||||
|
||||
(defn handler
|
||||
[rpc]
|
||||
(let [context (prepare-context rpc)]
|
||||
(if (contains? cf/flags :backend-api-doc)
|
||||
(fn [_ respond _]
|
||||
(respond (yrs/response 200 (-> (io/resource "api-doc.tmpl")
|
||||
(tmpl/render context)))))
|
||||
(fn [_ respond _]
|
||||
(respond (yrs/response 404))))))
|
||||
@@ -8,30 +8,33 @@
|
||||
"A errors handling for the http server."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.util.logging :as l]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[expound.alpha :as expound]))
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(defn- explain-error
|
||||
[error]
|
||||
(with-out-str
|
||||
(expound/printer (:data error))))
|
||||
(def ^:dynamic *context* {})
|
||||
|
||||
(defn get-error-context
|
||||
[request error]
|
||||
(let [edata (ex-data error)]
|
||||
(merge
|
||||
{:id (uuid/next)
|
||||
:path (:uri request)
|
||||
:method (:request-method request)
|
||||
:params (:params request)
|
||||
:data edata}
|
||||
(let [headers (:headers request)]
|
||||
{:user-agent (get headers "user-agent")
|
||||
:frontend-version (get headers "x-frontend-version" "unknown")})
|
||||
(when (and (map? edata) (:data edata))
|
||||
{:explain (explain-error edata)}))))
|
||||
(defn- parse-client-ip
|
||||
[request]
|
||||
(or (some-> (yrq/get-header request "x-forwarded-for") (str/split ",") first)
|
||||
(yrq/get-header request "x-real-ip")
|
||||
(yrq/remote-addr request)))
|
||||
|
||||
(defn get-context
|
||||
[request]
|
||||
(merge
|
||||
*context*
|
||||
{:path (:path request)
|
||||
:method (:method request)
|
||||
:params (:params request)
|
||||
:ip-addr (parse-client-ip request)
|
||||
:profile-id (:profile-id request)}
|
||||
(let [headers (:headers request)]
|
||||
{:user-agent (get headers "user-agent")
|
||||
:frontend-version (get headers "x-frontend-version" "unknown")})))
|
||||
|
||||
(defmulti handle-exception
|
||||
(fn [err & _rest]
|
||||
@@ -41,108 +44,117 @@
|
||||
|
||||
(defmethod handle-exception :authentication
|
||||
[err _]
|
||||
{:status 401 :body (ex-data err)})
|
||||
|
||||
(yrs/response 401 (ex-data err)))
|
||||
|
||||
(defmethod handle-exception :restriction
|
||||
[err _]
|
||||
{:status 400 :body (ex-data err)})
|
||||
(yrs/response 400 (ex-data err)))
|
||||
|
||||
(defmethod handle-exception :validation
|
||||
[err req]
|
||||
(let [header (get-in req [:headers "accept"])
|
||||
edata (ex-data err)]
|
||||
(if (and (= :spec-validation (:code edata))
|
||||
(str/starts-with? header "text/html"))
|
||||
{:status 400
|
||||
:headers {"content-type" "text/html"}
|
||||
:body (str "<pre style='font-size:16px'>"
|
||||
(explain-error edata)
|
||||
"</pre>\n")}
|
||||
{:status 400
|
||||
:body (cond-> edata
|
||||
(map? (:data edata))
|
||||
(-> (assoc :explain (explain-error edata))
|
||||
(dissoc :data)))})))
|
||||
[err _]
|
||||
(let [{:keys [code] :as data} (ex-data err)]
|
||||
(cond
|
||||
(= code :spec-validation)
|
||||
(let [explain (us/pretty-explain data)]
|
||||
(yrs/response :status 400
|
||||
:body (-> data
|
||||
(dissoc ::s/problems ::s/value)
|
||||
(cond-> explain (assoc :explain explain)))))
|
||||
|
||||
(= code :request-body-too-large)
|
||||
(yrs/response :status 413 :body data)
|
||||
|
||||
:else
|
||||
(yrs/response :status 400 :body data))))
|
||||
|
||||
(defmethod handle-exception :assertion
|
||||
[error request]
|
||||
(let [edata (ex-data error)
|
||||
cdata (get-error-context request error)]
|
||||
(l/update-thread-context! cdata)
|
||||
(l/error :hint "internal error: assertion"
|
||||
:error-id (str (:id cdata))
|
||||
explain (us/pretty-explain edata)]
|
||||
(l/error ::l/raw (ex-message error)
|
||||
::l/context (get-context request)
|
||||
:cause error)
|
||||
|
||||
{:status 500
|
||||
:body {:type :server-error
|
||||
:code :assertion
|
||||
:data (-> edata
|
||||
(assoc :explain (explain-error edata))
|
||||
(dissoc :data))}}))
|
||||
(yrs/response :status 500
|
||||
:body {:type :server-error
|
||||
:code :assertion
|
||||
:data (-> edata
|
||||
(dissoc ::s/problems ::s/value ::s/spec)
|
||||
(cond-> explain (assoc :explain explain)))})))
|
||||
|
||||
(defmethod handle-exception :not-found
|
||||
[err _]
|
||||
{:status 404 :body (ex-data err)})
|
||||
(yrs/response 404 (ex-data err)))
|
||||
|
||||
(defmethod handle-exception org.postgresql.util.PSQLException
|
||||
[error request]
|
||||
(let [state (.getSQLState ^java.sql.SQLException error)]
|
||||
(l/error ::l/raw (ex-message error)
|
||||
::l/context (get-context request)
|
||||
:cause error)
|
||||
(cond
|
||||
(= state "57014")
|
||||
(yrs/response 504 {:type :server-error
|
||||
:code :statement-timeout
|
||||
:hint (ex-message error)})
|
||||
|
||||
(= state "25P03")
|
||||
(yrs/response 504 {:type :server-error
|
||||
:code :idle-in-transaction-timeout
|
||||
:hint (ex-message error)})
|
||||
|
||||
:else
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:state state}))))
|
||||
|
||||
(defmethod handle-exception :default
|
||||
[error request]
|
||||
(let [edata (ex-data error)]
|
||||
;; NOTE: this is a special case for the idle-in-transaction error;
|
||||
;; when it happens, the connection is automatically closed and
|
||||
;; next-jdbc combines the two errors in a single ex-info. We only
|
||||
;; need the :handling error, because the :rollback error will be
|
||||
;; always "connection closed".
|
||||
(if (and (ex/exception? (:rollback edata))
|
||||
(ex/exception? (:handling edata)))
|
||||
(handle-exception (:handling edata) request)
|
||||
(let [cdata (get-error-context request error)]
|
||||
(l/update-thread-context! cdata)
|
||||
(l/error :hint "internal error"
|
||||
:error-message (ex-message error)
|
||||
:error-id (str (:id cdata))
|
||||
:cause error)
|
||||
{:status 500
|
||||
:body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:data edata}}))))
|
||||
|
||||
(defmethod handle-exception org.postgresql.util.PSQLException
|
||||
[error request]
|
||||
(let [cdata (get-error-context request error)
|
||||
state (.getSQLState ^java.sql.SQLException error)]
|
||||
|
||||
(l/update-thread-context! cdata)
|
||||
(l/error :hint "psql exception"
|
||||
:error-message (ex-message error)
|
||||
:error-id (str (:id cdata))
|
||||
:sql-state state
|
||||
:cause error)
|
||||
|
||||
(cond
|
||||
(= state "57014")
|
||||
{:status 504
|
||||
:body {:type :server-timeout
|
||||
:code :statement-timeout
|
||||
:hint (ex-message error)}}
|
||||
;; This means that exception is not a controlled exception.
|
||||
(nil? edata)
|
||||
(do
|
||||
(l/error ::l/raw (ex-message error)
|
||||
::l/context (get-context request)
|
||||
:cause error)
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}))
|
||||
|
||||
(= state "25P03")
|
||||
{:status 504
|
||||
:body {:type :server-timeout
|
||||
:code :idle-in-transaction-timeout
|
||||
:hint (ex-message error)}}
|
||||
;; This is a special case for the idle-in-transaction error;
|
||||
;; when it happens, the connection is automatically closed and
|
||||
;; next-jdbc combines the two errors in a single ex-info. We
|
||||
;; only need the :handling error, because the :rollback error
|
||||
;; will be always "connection closed".
|
||||
(and (ex/exception? (:rollback edata))
|
||||
(ex/exception? (:handling edata)))
|
||||
(handle-exception (:handling edata) request)
|
||||
|
||||
:else
|
||||
{:status 500
|
||||
:body {:type :server-error
|
||||
:code :psql-exception
|
||||
:hint (ex-message error)
|
||||
:state state}})))
|
||||
(do
|
||||
(l/error ::l/raw (ex-message error)
|
||||
::l/context (get-context request)
|
||||
:cause error)
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unhandled
|
||||
:hint (ex-message error)
|
||||
:data edata})))))
|
||||
|
||||
(defn handle
|
||||
[error req]
|
||||
(if (or (instance? java.util.concurrent.CompletionException error)
|
||||
(instance? java.util.concurrent.ExecutionException error))
|
||||
(handle-exception (.getCause ^Throwable error) req)
|
||||
(handle-exception error req)))
|
||||
[cause request]
|
||||
|
||||
(cond
|
||||
(or (instance? java.util.concurrent.CompletionException cause)
|
||||
(instance? java.util.concurrent.ExecutionException cause))
|
||||
(handle-exception (.getCause ^Throwable cause) request)
|
||||
|
||||
|
||||
(ex/wrapped? cause)
|
||||
(let [context (meta cause)
|
||||
cause (deref cause)]
|
||||
(binding [*context* context]
|
||||
(handle-exception cause request)))
|
||||
|
||||
:else
|
||||
(handle-exception cause request)))
|
||||
|
||||
@@ -10,57 +10,67 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cfg]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.emails :as eml]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(declare send-feedback)
|
||||
(declare ^:private send-feedback)
|
||||
(declare ^:private handler)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool]))
|
||||
(s/keys :req-un [::db/pool ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [pool] :as scfg}]
|
||||
(let [ftoken (cfg/get :feedback-token ::no-token)
|
||||
enabled (cfg/get :feedback-enabled)]
|
||||
(fn [{:keys [profile-id] :as request}]
|
||||
(let [token (get-in request [:headers "x-feedback-token"])
|
||||
params (d/merge (:params request)
|
||||
(:body-params request))]
|
||||
[_ {:keys [executor] :as cfg}]
|
||||
(let [enabled? (contains? cf/flags :user-feedback)]
|
||||
(if enabled?
|
||||
(fn [request respond raise]
|
||||
(-> (px/submit! executor #(handler cfg request))
|
||||
(p/then' respond)
|
||||
(p/catch raise)))
|
||||
(fn [_ _ raise]
|
||||
(raise (ex/error :type :validation
|
||||
:code :feedback-disabled
|
||||
:hint "feedback module is disabled"))))))
|
||||
|
||||
(when-not enabled
|
||||
(ex/raise :type :validation
|
||||
:code :feedback-disabled
|
||||
:hint "feedback module is disabled"))
|
||||
(defn- handler
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id] :as request}]
|
||||
(let [ftoken (cf/get :feedback-token ::no-token)
|
||||
token (yrq/get-header request "x-feedback-token")
|
||||
params (d/merge (:params request)
|
||||
(:body-params request))]
|
||||
(cond
|
||||
(uuid? profile-id)
|
||||
(let [profile (profile/retrieve-profile-data pool profile-id)
|
||||
params (assoc params :from (:email profile))]
|
||||
(send-feedback pool profile params))
|
||||
|
||||
(cond
|
||||
(uuid? profile-id)
|
||||
(let [profile (profile/retrieve-profile-data pool profile-id)
|
||||
params (assoc params :from (:email profile))]
|
||||
(when-not (:is-muted profile)
|
||||
(send-feedback pool profile params)))
|
||||
(= token ftoken)
|
||||
(send-feedback cfg nil params))
|
||||
|
||||
(= token ftoken)
|
||||
(send-feedback scfg nil params))
|
||||
|
||||
{:status 204 :body ""}))))
|
||||
(yrs/response 204)))
|
||||
|
||||
(s/def ::content ::us/string)
|
||||
(s/def ::from ::us/email)
|
||||
(s/def ::subject ::us/string)
|
||||
|
||||
(s/def ::feedback
|
||||
(s/keys :req-un [::from ::subject ::content]))
|
||||
|
||||
(defn send-feedback
|
||||
(defn- send-feedback
|
||||
[pool profile params]
|
||||
(let [params (us/conform ::feedback params)
|
||||
destination (cfg/get :feedback-destination)]
|
||||
destination (cf/get :feedback-destination)]
|
||||
(eml/send! {::eml/conn pool
|
||||
::eml/factory eml/feedback
|
||||
:from destination
|
||||
:to destination
|
||||
:profile profile
|
||||
:reply-to (:from params)
|
||||
|
||||
@@ -6,192 +6,189 @@
|
||||
|
||||
(ns app.http.middleware
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.metrics :as mtx]
|
||||
[app.config :as cf]
|
||||
[app.util.json :as json]
|
||||
[app.util.logging :as l]
|
||||
[buddy.core.codecs :as bc]
|
||||
[buddy.core.hash :as bh]
|
||||
[clojure.java.io :as io]
|
||||
[ring.core.protocols :as rp]
|
||||
[ring.middleware.cookies :refer [wrap-cookies]]
|
||||
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
|
||||
[ring.middleware.multipart-params :refer [wrap-multipart-params]]
|
||||
[ring.middleware.params :refer [wrap-params]]))
|
||||
[cuerdas.core :as str]
|
||||
[yetti.adapter :as yt]
|
||||
[yetti.middleware :as ymw]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs])
|
||||
(:import
|
||||
com.fasterxml.jackson.core.io.JsonEOFException
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.OutputStream))
|
||||
|
||||
(defn wrap-server-timing
|
||||
(def server-timing
|
||||
{:name ::server-timing
|
||||
:compile (constantly ymw/wrap-server-timing)})
|
||||
|
||||
(def params
|
||||
{:name ::params
|
||||
:compile (constantly ymw/wrap-params)})
|
||||
|
||||
(defn wrap-parse-request
|
||||
[handler]
|
||||
(let [seconds-from #(float (/ (- (System/nanoTime) %) 1000000000))]
|
||||
(fn [request]
|
||||
(let [start (System/nanoTime)
|
||||
response (handler request)]
|
||||
(update response :headers
|
||||
(fn [headers]
|
||||
(assoc headers "Server-Timing" (str "total;dur=" (seconds-from start)))))))))
|
||||
(letfn [(process-request [request]
|
||||
(let [header (yrq/get-header request "content-type")]
|
||||
(cond
|
||||
(str/starts-with? header "application/transit+json")
|
||||
(with-open [is (-> request yrq/body yrq/body-stream)]
|
||||
(let [params (t/read! (t/reader is))]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params))))
|
||||
|
||||
(defn wrap-parse-request-body
|
||||
(str/starts-with? header "application/json")
|
||||
(with-open [is (-> request yrq/body yrq/body-stream)]
|
||||
(let [params (json/read is)]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params))))
|
||||
|
||||
:else
|
||||
request)))
|
||||
|
||||
(handle-error [raise cause]
|
||||
(cond
|
||||
(instance? RequestTooBigException cause)
|
||||
(raise (ex/error :type :validation
|
||||
:code :request-body-too-large
|
||||
:hint (ex-message cause)))
|
||||
|
||||
(instance? JsonEOFException cause)
|
||||
(raise (ex/error :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)))
|
||||
:else
|
||||
(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)))))
|
||||
|
||||
(def parse-request
|
||||
{:name ::parse-request
|
||||
:compile (constantly wrap-parse-request)})
|
||||
|
||||
(defn buffered-output-stream
|
||||
"Returns a buffered output stream that ignores flush calls. This is
|
||||
needed because transit-java calls flush very aggresivelly on each
|
||||
object write."
|
||||
[^java.io.OutputStream os ^long chunk-size]
|
||||
(proxy [java.io.BufferedOutputStream] [os (int chunk-size)]
|
||||
;; Explicitly do not forward flush
|
||||
(flush [])
|
||||
(close []
|
||||
(proxy-super flush)
|
||||
(proxy-super close))))
|
||||
|
||||
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
|
||||
|
||||
(defn wrap-format-response
|
||||
[handler]
|
||||
(letfn [(parse-transit [body]
|
||||
(let [reader (t/reader body)]
|
||||
(t/read! reader)))
|
||||
(letfn [(transit-streamable-body [data opts]
|
||||
(reify yrs/StreamableResponseBody
|
||||
(-write-body-to-stream [_ _ output-stream]
|
||||
(try
|
||||
(with-open [bos (buffered-output-stream output-stream buffer-size)]
|
||||
(let [tw (t/writer bos opts)]
|
||||
(t/write! tw data)))
|
||||
|
||||
(parse-json [body]
|
||||
(let [reader (io/reader body)]
|
||||
(json/read reader)))
|
||||
(catch java.io.IOException _cause
|
||||
;; Do nothing, EOF means client closes connection abruptly
|
||||
nil)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected error on encoding response"
|
||||
:cause cause))
|
||||
(finally
|
||||
(.close ^OutputStream output-stream))))))
|
||||
|
||||
(parse [type body]
|
||||
(try
|
||||
(case type
|
||||
:json (parse-json body)
|
||||
:transit (parse-transit body))
|
||||
(catch Exception e
|
||||
(let [data {:type :parse
|
||||
:hint "unable to parse request body"
|
||||
:message (ex-message e)}]
|
||||
{:status 400
|
||||
:headers {"content-type" "application/transit+json"}
|
||||
:body (t/encode-str data {:type :json-verbose})}))))]
|
||||
(format-response [response request]
|
||||
(let [body (yrs/body response)]
|
||||
(if (coll? body)
|
||||
(let [qs (yrq/query request)
|
||||
opts (if (or (contains? cf/flags :transit-readable-response)
|
||||
(str/includes? qs "transit_verbose"))
|
||||
{:type :json-verbose}
|
||||
{:type :json})]
|
||||
(-> response
|
||||
(update :headers assoc "content-type" "application/transit+json")
|
||||
(assoc :body (transit-streamable-body body opts))))
|
||||
response)))
|
||||
|
||||
(fn [{:keys [headers body] :as request}]
|
||||
(let [ctype (get headers "content-type")]
|
||||
(handler
|
||||
(case ctype
|
||||
"application/transit+json"
|
||||
(let [params (parse :transit body)]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params)))
|
||||
(process-response [response request]
|
||||
(cond-> response
|
||||
(map? response) (format-response request)))]
|
||||
|
||||
"application/json"
|
||||
(let [params (parse :json body)]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params)))
|
||||
(fn [request respond raise]
|
||||
(handler request
|
||||
(fn [response]
|
||||
(let [response (process-response response request)]
|
||||
(respond response)))
|
||||
raise))))
|
||||
|
||||
request))))))
|
||||
|
||||
(def parse-request-body
|
||||
{:name ::parse-request-body
|
||||
:compile (constantly wrap-parse-request-body)})
|
||||
|
||||
(defn- transit-streamable-body
|
||||
[data opts]
|
||||
(reify rp/StreamableResponseBody
|
||||
(write-body-to-stream [_ response output-stream]
|
||||
(try
|
||||
(let [tw (t/writer output-stream opts)]
|
||||
(t/write! tw data))
|
||||
(finally
|
||||
(.close ^java.io.OutputStream output-stream))))))
|
||||
|
||||
(defn- impl-format-response-body
|
||||
[response _request]
|
||||
(let [body (:body response)
|
||||
opts {:type :json-verbose}]
|
||||
(cond
|
||||
(coll? body)
|
||||
(-> response
|
||||
(update :headers assoc "content-type" "application/transit+json")
|
||||
(assoc :body (transit-streamable-body body opts)))
|
||||
|
||||
;; ;; Temporary disabled
|
||||
;; (-> response
|
||||
;; (update :headers assoc "content-type" "application/transit+json")
|
||||
;; (assoc :body
|
||||
;; (if (= :post (:request-method request))
|
||||
;; (transit-streamable-body body opts)
|
||||
;; (t/encode body opts))))
|
||||
|
||||
(nil? body)
|
||||
(assoc response :status 204 :body "")
|
||||
|
||||
:else
|
||||
response)))
|
||||
|
||||
(defn- wrap-format-response-body
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [response (handler request)]
|
||||
(cond-> response
|
||||
(map? response) (impl-format-response-body request)))))
|
||||
|
||||
(def format-response-body
|
||||
{:name ::format-response-body
|
||||
:compile (constantly wrap-format-response-body)})
|
||||
(def format-response
|
||||
{:name ::format-response
|
||||
:compile (constantly wrap-format-response)})
|
||||
|
||||
(defn wrap-errors
|
||||
[handler on-error]
|
||||
(fn [request]
|
||||
(try
|
||||
(handler request)
|
||||
(catch Throwable e
|
||||
(on-error e request)))))
|
||||
(fn [request respond _]
|
||||
(handler request respond (fn [cause]
|
||||
(-> cause (on-error request) respond)))))
|
||||
|
||||
(def errors
|
||||
{:name ::errors
|
||||
:compile (constantly wrap-errors)})
|
||||
|
||||
(def metrics
|
||||
{:name ::metrics
|
||||
:wrap (fn [handler]
|
||||
(mtx/wrap-counter handler {:id "http__requests_counter"
|
||||
:help "Absolute http requests counter."}))})
|
||||
(def cookies
|
||||
{:name ::cookies
|
||||
:compile (constantly wrap-cookies)})
|
||||
|
||||
(def params
|
||||
{:name ::params
|
||||
:compile (constantly wrap-params)})
|
||||
|
||||
(def multipart-params
|
||||
{:name ::multipart-params
|
||||
:compile (constantly wrap-multipart-params)})
|
||||
|
||||
(def keyword-params
|
||||
{:name ::keyword-params
|
||||
:compile (constantly wrap-keyword-params)})
|
||||
|
||||
(def server-timing
|
||||
{:name ::server-timing
|
||||
:compile (constantly wrap-server-timing)})
|
||||
|
||||
(defn wrap-etag
|
||||
(defn wrap-cors
|
||||
[handler]
|
||||
(letfn [(generate-etag [{:keys [body] :as response}]
|
||||
(str "W/\"" (-> body bh/blake2b-128 bc/bytes->hex) "\""))
|
||||
(get-match [{:keys [headers] :as request}]
|
||||
(get headers "if-none-match"))]
|
||||
(fn [request]
|
||||
(let [response (handler request)]
|
||||
(if (= :get (:request-method request))
|
||||
(let [etag (generate-etag response)
|
||||
match (get-match request)
|
||||
response (update response :headers #(assoc % "ETag" etag))]
|
||||
(cond-> response
|
||||
(and (string? match)
|
||||
(= :get (:request-method request))
|
||||
(= etag match))
|
||||
(-> response
|
||||
(assoc :body "")
|
||||
(assoc :status 304))))
|
||||
response)))))
|
||||
(if-not (contains? cf/flags :cors)
|
||||
handler
|
||||
(letfn [(add-headers [headers request]
|
||||
(let [origin (yrq/get-header request "origin")]
|
||||
(-> headers
|
||||
(assoc "access-control-allow-origin" origin)
|
||||
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
||||
(assoc "access-control-allow-credentials" "true")
|
||||
(assoc "access-control-expose-headers" "x-requested-with, content-type, cookie")
|
||||
(assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width"))))
|
||||
|
||||
(def etag
|
||||
{:name ::etag
|
||||
:compile (constantly wrap-etag)})
|
||||
(update-response [response request]
|
||||
(update response :headers add-headers request))]
|
||||
|
||||
(defn activity-logger
|
||||
[handler]
|
||||
(let [logger "penpot.profile-activity"]
|
||||
(fn [{:keys [headers] :as request}]
|
||||
(let [ip-addr (get headers "x-forwarded-for")
|
||||
profile-id (:profile-id request)
|
||||
qstring (:query-string request)]
|
||||
(l/info ::l/async true
|
||||
::l/logger logger
|
||||
:ip-addr ip-addr
|
||||
:profile-id profile-id
|
||||
:uri (str (:uri request) (when qstring (str "?" qstring)))
|
||||
:method (name (:request-method request)))
|
||||
(handler request)))))
|
||||
(fn [request respond raise]
|
||||
(if (= (yrq/method request) :options)
|
||||
(-> (yrs/response 200)
|
||||
(update-response request)
|
||||
(respond))
|
||||
(handler request
|
||||
(fn [response]
|
||||
(respond (update-response response request)))
|
||||
raise))))))
|
||||
|
||||
(def cors
|
||||
{:name ::cors
|
||||
:compile (constantly wrap-cors)})
|
||||
|
||||
(defn compile-restrict-methods
|
||||
[data _]
|
||||
(when-let [allowed (:allowed-methods data)]
|
||||
(fn [handler]
|
||||
(fn [request respond raise]
|
||||
(let [method (yrq/method request)]
|
||||
(if (contains? allowed method)
|
||||
(handler request respond raise)
|
||||
(respond (yrs/response 405))))))))
|
||||
|
||||
(def restrict-methods
|
||||
{:name ::restrict-methods
|
||||
:compile compile-restrict-methods})
|
||||
|
||||
@@ -8,20 +8,22 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.util.http :as http]
|
||||
[app.util.logging :as l]
|
||||
[app.util.json :as json]
|
||||
[app.util.time :as dt]
|
||||
[clojure.data.json :as json]
|
||||
[clojure.set :as set]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(defn- build-redirect-uri
|
||||
[{:keys [provider] :as cfg}]
|
||||
@@ -40,54 +42,106 @@
|
||||
(assoc :query query)
|
||||
(str))))
|
||||
|
||||
(defn- qualify-props
|
||||
[provider props]
|
||||
(reduce-kv (fn [result k v]
|
||||
(assoc result (keyword (:name provider) (name k)) v))
|
||||
{}
|
||||
props))
|
||||
|
||||
(defn retrieve-access-token
|
||||
[{:keys [provider] :as cfg} code]
|
||||
(try
|
||||
(let [params {:client_id (:client-id provider)
|
||||
:client_secret (:client-secret provider)
|
||||
:code code
|
||||
:grant_type "authorization_code"
|
||||
:redirect_uri (build-redirect-uri cfg)}
|
||||
req {:method :post
|
||||
:headers {"content-type" "application/x-www-form-urlencoded"}
|
||||
:uri (:token-uri provider)
|
||||
:body (u/map->query-string params)}
|
||||
res (http/send! req)]
|
||||
(when (= 200 (:status res))
|
||||
(let [data (json/read-str (:body res))]
|
||||
{:token (get data "access_token")
|
||||
:type (get data "token_type")})))
|
||||
(catch Exception e
|
||||
(l/error :hint "unexpected error on retrieve-access-token"
|
||||
:cause e)
|
||||
nil)))
|
||||
[{:keys [provider http-client] :as cfg} code]
|
||||
(let [params {:client_id (:client-id provider)
|
||||
:client_secret (:client-secret provider)
|
||||
:code code
|
||||
:grant_type "authorization_code"
|
||||
:redirect_uri (build-redirect-uri cfg)}
|
||||
req {:method :post
|
||||
:headers {"content-type" "application/x-www-form-urlencoded"
|
||||
"accept" "application/json"}
|
||||
:uri (:token-uri provider)
|
||||
:body (u/map->query-string params)}]
|
||||
(p/then
|
||||
(http-client req)
|
||||
(fn [{:keys [status body] :as res}]
|
||||
(if (= status 200)
|
||||
(let [data (json/read body)]
|
||||
{:token (get data :access_token)
|
||||
:type (get data :token_type)})
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-token
|
||||
:http-status status
|
||||
:http-body body))))))
|
||||
|
||||
(defn- retrieve-user-info
|
||||
[{:keys [provider] :as cfg} tdata]
|
||||
(try
|
||||
(let [req {:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
res (http/send! req)]
|
||||
[{:keys [provider http-client] :as cfg} tdata]
|
||||
(letfn [(retrieve []
|
||||
(http-client {:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get}))
|
||||
|
||||
(when (= 200 (:status res))
|
||||
(let [info (json/read-str (:body res) :key-fn keyword)]
|
||||
{:backend (:name provider)
|
||||
:email (:email info)
|
||||
:fullname (:name info)
|
||||
:props (dissoc info :name :email)})))
|
||||
(retrieve-emails []
|
||||
(if (some? (:emails-uri provider))
|
||||
(http-client {:uri (:emails-uri provider)
|
||||
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get})
|
||||
(p/resolved {:status 200})))
|
||||
|
||||
(catch Exception e
|
||||
(l/error :hint "unexpected exception on retrieve-user-info"
|
||||
:cause e)
|
||||
nil)))
|
||||
(validate-response [[retrieve-res emails-res]]
|
||||
(when-not (s/int-in-range? 200 300 (:status retrieve-res))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
:hint "unable to retrieve user info"
|
||||
:http-status (:status retrieve-res)
|
||||
:http-body (:body retrieve-res)))
|
||||
(when-not (s/int-in-range? 200 300 (:status emails-res))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
:hint "unable to retrieve user info"
|
||||
:http-status (:status emails-res)
|
||||
:http-body (:body emails-res)))
|
||||
[retrieve-res emails-res])
|
||||
|
||||
(get-email [info]
|
||||
(let [attr-kw (cf/get :oidc-email-attr :email)]
|
||||
(get info attr-kw)))
|
||||
|
||||
(get-name [info]
|
||||
(let [attr-kw (cf/get :oidc-name-attr :name)]
|
||||
(get info attr-kw)))
|
||||
|
||||
(process-response [[retrieve-res emails-res]]
|
||||
(let [info (json/read (:body retrieve-res))
|
||||
email (if (some? (:extract-email-callback provider))
|
||||
((:extract-email-callback provider) emails-res)
|
||||
(get-email info))]
|
||||
{:backend (:name provider)
|
||||
:email email
|
||||
:fullname (get-name info)
|
||||
:props (->> (dissoc info :name :email)
|
||||
(qualify-props provider))}))
|
||||
|
||||
(validate-info [info]
|
||||
(when-not (s/valid? ::info info)
|
||||
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
|
||||
:info (pr-str info))
|
||||
(ex/raise :type :internal
|
||||
:code :incomplete-user-info
|
||||
:hint "inconmplete user info"
|
||||
:info info))
|
||||
info)]
|
||||
|
||||
(-> (p/all [(retrieve) (retrieve-emails)])
|
||||
(p/then' validate-response)
|
||||
(p/then' process-response)
|
||||
(p/then' validate-info))))
|
||||
|
||||
(s/def ::backend ::us/not-empty-string)
|
||||
(s/def ::email ::us/not-empty-string)
|
||||
(s/def ::fullname ::us/not-empty-string)
|
||||
(s/def ::props (s/map-of ::us/keyword any?))
|
||||
|
||||
(s/def ::info
|
||||
(s/keys :req-un [::backend
|
||||
::email
|
||||
@@ -95,74 +149,66 @@
|
||||
::props]))
|
||||
|
||||
(defn retrieve-info
|
||||
[{:keys [tokens provider] :as cfg} request]
|
||||
(let [state (get-in request [:params :state])
|
||||
state (tokens :verify {:token state :iss :oauth})
|
||||
info (some->> (get-in request [:params :code])
|
||||
(retrieve-access-token cfg)
|
||||
(retrieve-user-info cfg))]
|
||||
[{:keys [tokens provider] :as cfg} {:keys [params] :as request}]
|
||||
(letfn [(validate-oidc [info]
|
||||
;; If the provider is OIDC, we can proceed to check
|
||||
;; roles if they are defined.
|
||||
(when (and (= "oidc" (:name provider))
|
||||
(seq (:roles provider)))
|
||||
(let [provider-roles (into #{} (:roles provider))
|
||||
profile-roles (let [attr (cf/get :oidc-roles-attr :roles)
|
||||
roles (get info attr)]
|
||||
(cond
|
||||
(string? roles) (into #{} (str/words roles))
|
||||
(vector? roles) (into #{} roles)
|
||||
:else #{}))]
|
||||
|
||||
(when-not (s/valid? ::info info)
|
||||
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
|
||||
:info (pr-str info))
|
||||
;; check if profile has a configured set of roles
|
||||
(when-not (set/subset? provider-roles profile-roles)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-auth
|
||||
:hint "not enough permissions"))))
|
||||
info)
|
||||
|
||||
(post-process [state info]
|
||||
(cond-> info
|
||||
(some? (:invitation-token state))
|
||||
(assoc :invitation-token (:invitation-token state))
|
||||
|
||||
;; If state token comes with props, merge them. The state token
|
||||
;; props can contain pm_ and utm_ prefixed query params.
|
||||
(map? (:props state))
|
||||
(update :props merge (:props state))))]
|
||||
|
||||
(when-let [error (get params :error)]
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-auth
|
||||
:hint "no user info"))
|
||||
:code :error-on-retrieving-code
|
||||
:error-id error
|
||||
:error-desc (get params :error_description)))
|
||||
|
||||
;; If the provider is OIDC, we can proceed to check
|
||||
;; roles if they are defined.
|
||||
(when (and (= "oidc" (:name provider))
|
||||
(seq (:roles provider)))
|
||||
(let [provider-roles (into #{} (:roles provider))
|
||||
profile-roles (let [attr (cf/get :oidc-roles-attr :roles)
|
||||
roles (get info attr)]
|
||||
(cond
|
||||
(string? roles) (into #{} (str/words roles))
|
||||
(vector? roles) (into #{} roles)
|
||||
:else #{}))]
|
||||
|
||||
;; check if profile has a configured set of roles
|
||||
(when-not (set/subset? provider-roles profile-roles)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-auth
|
||||
:hint "not enought permissions"))))
|
||||
|
||||
(cond-> info
|
||||
(some? (:invitation-token state))
|
||||
(assoc :invitation-token (:invitation-token state))
|
||||
|
||||
;; If state token comes with props, merge them. The state token
|
||||
;; props can contain pm_ and utm_ prefixed query params.
|
||||
(map? (:props state))
|
||||
(update :props merge (:props state)))))
|
||||
(let [state (get params :state)
|
||||
code (get params :code)
|
||||
state (tokens :verify {:token state :iss :oauth})]
|
||||
(-> (p/resolved code)
|
||||
(p/then #(retrieve-access-token cfg %))
|
||||
(p/then #(retrieve-user-info cfg %))
|
||||
(p/then' validate-oidc)
|
||||
(p/then' (partial post-process state))))))
|
||||
|
||||
;; --- HTTP HANDLERS
|
||||
|
||||
(defn extract-props
|
||||
[params]
|
||||
(reduce-kv (fn [params k v]
|
||||
(let [sk (name k)]
|
||||
(cond-> params
|
||||
(or (str/starts-with? sk "pm_")
|
||||
(str/starts-with? sk "pm-")
|
||||
(str/starts-with? sk "utm_"))
|
||||
(assoc (-> sk str/kebab keyword) v))))
|
||||
{}
|
||||
params))
|
||||
|
||||
(defn- retrieve-profile
|
||||
[{:keys [pool] :as cfg} info]
|
||||
(with-open [conn (db/open pool)]
|
||||
(some->> (:email info)
|
||||
(profile/retrieve-profile-data-by-email conn)
|
||||
(profile/populate-additional-data conn)
|
||||
(profile/decode-profile-row))))
|
||||
[{:keys [pool executor] :as cfg} info]
|
||||
(px/with-dispatch executor
|
||||
(with-open [conn (db/open pool)]
|
||||
(some->> (:email info)
|
||||
(profile/retrieve-profile-data-by-email conn)
|
||||
(profile/populate-additional-data conn)
|
||||
(profile/decode-profile-row)))))
|
||||
|
||||
(defn- redirect-response
|
||||
[uri]
|
||||
{:status 302
|
||||
:headers {"location" (str uri)}
|
||||
:body ""})
|
||||
(yrs/response :status 302 :headers {"location" (str uri)}))
|
||||
|
||||
(defn- generate-error-redirect
|
||||
[cfg error]
|
||||
@@ -195,8 +241,10 @@
|
||||
|
||||
(->> (redirect-response uri)
|
||||
(sxf request)))
|
||||
|
||||
(let [info (assoc info
|
||||
:iss :prepared-register
|
||||
:is-active true
|
||||
:exp (dt/in-future {:hours 48}))
|
||||
token (tokens :generate info)
|
||||
params (d/without-nils
|
||||
@@ -208,28 +256,33 @@
|
||||
(redirect-response uri))))
|
||||
|
||||
(defn- auth-handler
|
||||
[{:keys [tokens] :as cfg} {:keys [params] :as request}]
|
||||
(let [invitation (:invitation-token params)
|
||||
props (extract-props params)
|
||||
state (tokens :generate
|
||||
{:iss :oauth
|
||||
:invitation-token invitation
|
||||
:props props
|
||||
:exp (dt/in-future "15m")})
|
||||
uri (build-auth-uri cfg state)]
|
||||
{:status 200
|
||||
:body {:redirect-uri uri}}))
|
||||
[{:keys [tokens] :as cfg} {:keys [params] :as request} respond raise]
|
||||
(try
|
||||
(let [props (audit/extract-utm-params params)
|
||||
state (tokens :generate
|
||||
{:iss :oauth
|
||||
:invitation-token (:invitation-token params)
|
||||
:props props
|
||||
:exp (dt/in-future "15m")})
|
||||
uri (build-auth-uri cfg state)]
|
||||
(respond (yrs/response 200 {:redirect-uri uri})))
|
||||
(catch Throwable cause
|
||||
(raise cause))))
|
||||
|
||||
(defn- callback-handler
|
||||
[cfg request]
|
||||
(try
|
||||
(let [info (retrieve-info cfg request)
|
||||
profile (retrieve-profile cfg info)]
|
||||
(generate-redirect cfg request info profile))
|
||||
(catch Exception e
|
||||
(l/warn :hint "error on oauth process"
|
||||
:cause e)
|
||||
(generate-error-redirect cfg e))))
|
||||
[cfg request respond _]
|
||||
(letfn [(process-request []
|
||||
(p/let [info (retrieve-info cfg request)
|
||||
profile (retrieve-profile cfg info)]
|
||||
(generate-redirect cfg request info profile)))
|
||||
|
||||
(handle-error [cause]
|
||||
(l/error :hint "error on oauth process" :cause cause)
|
||||
(respond (generate-error-redirect cfg cause)))]
|
||||
|
||||
(-> (process-request)
|
||||
(p/then respond)
|
||||
(p/catch handle-error))))
|
||||
|
||||
;; --- INIT
|
||||
|
||||
@@ -245,15 +298,19 @@
|
||||
|
||||
(defn wrap-handler
|
||||
[cfg handler]
|
||||
(fn [request]
|
||||
(fn [request respond raise]
|
||||
(let [provider (get-in request [:path-params :provider])
|
||||
provider (get-in @cfg [:providers provider])]
|
||||
(when-not provider
|
||||
(ex/raise :type :not-found
|
||||
:context {:provider provider}
|
||||
:hint "provider not configured"))
|
||||
(-> (assoc @cfg :provider provider)
|
||||
(handler request)))))
|
||||
(if provider
|
||||
(handler (assoc @cfg :provider provider)
|
||||
request
|
||||
respond
|
||||
raise)
|
||||
(raise
|
||||
(ex/error
|
||||
:type :not-found
|
||||
:provider provider
|
||||
:hint "provider not configured"))))))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
@@ -262,15 +319,30 @@
|
||||
:callback-handler (wrap-handler cfg callback-handler)}))
|
||||
|
||||
(defn- discover-oidc-config
|
||||
[{:keys [base-uri] :as opts}]
|
||||
[{:keys [http-client]} {:keys [base-uri] :as opts}]
|
||||
|
||||
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
|
||||
response (http/send! {:method :get :uri (str discovery-uri)})]
|
||||
(when (= 200 (:status response))
|
||||
(let [data (json/read-str (:body response))]
|
||||
(assoc opts
|
||||
:token-uri (get data "token_endpoint")
|
||||
:auth-uri (get data "authorization_endpoint")
|
||||
:user-uri (get data "userinfo_endpoint"))))))
|
||||
response (ex/try (http-client {:method :get :uri (str discovery-uri)} {:sync? true}))]
|
||||
(cond
|
||||
(ex/exception? response)
|
||||
(do
|
||||
(l/warn :hint "unable to discover oidc configuration"
|
||||
:discover-uri (str discovery-uri)
|
||||
:cause response)
|
||||
nil)
|
||||
|
||||
(= 200 (:status response))
|
||||
(let [data (json/read (:body response))]
|
||||
{:token-uri (get data :token_endpoint)
|
||||
:auth-uri (get data :authorization_endpoint)
|
||||
:user-uri (get data :userinfo_endpoint)})
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/warn :hint "unable to discover OIDC configuration"
|
||||
:uri (str discovery-uri)
|
||||
:response-status-code (:status response))
|
||||
nil))))
|
||||
|
||||
(defn- obfuscate-string
|
||||
[s]
|
||||
@@ -291,20 +363,27 @@
|
||||
:roles-attr (cf/get :oidc-roles-attr)
|
||||
:roles (cf/get :oidc-roles)
|
||||
:name "oidc"}]
|
||||
|
||||
(if (and (string? (:base-uri opts))
|
||||
(string? (:client-id opts))
|
||||
(string? (:client-secret opts)))
|
||||
(if (and (string? (:token-uri opts))
|
||||
(string? (:user-uri opts))
|
||||
(string? (:auth-uri opts)))
|
||||
(do
|
||||
(l/info :action "initialize" :provider "oidc" :method "static"
|
||||
:opts (pr-str (update opts :client-secret obfuscate-string)))
|
||||
(assoc-in cfg [:providers "oidc"] opts))
|
||||
(let [opts (discover-oidc-config opts)]
|
||||
(l/info :action "initialize" :provider "oidc" :method "discover"
|
||||
:opts (pr-str (update opts :client-secret obfuscate-string)))
|
||||
(assoc-in cfg [:providers "oidc"] opts)))
|
||||
(do
|
||||
(l/debug :hint "initialize oidc provider" :name "generic-oidc"
|
||||
:opts (update opts :client-secret obfuscate-string))
|
||||
(if (and (string? (:token-uri opts))
|
||||
(string? (:user-uri opts))
|
||||
(string? (:auth-uri opts)))
|
||||
(do
|
||||
(l/debug :hint "initialized with user provided configuration")
|
||||
(assoc-in cfg [:providers "oidc"] opts))
|
||||
(do
|
||||
(l/debug :hint "trying to discover oidc provider configuration using BASE_URI")
|
||||
(if-let [opts' (discover-oidc-config cfg opts)]
|
||||
(do
|
||||
(l/debug :hint "discovered opts" :additional-opts opts')
|
||||
(assoc-in cfg [:providers "oidc"] (merge opts opts')))
|
||||
|
||||
cfg))))
|
||||
cfg)))
|
||||
|
||||
(defn- initialize-google-provider
|
||||
@@ -324,15 +403,25 @@
|
||||
(assoc-in cfg [:providers "google"] opts))
|
||||
cfg)))
|
||||
|
||||
(defn extract-github-email
|
||||
[response]
|
||||
(let [emails (json/read (:body response))
|
||||
primary-email (->> emails
|
||||
(filter #(:primary %))
|
||||
first)]
|
||||
(:email primary-email)))
|
||||
|
||||
(defn- initialize-github-provider
|
||||
[cfg]
|
||||
(let [opts {:client-id (cf/get :github-client-id)
|
||||
:client-secret (cf/get :github-client-secret)
|
||||
:scopes #{"read:user" "user:email"}
|
||||
:auth-uri "https://github.com/login/oauth/authorize"
|
||||
:token-uri "https://github.com/login/oauth/access_token"
|
||||
:user-uri "https://api.github.com/user"
|
||||
:name "github"}]
|
||||
(let [opts {:client-id (cf/get :github-client-id)
|
||||
:client-secret (cf/get :github-client-secret)
|
||||
:scopes #{"read:user" "user:email"}
|
||||
:auth-uri "https://github.com/login/oauth/authorize"
|
||||
:token-uri "https://github.com/login/oauth/access_token"
|
||||
:emails-uri "https://api.github.com/user/emails"
|
||||
:extract-email-callback extract-github-email
|
||||
:user-uri "https://api.github.com/user"
|
||||
:name "github"}]
|
||||
(if (and (string? (:client-id opts))
|
||||
(string? (:client-secret opts)))
|
||||
(do
|
||||
@@ -341,17 +430,16 @@
|
||||
(assoc-in cfg [:providers "github"] opts))
|
||||
cfg)))
|
||||
|
||||
|
||||
(defn- initialize-gitlab-provider
|
||||
[cfg]
|
||||
(let [base (cf/get :gitlab-base-uri "https://gitlab.com")
|
||||
opts {:base-uri base
|
||||
:client-id (cf/get :gitlab-client-id)
|
||||
:client-secret (cf/get :gitlab-client-secret)
|
||||
:scopes #{"read_user"}
|
||||
:scopes #{"openid" "profile" "email"}
|
||||
:auth-uri (str base "/oauth/authorize")
|
||||
:token-uri (str base "/oauth/token")
|
||||
:user-uri (str base "/api/v4/user")
|
||||
:user-uri (str base "/oauth/userinfo")
|
||||
:name "gitlab"}]
|
||||
(if (and (string? (:client-id opts))
|
||||
(string? (:client-secret opts)))
|
||||
|
||||
@@ -8,102 +8,212 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cfg]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.async :as aa]
|
||||
[app.util.logging :as l]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]))
|
||||
|
||||
;; A default cookie name for storing the session. We don't allow
|
||||
;; configure it.
|
||||
(def cookie-name "auth-token")
|
||||
;; A default cookie name for storing the session. We don't allow to configure it.
|
||||
(def token-cookie-name "auth-token")
|
||||
|
||||
;; A cookie that we can use to check from other sites of the same domain if a user
|
||||
;; is registered. Is not intended for on premise installations, although nothing
|
||||
;; prevents using it if some one wants to.
|
||||
(def authenticated-cookie-name "authenticated")
|
||||
|
||||
(defprotocol ISessionStore
|
||||
(read-session [store key])
|
||||
(write-session [store key data])
|
||||
(delete-session [store key]))
|
||||
|
||||
(defn- make-database-store
|
||||
[{:keys [pool tokens executor]}]
|
||||
(reify ISessionStore
|
||||
(read-session [_ token]
|
||||
(px/with-dispatch executor
|
||||
(db/exec-one! pool (sql/select :http-session {:id token}))))
|
||||
|
||||
(write-session [_ _ data]
|
||||
(px/with-dispatch executor
|
||||
(let [profile-id (:profile-id data)
|
||||
user-agent (:user-agent data)
|
||||
token (tokens :generate {:iss "authentication"
|
||||
:iat (dt/now)
|
||||
:uid profile-id})
|
||||
|
||||
now (dt/now)
|
||||
params {:user-agent user-agent
|
||||
:profile-id profile-id
|
||||
:created-at now
|
||||
:updated-at now
|
||||
:id token}]
|
||||
(db/insert! pool :http-session params)
|
||||
token)))
|
||||
|
||||
(delete-session [_ token]
|
||||
(px/with-dispatch executor
|
||||
(db/delete! pool :http-session {:id token})
|
||||
nil))))
|
||||
|
||||
(defn make-inmemory-store
|
||||
[{:keys [tokens]}]
|
||||
(let [cache (atom {})]
|
||||
(reify ISessionStore
|
||||
(read-session [_ token]
|
||||
(p/do (get @cache token)))
|
||||
|
||||
(write-session [_ _ data]
|
||||
(p/do
|
||||
(let [profile-id (:profile-id data)
|
||||
user-agent (:user-agent data)
|
||||
token (tokens :generate {:iss "authentication"
|
||||
:iat (dt/now)
|
||||
:uid profile-id})
|
||||
params {:user-agent user-agent
|
||||
:profile-id profile-id
|
||||
:id token}]
|
||||
|
||||
(swap! cache assoc token params)
|
||||
token)))
|
||||
|
||||
(delete-session [_ token]
|
||||
(p/do
|
||||
(swap! cache dissoc token)
|
||||
nil)))))
|
||||
|
||||
(s/def ::tokens fn?)
|
||||
(defmethod ig/pre-init-spec ::store [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor ::tokens]))
|
||||
|
||||
(defmethod ig/init-key ::store
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
(if (db/read-only? pool)
|
||||
(make-inmemory-store cfg)
|
||||
(make-database-store cfg)))
|
||||
|
||||
(defmethod ig/halt-key! ::store
|
||||
[_ _])
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
(defn- create-session
|
||||
[{:keys [conn tokens] :as cfg} {:keys [profile-id headers] :as request}]
|
||||
(let [token (tokens :generate {:iss "authentication"
|
||||
:iat (dt/now)
|
||||
:uid profile-id})
|
||||
params {:user-agent (get headers "user-agent")
|
||||
:profile-id profile-id
|
||||
:id token}]
|
||||
(db/insert! conn :http-session params)))
|
||||
(defn- create-session!
|
||||
[store request profile-id]
|
||||
(let [params {:user-agent (yrq/get-header request "user-agent")
|
||||
:profile-id profile-id}]
|
||||
(write-session store nil params)))
|
||||
|
||||
(defn- delete-session
|
||||
[{:keys [conn] :as cfg} {:keys [cookies] :as request}]
|
||||
(when-let [token (get-in cookies [cookie-name :value])]
|
||||
(db/delete! conn :http-session {:id token}))
|
||||
nil)
|
||||
(defn- delete-session!
|
||||
[store {:keys [cookies] :as request}]
|
||||
(when-let [token (get-in cookies [token-cookie-name :value])]
|
||||
(delete-session store token)))
|
||||
|
||||
(defn- retrieve-session
|
||||
[{:keys [conn] :as cfg} id]
|
||||
(when id
|
||||
(db/exec-one! conn ["select id, profile_id from http_session where id = ?" id])))
|
||||
|
||||
(defn- retrieve-from-request
|
||||
[cfg {:keys [cookies] :as request}]
|
||||
(->> (get-in cookies [cookie-name :value])
|
||||
(retrieve-session cfg)))
|
||||
[store request]
|
||||
(when-let [cookie (yrq/get-cookie request token-cookie-name)]
|
||||
(-> (read-session store (:value cookie))
|
||||
(p/then (fn [session]
|
||||
(when session
|
||||
{:session-id (:id session)
|
||||
:profile-id (:profile-id session)}))))))
|
||||
|
||||
(defn- add-cookies
|
||||
[response {:keys [id] :as session}]
|
||||
(assoc response :cookies {cookie-name {:path "/" :http-only true :value id}}))
|
||||
[response token]
|
||||
(let [cors? (contains? cfg/flags :cors)
|
||||
secure? (contains? cfg/flags :secure-session-cookies)
|
||||
authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)]
|
||||
(update response :cookies
|
||||
(fn [cookies]
|
||||
(cond-> cookies
|
||||
:always
|
||||
(assoc token-cookie-name {:path "/"
|
||||
:http-only true
|
||||
:value token
|
||||
:same-site (if cors? :none :lax)
|
||||
:secure secure?})
|
||||
|
||||
(some? authenticated-cookie-domain)
|
||||
(assoc authenticated-cookie-name {:domain authenticated-cookie-domain
|
||||
:path "/"
|
||||
:value true
|
||||
:same-site :strict
|
||||
:secure secure?}))))))
|
||||
|
||||
(defn- clear-cookies
|
||||
[response]
|
||||
(assoc response :cookies {cookie-name {:value "" :max-age -1}}))
|
||||
(let [authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)]
|
||||
(assoc response :cookies
|
||||
{token-cookie-name {:path "/"
|
||||
:value ""
|
||||
:max-age -1}
|
||||
authenticated-cookie-name {:domain authenticated-cookie-domain
|
||||
:path "/"
|
||||
:value ""
|
||||
:max-age -1}})))
|
||||
|
||||
(defn- make-middleware
|
||||
[{:keys [::events-ch store] :as cfg}]
|
||||
{:name :session-middleware
|
||||
:wrap (fn [handler]
|
||||
(fn [request respond raise]
|
||||
(try
|
||||
(-> (retrieve-session store request)
|
||||
(p/then' #(merge request %))
|
||||
(p/finally (fn [request cause]
|
||||
(if cause
|
||||
(raise cause)
|
||||
(do
|
||||
(when-let [session-id (:session-id request)]
|
||||
(a/offer! events-ch session-id))
|
||||
(handler request respond raise))))))
|
||||
(catch Throwable cause
|
||||
(raise cause)))))})
|
||||
|
||||
(defn- middleware
|
||||
[cfg handler]
|
||||
(fn [request]
|
||||
(if-let [{:keys [id profile-id] :as session} (retrieve-from-request cfg request)]
|
||||
(do
|
||||
(a/>!! (::events-ch cfg) id)
|
||||
(l/update-thread-context! {:profile-id profile-id})
|
||||
(handler (assoc request :profile-id profile-id)))
|
||||
(handler request))))
|
||||
|
||||
;; --- STATE INIT: SESSION
|
||||
|
||||
(defmethod ig/pre-init-spec ::session [_]
|
||||
(s/keys :req-un [::db/pool]))
|
||||
(s/def ::store #(satisfies? ISessionStore %))
|
||||
|
||||
(defmethod ig/prep-key ::session
|
||||
(defmethod ig/pre-init-spec :app.http/session [_]
|
||||
(s/keys :req-un [::store]))
|
||||
|
||||
(defmethod ig/prep-key :app.http/session
|
||||
[_ cfg]
|
||||
(d/merge {:buffer-size 64}
|
||||
(d/merge {:buffer-size 128}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(defmethod ig/init-key ::session
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
(let [events (a/chan (a/dropping-buffer (:buffer-size cfg)))
|
||||
cfg (-> cfg
|
||||
(assoc :conn pool)
|
||||
(assoc ::events-ch events))]
|
||||
(defmethod ig/init-key :app.http/session
|
||||
[_ {:keys [store] :as cfg}]
|
||||
(let [events-ch (a/chan (a/dropping-buffer (:buffer-size cfg)))
|
||||
cfg (assoc cfg ::events-ch events-ch)]
|
||||
|
||||
(-> cfg
|
||||
(assoc :middleware #(middleware cfg %))
|
||||
(assoc :middleware (make-middleware cfg))
|
||||
(assoc :create (fn [profile-id]
|
||||
(fn [request response]
|
||||
(let [request (assoc request :profile-id profile-id)
|
||||
session (create-session cfg request)]
|
||||
(add-cookies response session)))))
|
||||
(p/let [token (create-session! store request profile-id)]
|
||||
(add-cookies response token)))))
|
||||
(assoc :delete (fn [request response]
|
||||
(delete-session cfg request)
|
||||
(-> response
|
||||
(assoc :status 204)
|
||||
(assoc :body "")
|
||||
(clear-cookies)))))))
|
||||
(p/do
|
||||
(delete-session! store request)
|
||||
(-> response
|
||||
(assoc :status 204)
|
||||
(assoc :body nil)
|
||||
(clear-cookies))))))))
|
||||
|
||||
(defmethod ig/halt-key! ::session
|
||||
(defmethod ig/halt-key! :app.http/session
|
||||
[_ data]
|
||||
(a/close! (::events-ch data)))
|
||||
|
||||
|
||||
;; --- STATE INIT: SESSION UPDATER
|
||||
|
||||
(declare update-sessions)
|
||||
@@ -114,8 +224,7 @@
|
||||
|
||||
(defmethod ig/pre-init-spec ::updater [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor ::mtx/metrics ::session]
|
||||
:opt-un [::max-batch-age
|
||||
::max-batch-size]))
|
||||
:opt-un [::max-batch-age ::max-batch-size]))
|
||||
|
||||
(defmethod ig/prep-key ::updater
|
||||
[_ cfg]
|
||||
@@ -130,16 +239,11 @@
|
||||
:max-batch-size (str (:max-batch-size cfg)))
|
||||
(let [input (aa/batch (::events-ch session)
|
||||
{:max-batch-size (:max-batch-size cfg)
|
||||
:max-batch-age (inst-ms (:max-batch-age cfg))})
|
||||
mcnt (mtx/create
|
||||
{:name "http_session_update_total"
|
||||
:help "A counter of session update batch events."
|
||||
:registry (:registry metrics)
|
||||
:type :counter})]
|
||||
:max-batch-age (inst-ms (:max-batch-age cfg))})]
|
||||
(a/go-loop []
|
||||
(when-let [[reason batch] (a/<! input)]
|
||||
(let [result (a/<! (update-sessions cfg batch))]
|
||||
(mcnt :inc)
|
||||
(mtx/run! metrics {:id :session-update-total :inc 1})
|
||||
(cond
|
||||
(ex/exception? result)
|
||||
(l/error :task "updater"
|
||||
@@ -148,9 +252,10 @@
|
||||
|
||||
(= :size reason)
|
||||
(l/debug :task "updater"
|
||||
:action "update sessions"
|
||||
:hint "update sessions"
|
||||
:reason (name reason)
|
||||
:count result))
|
||||
|
||||
(recur))))))
|
||||
|
||||
(defn- update-sessions
|
||||
@@ -172,22 +277,25 @@
|
||||
|
||||
(defmethod ig/prep-key ::gc-task
|
||||
[_ cfg]
|
||||
(merge {:max-age (dt/duration {:days 2})}
|
||||
(merge {:max-age (dt/duration {:days 15})}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(defmethod ig/init-key ::gc-task
|
||||
[_ {:keys [pool max-age] :as cfg}]
|
||||
(l/debug :hint "initializing session gc task" :max-age max-age)
|
||||
(fn [_]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [interval (db/interval max-age)
|
||||
result (db/exec-one! conn [sql:delete-expired interval])
|
||||
result (db/exec-one! conn [sql:delete-expired interval interval])
|
||||
result (:next.jdbc/update-count result)]
|
||||
(l/debug :task "gc"
|
||||
:action "clean http sessions"
|
||||
:count result)
|
||||
:hint "clean http sessions"
|
||||
:deleted result)
|
||||
result))))
|
||||
|
||||
(def ^:private
|
||||
sql:delete-expired
|
||||
"delete from http_session
|
||||
where updated_at < now() - ?::interval")
|
||||
where updated_at < now() - ?::interval
|
||||
or (updated_at is null and
|
||||
created_at < now() - ?::interval)")
|
||||
|
||||
223
backend/src/app/http/websocket.clj
Normal file
223
backend/src/app/http/websocket.clj
Normal file
@@ -0,0 +1,223 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.http.websocket
|
||||
"A penpot notification service for file cooperative edition."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.websocket :as ws]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[yetti.websocket :as yws]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; WEBSOCKET HANDLER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmulti handle-message
|
||||
(fn [_ message]
|
||||
(:type message)))
|
||||
|
||||
(defmethod handle-message :connect
|
||||
[wsp _]
|
||||
(l/trace :fn "handle-message" :event :connect)
|
||||
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
|
||||
xform (remove #(= (:session-id %) session-id))
|
||||
channel (a/chan (a/dropping-buffer 16) xform)]
|
||||
|
||||
(swap! wsp assoc ::profile-subs-channel channel)
|
||||
(a/pipe channel output-ch false)
|
||||
(msgbus-fn :cmd :sub :topic profile-id :chan channel)))
|
||||
|
||||
(defmethod handle-message :disconnect
|
||||
[wsp _]
|
||||
(l/trace :fn "handle-message" :event :disconnect)
|
||||
(a/go
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
profile-ch (::profile-subs-channel @wsp)
|
||||
subs (::subscriptions @wsp)]
|
||||
|
||||
;; Close the main profile subscription
|
||||
(a/close! profile-ch)
|
||||
(a/<! (msgbus-fn :cmd :purge :chans [profile-ch]))
|
||||
|
||||
;; Close all other active subscrption on this websocket context.
|
||||
(doseq [{:keys [channel topic]} (map second subs)]
|
||||
(a/close! channel)
|
||||
(a/<! (msgbus-fn :cmd :pub :topic topic
|
||||
:message {:type :disconnect
|
||||
:profile-id profile-id
|
||||
:session-id session-id}))
|
||||
(a/<! (msgbus-fn :cmd :purge :chans [channel]))))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[wsp {:keys [team-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event :subscribe-team :team-id team-id)
|
||||
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
subs (get-in @wsp [::subscriptions team-id])
|
||||
xform (comp
|
||||
(remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id team-id)))]
|
||||
|
||||
(a/go
|
||||
(when (not= (:team-id subs) team-id)
|
||||
;; if it exists we just need to close that
|
||||
(when-let [channel (:channel subs)]
|
||||
(a/close! channel)
|
||||
(a/<! (msgbus-fn :cmd :purge :chans [channel])))
|
||||
|
||||
|
||||
(let [channel (a/chan (a/dropping-buffer 64) xform)]
|
||||
;; Message forwarding
|
||||
(a/pipe channel output-ch false)
|
||||
|
||||
(let [state {:team-id team-id :channel channel :topic team-id}]
|
||||
(swap! wsp update ::subscriptions assoc team-id state))
|
||||
|
||||
(a/<! (msgbus-fn :cmd :sub :topic team-id :chan channel)))))))
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[wsp {:keys [subs-id file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event :subscribe-file :subs-id subs-id :file-id file-id)
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
|
||||
xform (comp
|
||||
(remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id subs-id)))
|
||||
|
||||
channel (a/chan (a/dropping-buffer 64) xform)]
|
||||
|
||||
;; Message forwarding
|
||||
(a/go-loop []
|
||||
(when-let [{:keys [type] :as message} (a/<! channel)]
|
||||
(when (or (= :join-file type)
|
||||
(= :leave-file type)
|
||||
(= :disconnect type))
|
||||
(let [message {:type :presence
|
||||
:file-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(a/<! (msgbus-fn :cmd :pub
|
||||
:topic file-id
|
||||
:message message))))
|
||||
(a/>! output-ch message)
|
||||
(recur)))
|
||||
|
||||
(let [state {:file-id file-id :channel channel :topic file-id}]
|
||||
(swap! wsp update ::subscriptions assoc subs-id state))
|
||||
|
||||
(a/go
|
||||
;; Subscribe to file topic
|
||||
(a/<! (msgbus-fn :cmd :sub :topic file-id :chan channel))
|
||||
|
||||
;; Notifify the rest of participants of the new connection.
|
||||
(let [message {:type :join-file
|
||||
:file-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(a/<! (msgbus-fn :cmd :pub
|
||||
:topic file-id
|
||||
:message message))))))
|
||||
|
||||
(defmethod handle-message :unsubscribe-file
|
||||
[wsp {:keys [subs-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event :unsubscribe-file :subs-id subs-id)
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
profile-id (::profile-id @wsp)]
|
||||
(a/go
|
||||
(when-let [{:keys [file-id channel]} (get-in @wsp [::subscriptions subs-id])]
|
||||
(let [message {:type :leave-file
|
||||
:file-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(a/close! channel)
|
||||
(a/<! (msgbus-fn :cmd :pub :topic file-id :message message))
|
||||
(a/<! (msgbus-fn :cmd :purge :chans [channel])))))))
|
||||
|
||||
(defmethod handle-message :keepalive
|
||||
[_ _]
|
||||
(l/trace :fn "handle-message" :event :keepalive)
|
||||
(a/go :nothing))
|
||||
|
||||
(defmethod handle-message :pointer-update
|
||||
[wsp {:keys [subs-id] :as message}]
|
||||
(a/go
|
||||
;; Only allow receive pointer updates when active subscription
|
||||
(when-let [{:keys [topic]} (get-in @wsp [::subscriptions subs-id])]
|
||||
(let [msgbus-fn (:msgbus @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
message (-> message
|
||||
(dissoc :subs-id)
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :session-id session-id))]
|
||||
|
||||
(a/<! (msgbus-fn :cmd :pub
|
||||
:topic topic
|
||||
:message message))))))
|
||||
|
||||
(defmethod handle-message :default
|
||||
[_ message]
|
||||
(a/go
|
||||
(l/log :level :warn
|
||||
:msg "received unexpected message"
|
||||
:message message)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP HANDLER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::msgbus fn?)
|
||||
(s/def ::session-id ::us/uuid)
|
||||
|
||||
(s/def ::handler-params
|
||||
(s/keys :req-un [::session-id]))
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::msgbus ::db/pool ::mtx/metrics]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [profile-id params] :as req} respond raise]
|
||||
(let [{:keys [session-id]} (us/conform ::handler-params params)
|
||||
cfg (-> cfg
|
||||
(assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))]
|
||||
|
||||
(l/trace :hint "http request to websocket" :profile-id profile-id :session-id session-id)
|
||||
(cond
|
||||
(not profile-id)
|
||||
(raise (ex/error :type :authentication
|
||||
:hint "Authentication required."))
|
||||
|
||||
(not (yws/upgrade-request? req))
|
||||
(raise (ex/error :type :validation
|
||||
:code :websocket-request-expected
|
||||
:hint "this endpoint only accepts websocket connections"))
|
||||
|
||||
:else
|
||||
(->> (ws/handler handle-message cfg)
|
||||
(yws/upgrade req)
|
||||
(respond))))))
|
||||
@@ -9,63 +9,143 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.util.async :as aa]
|
||||
[app.util.http :as http]
|
||||
[app.util.logging :as l]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[lambdaisland.uri :as u]))
|
||||
[lambdaisland.uri :as u]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(defn parse-client-ip
|
||||
[{:keys [headers] :as request}]
|
||||
(or (some-> (get headers "x-forwarded-for") (str/split ",") first)
|
||||
(get headers "x-real-ip")
|
||||
(get request :remote-addr)))
|
||||
[request]
|
||||
(or (some-> (yrq/get-header request "x-forwarded-for") (str/split ",") first)
|
||||
(yrq/get-header request "x-real-ip")
|
||||
(yrq/remote-addr request)))
|
||||
|
||||
(defn extract-utm-params
|
||||
"Extracts additional data from params and namespace them under
|
||||
`penpot` ns."
|
||||
[params]
|
||||
(letfn [(process-param [params k v]
|
||||
(let [sk (d/name k)]
|
||||
(cond-> params
|
||||
(str/starts-with? sk "utm_")
|
||||
(assoc (->> sk str/kebab (keyword "penpot")) v)
|
||||
|
||||
(str/starts-with? sk "mtm_")
|
||||
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
|
||||
(reduce-kv process-param {} params)))
|
||||
|
||||
(defn profile->props
|
||||
[profile]
|
||||
(-> profile
|
||||
(select-keys [:is-active :is-muted :auth-backend :email :default-team-id :default-project-id :fullname :lang])
|
||||
(merge (:props profile))
|
||||
(d/without-nils)))
|
||||
|
||||
(defn clean-props
|
||||
[{:keys [profile-id] :as event}]
|
||||
(letfn [(clean-common [props]
|
||||
(-> props
|
||||
(dissoc :session-id)
|
||||
(dissoc :password)
|
||||
(dissoc :old-password)
|
||||
(dissoc :token)))
|
||||
(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)))))]
|
||||
|
||||
(clean-profile-id [props]
|
||||
(cond-> props
|
||||
(= profile-id (:profile-id props))
|
||||
(dissoc :profile-id)))
|
||||
(update event :props #(into {} xform %))))
|
||||
|
||||
(clean-complex-data [props]
|
||||
(reduce-kv (fn [props k v]
|
||||
(cond-> props
|
||||
(or (string? v)
|
||||
(uuid? v)
|
||||
(boolean? v)
|
||||
(number? v))
|
||||
(assoc k v)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP Handler
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(keyword? v)
|
||||
(assoc k (name v))))
|
||||
{}
|
||||
props))]
|
||||
(declare persist-http-events)
|
||||
|
||||
(update event :props #(-> % clean-common clean-profile-id clean-complex-data))))
|
||||
(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?))
|
||||
(s/def ::timestamp dt/instant?)
|
||||
(s/def ::context (s/map-of ::us/keyword any?))
|
||||
|
||||
(s/def ::frontend-event
|
||||
(s/keys :req-un [::type ::name ::props ::timestamp ::profile-id]
|
||||
:opt-un [::context]))
|
||||
|
||||
(s/def ::frontend-events (s/every ::frontend-event))
|
||||
|
||||
(defmethod ig/init-key ::http-handler
|
||||
[_ {:keys [executor pool] :as cfg}]
|
||||
(if (or (db/read-only? pool) (not (contains? cf/flags :audit-log)))
|
||||
(do
|
||||
(l/warn :hint "audit log http handler disabled or db is read-only")
|
||||
(fn [_ respond _]
|
||||
(respond (yrs/response 204))))
|
||||
|
||||
(letfn [(handler [{:keys [profile-id] :as request}]
|
||||
(let [events (->> (:events (:params request))
|
||||
(remove #(not= profile-id (:profile-id %)))
|
||||
(us/conform ::frontend-events))
|
||||
|
||||
ip-addr (parse-client-ip request)
|
||||
cfg (-> cfg
|
||||
(assoc :source "frontend")
|
||||
(assoc :events events)
|
||||
(assoc :ip-addr ip-addr))]
|
||||
(persist-http-events cfg)))
|
||||
|
||||
(handle-error [cause]
|
||||
(let [xdata (ex-data cause)]
|
||||
(if (= :spec-validation (:code xdata))
|
||||
(l/error ::l/raw (str "spec validation on persist-events:\n" (us/pretty-explain xdata)))
|
||||
(l/error :hint "error on persist-events" :cause cause))))]
|
||||
|
||||
(fn [request respond _]
|
||||
;; Fire and forget, log error in case of errro
|
||||
(-> (px/submit! executor #(handler request))
|
||||
(p/catch handle-error))
|
||||
|
||||
(respond (yrs/response 204))))))
|
||||
|
||||
(defn- persist-http-events
|
||||
[{:keys [pool events ip-addr source] :as cfg}]
|
||||
(let [columns [:id :name :source :type :tracked-at :profile-id :ip-addr :props :context]
|
||||
prepare-xf (map (fn [event]
|
||||
[(uuid/next)
|
||||
(:name event)
|
||||
source
|
||||
(:type event)
|
||||
(:timestamp event)
|
||||
(:profile-id event)
|
||||
(db/inet ip-addr)
|
||||
(db/tjson (:props event))
|
||||
(db/tjson (d/without-nils (:context event)))]))]
|
||||
(when (seq events)
|
||||
(->> (into [] prepare-xf events)
|
||||
(db/insert-multi! pool :audit-log columns)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Collector
|
||||
@@ -76,39 +156,57 @@
|
||||
;; an external storage and data cleared.
|
||||
|
||||
(declare persist-events)
|
||||
(s/def ::enabled ::us/boolean)
|
||||
|
||||
(defmethod ig/pre-init-spec ::collector [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor ::enabled]))
|
||||
(s/keys :req-un [::db/pool ::wrk/executor]))
|
||||
|
||||
(def event-xform
|
||||
(s/def ::ip-addr string?)
|
||||
(s/def ::backend-event
|
||||
(s/keys :req-un [::type ::name ::profile-id]
|
||||
:opt-un [::ip-addr ::props]))
|
||||
|
||||
(def ^:private backend-event-xform
|
||||
(comp
|
||||
(filter :profile-id)
|
||||
(filter #(us/valid? ::backend-event %))
|
||||
(map clean-props)))
|
||||
|
||||
(defmethod ig/init-key ::collector
|
||||
[_ {:keys [enabled] :as cfg}]
|
||||
(when enabled
|
||||
(l/info :msg "intializing audit collector")
|
||||
(let [input (a/chan 512 event-xform)
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
(cond
|
||||
(not (contains? cf/flags :audit-log))
|
||||
(do
|
||||
(l/info :hint "audit log collection disabled")
|
||||
(constantly nil))
|
||||
|
||||
(db/read-only? pool)
|
||||
(do
|
||||
(l/warn :hint "audit log collection disabled, db is read-only")
|
||||
(constantly nil))
|
||||
|
||||
:else
|
||||
(let [input (a/chan 512 backend-event-xform)
|
||||
buffer (aa/batch input {:max-batch-size 100
|
||||
:max-batch-age (* 10 1000) ; 10s
|
||||
:init []})]
|
||||
(l/info :hint "audit log collector initialized")
|
||||
(a/go-loop []
|
||||
(when-let [[_type events] (a/<! buffer)]
|
||||
(let [res (a/<! (persist-events cfg events))]
|
||||
(when (ex/exception? res)
|
||||
(l/error :hint "error on persiting events"
|
||||
:cause res)))
|
||||
(recur)))
|
||||
(l/error :hint "error on persisting events" :cause res))
|
||||
(recur))))
|
||||
|
||||
(fn [& {:keys [cmd] :as params}]
|
||||
(let [params (dissoc params :cmd)]
|
||||
(case cmd
|
||||
:stop (a/close! input)
|
||||
:submit (when-not (a/offer! input params)
|
||||
(l/warn :msg "activity channel is full"))))))))
|
||||
(case cmd
|
||||
:stop
|
||||
(a/close! input)
|
||||
|
||||
:submit
|
||||
(let [params (-> params
|
||||
(dissoc :cmd)
|
||||
(assoc :tracked-at (dt/now)))]
|
||||
(when-not (a/offer! input params)
|
||||
(l/warn :hint "activity channel is full"))))))))
|
||||
|
||||
(defn- persist-events
|
||||
[{:keys [pool executor] :as cfg} events]
|
||||
@@ -117,69 +215,85 @@
|
||||
(:name event)
|
||||
(:type event)
|
||||
(:profile-id event)
|
||||
(:tracked-at event)
|
||||
(some-> (:ip-addr event) db/inet)
|
||||
(db/tjson (:props event))])]
|
||||
|
||||
(db/tjson (:props event))
|
||||
"backend"])]
|
||||
(aa/with-thread executor
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert-multi! conn :audit-log
|
||||
[:id :name :type :profile-id :ip-addr :props]
|
||||
(sequence (map event->row) events))))))
|
||||
(when (seq events)
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert-multi! conn :audit-log
|
||||
[:id :name :type :profile-id :tracked-at :ip-addr :props :source]
|
||||
(sequence (keep event->row) events)))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Archive Task
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; This is a task responsible to send the accomulated events to an
|
||||
;; This is a task responsible to send the accumulated events to an
|
||||
;; external service for archival.
|
||||
|
||||
(declare archive-events)
|
||||
|
||||
(s/def ::http-client fn?)
|
||||
(s/def ::uri ::us/string)
|
||||
(s/def ::tokens fn?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::archive-task [_]
|
||||
(s/keys :req-un [::db/pool ::tokens ::enabled]
|
||||
(s/keys :req-un [::db/pool ::tokens ::http-client]
|
||||
:opt-un [::uri]))
|
||||
|
||||
(defmethod ig/init-key ::archive-task
|
||||
[_ {:keys [uri enabled] :as cfg}]
|
||||
(fn [_]
|
||||
(when (and enabled (not uri))
|
||||
(ex/raise :type :internal
|
||||
:code :task-not-configured
|
||||
:hint "archive task not configured, missing uri"))
|
||||
(loop []
|
||||
(let [res (archive-events cfg)]
|
||||
(when (= res :continue)
|
||||
(aa/thread-sleep 200)
|
||||
(recur))))))
|
||||
[_ {:keys [uri] :as cfg}]
|
||||
(fn [props]
|
||||
;; NOTE: this let allows overwrite default configured values from
|
||||
;; the repl, when manually invoking the task.
|
||||
(let [enabled (or (contains? cf/flags :audit-log-archive)
|
||||
(:enabled props false))
|
||||
uri (or uri (:uri props))
|
||||
cfg (assoc cfg :uri uri)]
|
||||
|
||||
(when (and enabled (not uri))
|
||||
(ex/raise :type :internal
|
||||
:code :task-not-configured
|
||||
:hint "archive task not configured, missing uri"))
|
||||
(when enabled
|
||||
(loop []
|
||||
(let [res (archive-events cfg)]
|
||||
(when (= res :continue)
|
||||
(aa/thread-sleep 200)
|
||||
(recur))))))))
|
||||
|
||||
(def sql:retrieve-batch-of-audit-log
|
||||
"select * from audit_log
|
||||
where archived_at is null
|
||||
order by created_at asc
|
||||
limit 100
|
||||
limit 256
|
||||
for update skip locked;")
|
||||
|
||||
(defn archive-events
|
||||
[{:keys [pool uri tokens] :as cfg}]
|
||||
(letfn [(decode-row [{:keys [props ip-addr] :as row}]
|
||||
[{:keys [pool uri tokens http-client] :as cfg}]
|
||||
(letfn [(decode-row [{:keys [props ip-addr context] :as row}]
|
||||
(cond-> row
|
||||
(db/pgobject? props)
|
||||
(assoc :props (db/decode-transit-pgobject props))
|
||||
|
||||
(db/pgobject? context)
|
||||
(assoc :context (db/decode-transit-pgobject context))
|
||||
|
||||
(db/pgobject? ip-addr "inet")
|
||||
(assoc :ip-addr (db/decode-inet ip-addr))))
|
||||
|
||||
(row->event [{:keys [name type created-at profile-id props ip-addr]}]
|
||||
(cond-> {:type type
|
||||
:name name
|
||||
:timestamp created-at
|
||||
:profile-id profile-id
|
||||
:props props}
|
||||
(some? ip-addr)
|
||||
(update :context assoc :source-ip ip-addr)))
|
||||
(row->event [row]
|
||||
(select-keys row [:type
|
||||
:name
|
||||
:source
|
||||
:created-at
|
||||
:tracked-at
|
||||
:profile-id
|
||||
:ip-addr
|
||||
:props
|
||||
:context]))
|
||||
|
||||
(send [events]
|
||||
(let [token (tokens :generate {:iss "authentication"
|
||||
@@ -194,12 +308,14 @@
|
||||
:method :post
|
||||
:headers headers
|
||||
:body body}
|
||||
resp (http/send! params)]
|
||||
(when (not= (:status resp) 204)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-send-events
|
||||
:hint "unable to send events"
|
||||
:context resp))))
|
||||
resp (http-client params {:sync? true})]
|
||||
(if (= (:status resp) 204)
|
||||
true
|
||||
(do
|
||||
(l/error :hint "unable to archive events"
|
||||
:resp-status (:status resp)
|
||||
:resp-body (:body resp))
|
||||
false))))
|
||||
|
||||
(mark-as-archived [conn rows]
|
||||
(db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)"
|
||||
@@ -212,11 +328,9 @@
|
||||
xform (comp (map decode-row)
|
||||
(map row->event))
|
||||
events (into [] xform rows)]
|
||||
(l/debug :action "archive-events" :uri uri :events (count events))
|
||||
(if (empty? events)
|
||||
:empty
|
||||
(do
|
||||
(send events)
|
||||
(when-not (empty? events)
|
||||
(l/debug :action "archive-events" :uri uri :events (count events))
|
||||
(when (send events)
|
||||
(mark-as-archived conn rows)
|
||||
:continue))))))
|
||||
|
||||
@@ -224,18 +338,6 @@
|
||||
;; GC Task
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare clean-archived)
|
||||
|
||||
(s/def ::max-age ::cf/audit-archive-gc-max-age)
|
||||
|
||||
(defmethod ig/pre-init-spec ::archive-gc-task [_]
|
||||
(s/keys :req-un [::db/pool ::enabled ::max-age]))
|
||||
|
||||
(defmethod ig/init-key ::archive-gc-task
|
||||
[_ cfg]
|
||||
(fn [_]
|
||||
(clean-archived cfg)))
|
||||
|
||||
(def sql:clean-archived
|
||||
"delete from audit_log
|
||||
where archived_at is not null
|
||||
@@ -248,3 +350,13 @@
|
||||
result (:next.jdbc/update-count result)]
|
||||
(l/debug :action "clean archived audit log" :removed result)
|
||||
result))
|
||||
|
||||
(s/def ::max-age ::cf/audit-log-gc-max-age)
|
||||
|
||||
(defmethod ig/pre-init-spec ::gc-task [_]
|
||||
(s/keys :req-un [::db/pool ::max-age]))
|
||||
|
||||
(defmethod ig/init-key ::gc-task
|
||||
[_ cfg]
|
||||
(fn [_]
|
||||
(clean-archived cfg)))
|
||||
|
||||
92
backend/src/app/loggers/database.clj
Normal file
92
backend/src/app/loggers/database.clj
Normal file
@@ -0,0 +1,92 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.loggers.database
|
||||
"A specific logger impl that persists errors on the database."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.util.async :as aa]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Error Listener
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare handle-event)
|
||||
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[{:keys [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)})))
|
||||
|
||||
(defn- parse-event-data
|
||||
[event]
|
||||
(reduce-kv
|
||||
(fn [acc k v]
|
||||
(cond
|
||||
(= k :id) (assoc acc k (uuid/uuid v))
|
||||
(= k :profile-id) (assoc acc k (uuid/uuid v))
|
||||
(str/blank? v) acc
|
||||
:else (assoc acc k v)))
|
||||
{}
|
||||
event))
|
||||
|
||||
(defn parse-event
|
||||
[event]
|
||||
(-> (parse-event-data event)
|
||||
(assoc :tenant (cf/get :tenant))
|
||||
(assoc :host (cf/get :host))
|
||||
(assoc :public-uri (cf/get :public-uri))
|
||||
(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)]
|
||||
|
||||
(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)))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req-un [::wrk/executor ::db/pool ::receiver]))
|
||||
|
||||
(defn error-event?
|
||||
[event]
|
||||
(= "error" (:logger/level event)))
|
||||
|
||||
(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 "stoping error reporting loop")
|
||||
(do
|
||||
(a/<! (handle-event cfg msg))
|
||||
(recur)))))
|
||||
output))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ output]
|
||||
(a/close! output))
|
||||
@@ -7,39 +7,37 @@
|
||||
(ns app.loggers.loki
|
||||
"A Loki integration."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cfg]
|
||||
[app.util.async :as aa]
|
||||
[app.util.http :as http]
|
||||
[app.util.json :as json]
|
||||
[app.util.logging :as l]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(declare handle-event)
|
||||
(declare ^:private handle-event)
|
||||
(declare ^:private start-rcv-loop)
|
||||
|
||||
(s/def ::uri ::us/string)
|
||||
(s/def ::receiver fn?)
|
||||
(s/def ::http-client fn?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req-un [::wrk/executor ::receiver]
|
||||
(s/keys :req-un [ ::receiver ::http-client]
|
||||
:opt-un [::uri]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ {:keys [receiver uri] :as cfg}]
|
||||
(when uri
|
||||
(l/info :msg "intializing loki reporter" :uri uri)
|
||||
(let [input (a/chan (a/dropping-buffer 512))]
|
||||
(l/info :msg "initializing loki reporter" :uri uri)
|
||||
(let [input (a/chan (a/dropping-buffer 2048))]
|
||||
(receiver :sub input)
|
||||
(a/go-loop []
|
||||
(let [msg (a/<! input)]
|
||||
(if (nil? msg)
|
||||
(l/info :msg "stoping error reporting loop")
|
||||
(do
|
||||
(a/<! (handle-event cfg msg))
|
||||
(recur)))))
|
||||
|
||||
(doto (Thread. #(start-rcv-loop cfg input))
|
||||
(.setDaemon true)
|
||||
(.setName "penpot/loki-sender")
|
||||
(.start))
|
||||
|
||||
input)))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
@@ -47,53 +45,49 @@
|
||||
(when output
|
||||
(a/close! output)))
|
||||
|
||||
(defn- start-rcv-loop
|
||||
[cfg input]
|
||||
(loop []
|
||||
(let [msg (a/<!! input)]
|
||||
(when-not (nil? msg)
|
||||
(handle-event cfg msg)
|
||||
(recur))))
|
||||
|
||||
(l/info :msg "stoping error reporting loop"))
|
||||
|
||||
(defn- prepare-payload
|
||||
[event]
|
||||
(let [labels {:host (cfg/get :host)
|
||||
:tenant (cfg/get :tenant)
|
||||
:version (:full cfg/version)
|
||||
:logger (:logger event)
|
||||
:level (:level event)}]
|
||||
:logger (:logger/name event)
|
||||
:level (:logger/level event)}]
|
||||
{:streams
|
||||
[{:stream labels
|
||||
:values [[(str (* (inst-ms (:created-at event)) 1000000))
|
||||
(str (:message event)
|
||||
(when-let [error (:error event)]
|
||||
(str "\n" (:trace error))))]]}]}))
|
||||
(when-let [error (:trace event)]
|
||||
(str "\n" error)))]]}]}))
|
||||
|
||||
(defn- send-log
|
||||
[uri payload i]
|
||||
(try
|
||||
(let [response (http/send! {:uri uri
|
||||
:timeout 6000
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode payload)})]
|
||||
(cond
|
||||
(= (:status response) 204)
|
||||
true
|
||||
|
||||
(= (:status response) 400)
|
||||
(do
|
||||
(l/error :hint "error on sending log to loki (no retry)"
|
||||
:rsp (pr-str response))
|
||||
true)
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/error :hint "error on sending log to loki" :try i
|
||||
:rsp (pr-str response))
|
||||
false)))
|
||||
(catch Exception e
|
||||
(l/error :hint "error on sending message to loki" :cause e :try i)
|
||||
false)))
|
||||
(defn- make-request
|
||||
[{:keys [http-client uri] :as cfg} payload]
|
||||
(http-client {:uri uri
|
||||
:timeout 3000
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/write payload)}
|
||||
{:sync? true}))
|
||||
|
||||
(defn- handle-event
|
||||
[{:keys [executor uri]} event]
|
||||
(aa/with-thread executor
|
||||
(let [payload (prepare-payload event)]
|
||||
(loop [i 1]
|
||||
(when (and (not (send-log uri payload i)) (< i 20))
|
||||
(Thread/sleep (* i 2000))
|
||||
(recur (inc i)))))))
|
||||
|
||||
[cfg event]
|
||||
(try
|
||||
(let [payload (prepare-payload event)
|
||||
response (make-request cfg payload)]
|
||||
(when-not (= 204 (:status response))
|
||||
(map? response)
|
||||
(l/error :hint "error on sending log to loki (unexpected response)"
|
||||
:response (pr-str response))))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "error on sending log to loki (unexpected exception)"
|
||||
:cause cause))))
|
||||
|
||||
@@ -7,35 +7,49 @@
|
||||
(ns app.loggers.mattermost
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cfg]
|
||||
[app.db :as db]
|
||||
[app.util.async :as aa]
|
||||
[app.util.http :as http]
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.loggers.database :as ldb]
|
||||
[app.util.json :as json]
|
||||
[app.util.logging :as l]
|
||||
[app.util.template :as tmpl]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Error Listener
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(declare handle-event)
|
||||
(defn- send-mattermost-notification!
|
||||
[{:keys [http-client] :as cfg} {:keys [host id public-uri] :as event}]
|
||||
(let [uri (:uri cfg)
|
||||
text (str "Exception on (host: " host ", url: " public-uri "/dbg/error/" id ")\n"
|
||||
(when-let [pid (:profile-id event)]
|
||||
(str "- profile-id: #uuid-" pid "\n")))]
|
||||
(p/then
|
||||
(http-client {:uri uri
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/write-str {:text text})})
|
||||
(fn [{:keys [status] :as rsp}]
|
||||
(when (not= status 200)
|
||||
(l/warn :hint "error on sending data to mattermost"
|
||||
:response (pr-str rsp)))))))
|
||||
|
||||
(defonce enabled-mattermost (atom true))
|
||||
(defn handle-event
|
||||
[cfg event]
|
||||
(let [ch (a/chan)]
|
||||
(-> (p/let [event (ldb/parse-event event)]
|
||||
(send-mattermost-notification! cfg event))
|
||||
(p/finally (fn [_ cause]
|
||||
(when cause
|
||||
(l/warn :hint "unexpected exception on error reporter" :cause cause))
|
||||
(a/close! ch))))
|
||||
ch))
|
||||
|
||||
(s/def ::uri ::us/string)
|
||||
(s/def ::http-client fn?)
|
||||
(s/def ::uri ::cf/error-report-webhook)
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req-un [::wrk/executor ::db/pool ::receiver]
|
||||
(s/keys :req-un [::http-client ::receiver]
|
||||
:opt-un [::uri]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
@@ -43,7 +57,8 @@
|
||||
(when uri
|
||||
(l/info :msg "initializing mattermost error reporter" :uri uri)
|
||||
(let [output (a/chan (a/sliding-buffer 128)
|
||||
(filter #(= (:level %) "error")))]
|
||||
(filter (fn [event]
|
||||
(= (:logger/level event) "error"))))]
|
||||
(receiver :sub output)
|
||||
(a/go-loop []
|
||||
(let [msg (a/<! output)]
|
||||
@@ -58,95 +73,3 @@
|
||||
[_ output]
|
||||
(when output
|
||||
(a/close! output)))
|
||||
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [host id] :as cdata}]
|
||||
(try
|
||||
(let [uri (:uri cfg)
|
||||
text (str "Unhandled exception (host: " host ", url: " (cfg/get :public-uri) "/dbg/error-by-id/" id "\n"
|
||||
"- profile-id: #" (:profile-id cdata) "\n")
|
||||
rsp (http/send! {:uri uri
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str {:text text})})]
|
||||
(when (not= (:status rsp) 200)
|
||||
(l/error :hint "error on sending data to mattermost"
|
||||
:response (pr-str rsp))))
|
||||
|
||||
(catch Exception e
|
||||
(l/error :hint "unexpected exception on error reporter"
|
||||
:cause e))))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[{:keys [pool] :as cfg} {:keys [id] :as cdata}]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert! conn :server-error-report
|
||||
{:id id :content (db/tjson cdata)})))
|
||||
|
||||
(defn- parse-context
|
||||
[event]
|
||||
(reduce-kv
|
||||
(fn [acc k v]
|
||||
(cond
|
||||
(= k :id) (assoc acc k (uuid/uuid v))
|
||||
(= k :profile-id) (assoc acc k (uuid/uuid v))
|
||||
(str/blank? v) acc
|
||||
:else (assoc acc k v)))
|
||||
{:id (uuid/next)}
|
||||
(:context event)))
|
||||
|
||||
(defn- parse-event
|
||||
[event]
|
||||
(-> (parse-context event)
|
||||
(merge (dissoc event :context))
|
||||
(assoc :tenant (cfg/get :tenant))
|
||||
(assoc :host (cfg/get :host))
|
||||
(assoc :public-uri (cfg/get :public-uri))
|
||||
(assoc :version (:full cfg/version))))
|
||||
|
||||
(defn handle-event
|
||||
[{:keys [executor] :as cfg} event]
|
||||
(aa/with-thread executor
|
||||
(try
|
||||
(let [cdata (parse-event event)]
|
||||
(when @enabled-mattermost
|
||||
(send-mattermost-notification! cfg cdata))
|
||||
(persist-on-database! cfg cdata))
|
||||
(catch Exception e
|
||||
(l/error :hint "unexpected exception on error reporter"
|
||||
:cause e)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Http Handler
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [pool] :as cfg}]
|
||||
(letfn [(parse-id [request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
id (us/uuid-conformer id)]
|
||||
(when (uuid? id)
|
||||
id)))
|
||||
(retrieve-report [id]
|
||||
(ex/ignoring
|
||||
(when-let [{:keys [content] :as row} (db/get-by-id pool :server-error-report id)]
|
||||
(assoc row :content (db/decode-transit-pgobject content)))))
|
||||
|
||||
(render-template [{:keys [content] :as report}]
|
||||
(some-> (io/resource "error-report.tmpl")
|
||||
(tmpl/render content)))]
|
||||
|
||||
|
||||
(fn [request]
|
||||
(let [result (some-> (parse-id request)
|
||||
(retrieve-report)
|
||||
(render-template))]
|
||||
(if result
|
||||
{:status 200
|
||||
:headers {"content-type" "text/html; charset=utf-8"}
|
||||
:body result}
|
||||
{:status 404
|
||||
:body "not found"})))))
|
||||
|
||||
170
backend/src/app/loggers/sentry.clj
Normal file
170
backend/src/app/loggers/sentry.clj
Normal file
@@ -0,0 +1,170 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.loggers.sentry
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.util.async :as aa]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
io.sentry.Scope
|
||||
io.sentry.IHub
|
||||
io.sentry.Hub
|
||||
io.sentry.NoOpHub
|
||||
io.sentry.protocol.User
|
||||
io.sentry.SentryOptions
|
||||
io.sentry.SentryLevel
|
||||
io.sentry.ScopeCallback))
|
||||
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- parse-context
|
||||
[event]
|
||||
(reduce-kv
|
||||
(fn [acc k v]
|
||||
(cond
|
||||
(= k :id) (assoc acc k (uuid/uuid v))
|
||||
(= k :profile-id) (assoc acc k (uuid/uuid v))
|
||||
(str/blank? v) acc
|
||||
:else (assoc acc k v)))
|
||||
{}
|
||||
(:context event)))
|
||||
|
||||
(defn- parse-event
|
||||
[event]
|
||||
(assoc event :context (parse-context event)))
|
||||
|
||||
(defn- build-sentry-options
|
||||
[cfg]
|
||||
(let [version (:base cf/version)]
|
||||
(doto (SentryOptions.)
|
||||
(.setDebug (:debug cfg false))
|
||||
(.setTracesSampleRate (:traces-sample-rate cfg 1.0))
|
||||
(.setDsn (:dsn cfg))
|
||||
(.setServerName (cf/get :host))
|
||||
(.setEnvironment (cf/get :tenant))
|
||||
(.setAttachServerName true)
|
||||
(.setAttachStacktrace (:attach-stack-trace cfg false))
|
||||
(.setRelease (str "backend@" (if (= version "0.0.0") "develop" version))))))
|
||||
|
||||
(defn handle-event
|
||||
[^IHub shub event]
|
||||
(letfn [(set-user! [^Scope scope {:keys [context] :as event}]
|
||||
(let [user (User.)]
|
||||
(.setIpAddress ^User user ^String (:ip-addr context))
|
||||
(when-let [pid (:profile-id context)]
|
||||
(.setId ^User user ^String (str pid)))
|
||||
(.setUser scope ^User user)))
|
||||
|
||||
(set-level! [^Scope scope]
|
||||
(.setLevel scope SentryLevel/ERROR))
|
||||
|
||||
(set-context! [^Scope scope {:keys [context] :as event}]
|
||||
(let [uri (str (cf/get :public-uri) "/dbg/error-by-id/" (:id context))]
|
||||
(.setContexts scope "detailed_error_uri" ^String uri))
|
||||
(when-let [vers (:frontend-version event)]
|
||||
(.setContexts scope "frontend_version" ^String vers))
|
||||
(when-let [puri (:public-uri event)]
|
||||
(.setContexts scope "public_uri" ^String (str puri)))
|
||||
(when-let [uagent (:user-agent context)]
|
||||
(.setContexts scope "user_agent" ^String uagent))
|
||||
(when-let [tenant (:tenant event)]
|
||||
(.setTag scope "tenant" ^String tenant))
|
||||
(when-let [type (:error-type context)]
|
||||
(.setTag scope "error_type" ^String (str type)))
|
||||
(when-let [code (:error-code context)]
|
||||
(.setTag scope "error_code" ^String (str code)))
|
||||
)
|
||||
|
||||
(capture [^Scope scope {:keys [context error] :as event}]
|
||||
(let [msg (str (:message error) "\n\n"
|
||||
|
||||
"======================================================\n"
|
||||
"=================== Params ===========================\n"
|
||||
"======================================================\n"
|
||||
|
||||
(:params context) "\n"
|
||||
|
||||
(when (:explain context)
|
||||
(str "======================================================\n"
|
||||
"=================== Explain ==========================\n"
|
||||
"======================================================\n"
|
||||
(:explain context) "\n"))
|
||||
|
||||
(when (:data context)
|
||||
(str "======================================================\n"
|
||||
"=================== Error Data =======================\n"
|
||||
"======================================================\n"
|
||||
(:data context) "\n"))
|
||||
|
||||
(str "======================================================\n"
|
||||
"=================== Stack Trace ======================\n"
|
||||
"======================================================\n"
|
||||
(:trace error))
|
||||
|
||||
"\n")]
|
||||
(set-user! scope event)
|
||||
(set-level! scope)
|
||||
(set-context! scope event)
|
||||
(.captureMessage ^IHub shub msg)
|
||||
))
|
||||
]
|
||||
(when @enabled
|
||||
(.withScope ^IHub shub (reify ScopeCallback
|
||||
(run [_ scope]
|
||||
(->> event
|
||||
(parse-event)
|
||||
(capture scope))))))
|
||||
|
||||
))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Error Listener
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::receiver any?)
|
||||
(s/def ::dsn ::cf/sentry-dsn)
|
||||
(s/def ::trace-sample-rate ::cf/sentry-trace-sample-rate)
|
||||
(s/def ::attach-stack-trace ::cf/sentry-attach-stack-trace)
|
||||
(s/def ::debug ::cf/sentry-debug)
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req-un [::wrk/executor ::db/pool ::receiver]
|
||||
:opt-un [::dsn ::trace-sample-rate ::attach-stack-trace]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ {:keys [receiver dsn executor] :as cfg}]
|
||||
(l/info :msg "initializing sentry reporter" :dsn dsn)
|
||||
(let [opts (build-sentry-options cfg)
|
||||
shub (if dsn
|
||||
(Hub. ^SentryOptions opts)
|
||||
(NoOpHub/getInstance))
|
||||
output (a/chan (a/sliding-buffer 128)
|
||||
(filter #(= (:level %) "error")))]
|
||||
(receiver :sub output)
|
||||
(a/go-loop []
|
||||
(let [event (a/<! output)]
|
||||
(if (nil? event)
|
||||
(do
|
||||
(l/info :msg "stoping error reporting loop")
|
||||
(.close ^IHub shub))
|
||||
(do
|
||||
(a/<! (aa/with-thread executor (handle-event shub event)))
|
||||
(recur)))))
|
||||
output))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ output]
|
||||
(when output
|
||||
(a/close! output)))
|
||||
@@ -7,10 +7,10 @@
|
||||
(ns app.loggers.zmq
|
||||
"A generic ZMQ listener."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.util.json :as json]
|
||||
[app.util.logging :as l]
|
||||
[app.util.time :as dt]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
@@ -31,13 +31,17 @@
|
||||
|
||||
(defmethod ig/init-key ::receiver
|
||||
[_ {:keys [endpoint] :as cfg}]
|
||||
(l/info :msg "intializing ZMQ receiver" :bind endpoint)
|
||||
(l/info :msg "initializing ZMQ receiver" :bind endpoint)
|
||||
(let [buffer (a/chan 1)
|
||||
output (a/chan 1 (comp (filter map?)
|
||||
(map prepare)))
|
||||
(keep prepare)))
|
||||
mult (a/mult output)]
|
||||
(when endpoint
|
||||
(a/thread (start-rcv-loop {:out buffer :endpoint endpoint})))
|
||||
(let [thread (Thread. #(start-rcv-loop {:out buffer :endpoint endpoint}))]
|
||||
(.setDaemon thread false)
|
||||
(.setName thread "penpot/zmq-logger-receiver")
|
||||
(.start thread)))
|
||||
|
||||
(a/pipe buffer output)
|
||||
(with-meta
|
||||
(fn [cmd ch]
|
||||
@@ -53,37 +57,54 @@
|
||||
[_ f]
|
||||
(a/close! (::buffer (meta f))))
|
||||
|
||||
(def ^:private json-mapper
|
||||
(json/mapper
|
||||
{:encode-key-fn str/camel
|
||||
:decode-key-fn (comp keyword str/kebab)}))
|
||||
|
||||
(defn- start-rcv-loop
|
||||
([] (start-rcv-loop nil))
|
||||
([{:keys [out endpoint] :or {endpoint "tcp://localhost:5556"}}]
|
||||
(let [out (or out (a/chan 1))
|
||||
zctx (ZContext.)
|
||||
zctx (ZContext. 1)
|
||||
socket (.. zctx (createSocket SocketType/SUB))]
|
||||
(.. socket (connect ^String endpoint))
|
||||
(.. socket (subscribe ""))
|
||||
(.. socket (setReceiveTimeOut 5000))
|
||||
(loop []
|
||||
(let [msg (.recv ^ZMQ$Socket socket)
|
||||
msg (json/decode msg)
|
||||
msg (ex/ignoring (json/read msg json-mapper))
|
||||
msg (if (nil? msg) :empty msg)]
|
||||
(if (a/>!! out msg)
|
||||
(recur)
|
||||
(do
|
||||
(.close ^java.lang.AutoCloseable socket)
|
||||
(.close ^java.lang.AutoCloseable zctx))))))))
|
||||
(.destroy ^ZContext zctx))))))))
|
||||
|
||||
(s/def ::logger-name string?)
|
||||
(s/def ::level string?)
|
||||
(s/def ::thread string?)
|
||||
(s/def ::time-millis integer?)
|
||||
(s/def ::message string?)
|
||||
(s/def ::context-map map?)
|
||||
(s/def ::thrown map?)
|
||||
|
||||
(s/def ::log4j-event
|
||||
(s/keys :req-un [::logger-name ::level ::thread ::time-millis ::message]
|
||||
:opt-un [::context-map ::thrown]))
|
||||
|
||||
(defn- prepare
|
||||
[event]
|
||||
(d/merge
|
||||
{:logger (:loggerName event)
|
||||
:level (str/lower (:level event))
|
||||
:thread (:thread event)
|
||||
:created-at (dt/instant (:timeMillis event))
|
||||
:message (:message event)}
|
||||
(when-let [ctx (:contextMap event)]
|
||||
{:context ctx})
|
||||
(when-let [thrown (:thrown event)]
|
||||
{:error
|
||||
{:class (:name thrown)
|
||||
:message (:message thrown)
|
||||
:trace (:extendedStackTrace thrown)}})))
|
||||
(if (s/valid? ::log4j-event event)
|
||||
(merge {:message (:message event)
|
||||
:created-at (dt/instant (:time-millis event))
|
||||
:logger/name (:logger-name event)
|
||||
:logger/level (str/lower (:level event))}
|
||||
|
||||
(when-let [trace (-> event :thrown :extended-stack-trace)]
|
||||
{:trace trace})
|
||||
|
||||
(:context-map event))
|
||||
(do
|
||||
(l/warn :hint "invalid event" :event event)
|
||||
nil)))
|
||||
|
||||
@@ -6,41 +6,66 @@
|
||||
|
||||
(ns app.main
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.util.logging :as l]
|
||||
[app.util.time :as dt]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig])
|
||||
(:gen-class))
|
||||
|
||||
(def system-config
|
||||
{:app.db/pool
|
||||
{:uri (cf/get :database-uri)
|
||||
:username (cf/get :database-username)
|
||||
:password (cf/get :database-password)
|
||||
:read-only (cf/get :database-readonly false)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:migrations (ig/ref :app.migrations/all)
|
||||
:name :main
|
||||
:min-pool-size 0
|
||||
:max-pool-size 20}
|
||||
:name :main
|
||||
:min-size (cf/get :database-min-pool-size 0)
|
||||
:max-size (cf/get :database-max-pool-size 30)}
|
||||
|
||||
:app.metrics/metrics
|
||||
{:definitions
|
||||
{:profile-register
|
||||
{:name "actions_profile_register_count"
|
||||
:help "A global counter of user registrations."
|
||||
:type :counter}
|
||||
:profile-activation
|
||||
{:name "actions_profile_activation_count"
|
||||
:help "A global counter of profile activations"
|
||||
:type :counter}}}
|
||||
;; Default thread pool for IO operations
|
||||
[::default :app.worker/executor]
|
||||
{:parallelism (cf/get :default-executor-parallelism 60)
|
||||
:prefix :default}
|
||||
|
||||
:app.migrations/all
|
||||
{:main (ig/ref :app.migrations/migrations)}
|
||||
;; Constrained thread pool. Should only be used from high resources
|
||||
;; demanding operations.
|
||||
[::blocking :app.worker/executor]
|
||||
{:parallelism (cf/get :blocking-executor-parallelism 10)
|
||||
:prefix :blocking}
|
||||
|
||||
;; Dedicated thread pool for backround tasks execution.
|
||||
[::worker :app.worker/executor]
|
||||
{:parallelism (cf/get :worker-executor-parallelism 10)
|
||||
:prefix :worker}
|
||||
|
||||
:app.worker/scheduler
|
||||
{:parallelism 1
|
||||
:prefix :scheduler}
|
||||
|
||||
:app.worker/executors
|
||||
{:default (ig/ref [::default :app.worker/executor])
|
||||
:worker (ig/ref [::worker :app.worker/executor])
|
||||
:blocking (ig/ref [::blocking :app.worker/executor])}
|
||||
|
||||
:app.worker/executors-monitor
|
||||
{:metrics (ig/ref :app.metrics/metrics)
|
||||
:scheduler (ig/ref :app.worker/scheduler)
|
||||
:executors (ig/ref :app.worker/executors)}
|
||||
|
||||
:app.migrations/migrations
|
||||
{}
|
||||
|
||||
:app.metrics/metrics
|
||||
{}
|
||||
|
||||
:app.migrations/all
|
||||
{:main (ig/ref :app.migrations/migrations)}
|
||||
|
||||
:app.msgbus/msgbus
|
||||
{:backend (cf/get :msgbus-backend :redis)
|
||||
:executor (ig/ref [::default :app.worker/executor])
|
||||
:redis-uri (cf/get :redis-uri)}
|
||||
|
||||
:app.tokens/tokens
|
||||
@@ -49,18 +74,22 @@
|
||||
:app.storage/gc-deleted-task
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:executor (ig/ref [::worker :app.worker/executor])
|
||||
:min-age (dt/duration {:hours 2})}
|
||||
|
||||
:app.storage/gc-touched-task
|
||||
{:pool (ig/ref :app.db/pool)}
|
||||
{:pool (ig/ref :app.db/pool)}
|
||||
|
||||
:app.storage/recheck-task
|
||||
:app.http/client
|
||||
{:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
:app.http/session
|
||||
{:store (ig/ref :app.http.session/store)}
|
||||
|
||||
:app.http.session/store
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:storage (ig/ref :app.storage/storage)}
|
||||
|
||||
:app.http.session/session
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:tokens (ig/ref :app.tokens/tokens)}
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
:app.http.session/gc-task
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
@@ -69,107 +98,100 @@
|
||||
:app.http.session/updater
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:executor (ig/ref :app.worker/executor)
|
||||
:session (ig/ref :app.http.session/session)
|
||||
:executor (ig/ref [::worker :app.worker/executor])
|
||||
:session (ig/ref :app.http/session)
|
||||
:max-batch-age (cf/get :http-session-updater-batch-max-age)
|
||||
:max-batch-size (cf/get :http-session-updater-batch-max-size)}
|
||||
|
||||
:app.http.awsns/handler
|
||||
{:tokens (ig/ref :app.tokens/tokens)
|
||||
:pool (ig/ref :app.db/pool)}
|
||||
{:tokens (ig/ref :app.tokens/tokens)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:http-client (ig/ref :app.http/client)
|
||||
:executor (ig/ref [::worker :app.worker/executor])}
|
||||
|
||||
:app.http/server
|
||||
{:port (cf/get :http-server-port)
|
||||
:router (ig/ref :app.http/router)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:ws {"/ws/notifications" (ig/ref :app.notifications/handler)}}
|
||||
{:port (cf/get :http-server-port)
|
||||
:host (cf/get :http-server-host)
|
||||
:router (ig/ref :app.http/router)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:executor (ig/ref [::default :app.worker/executor])
|
||||
:io-threads (cf/get :http-server-io-threads)
|
||||
:max-body-size (cf/get :http-server-max-body-size)
|
||||
:max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
|
||||
|
||||
:app.http/router
|
||||
{:rpc (ig/ref :app.rpc/rpc)
|
||||
:session (ig/ref :app.http.session/session)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:oauth (ig/ref :app.http.oauth/handler)
|
||||
:assets (ig/ref :app.http.assets/handlers)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:sns-webhook (ig/ref :app.http.awsns/handler)
|
||||
:feedback (ig/ref :app.http.feedback/handler)
|
||||
:error-report-handler (ig/ref :app.loggers.mattermost/handler)}
|
||||
{:assets (ig/ref :app.http.assets/handlers)
|
||||
:feedback (ig/ref :app.http.feedback/handler)
|
||||
:session (ig/ref :app.http/session)
|
||||
:awsns-handler (ig/ref :app.http.awsns/handler)
|
||||
:oauth (ig/ref :app.http.oauth/handler)
|
||||
:debug (ig/ref :app.http.debug/handlers)
|
||||
:ws (ig/ref :app.http.websocket/handler)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:audit-handler (ig/ref :app.loggers.audit/http-handler)
|
||||
:rpc (ig/ref :app.rpc/rpc)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
:app.http.debug/handlers
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref [::worker :app.worker/executor])}
|
||||
|
||||
:app.http.websocket/handler
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)}
|
||||
|
||||
:app.http.assets/handlers
|
||||
{:metrics (ig/ref :app.metrics/metrics)
|
||||
:assets-path (cf/get :assets-path)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:executor (ig/ref [::default :app.worker/executor])
|
||||
:cache-max-age (dt/duration {:hours 24})
|
||||
:signature-max-age (dt/duration {:hours 24 :minutes 5})}
|
||||
|
||||
:app.http.feedback/handler
|
||||
{:pool (ig/ref :app.db/pool)}
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
:app.http.oauth/handler
|
||||
{:rpc (ig/ref :app.rpc/rpc)
|
||||
:session (ig/ref :app.http.session/session)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:audit (ig/ref :app.loggers.audit/collector)
|
||||
:public-uri (cf/get :public-uri)}
|
||||
|
||||
;; RLimit definition for password hashing
|
||||
:app.rlimits/password
|
||||
(cf/get :rlimits-password)
|
||||
|
||||
;; RLimit definition for image processing
|
||||
:app.rlimits/image
|
||||
(cf/get :rlimits-image)
|
||||
|
||||
;; RLimit definition for font processing
|
||||
:app.rlimits/font
|
||||
(cf/get :rlimits-font 2)
|
||||
|
||||
;; A collection of rlimits as hash-map.
|
||||
:app.rlimits/all
|
||||
{:password (ig/ref :app.rlimits/password)
|
||||
:image (ig/ref :app.rlimits/image)
|
||||
:font (ig/ref :app.rlimits/font)}
|
||||
{:rpc (ig/ref :app.rpc/rpc)
|
||||
:session (ig/ref :app.http/session)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:audit (ig/ref :app.loggers.audit/collector)
|
||||
:executor (ig/ref [::default :app.worker/executor])
|
||||
:http-client (ig/ref :app.http/client)
|
||||
:public-uri (cf/get :public-uri)}
|
||||
|
||||
:app.rpc/rpc
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:session (ig/ref :app.http.session/session)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)
|
||||
:rlimits (ig/ref :app.rlimits/all)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:audit (ig/ref :app.loggers.audit/collector)}
|
||||
|
||||
:app.notifications/handler
|
||||
{:msgbus (ig/ref :app.msgbus/msgbus)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:session (ig/ref :app.http.session/session)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:executor (ig/ref :app.worker/executor)}
|
||||
|
||||
:app.worker/executor
|
||||
{:min-threads 0
|
||||
:max-threads 256
|
||||
:idle-timeout 60000
|
||||
:name :worker}
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:session (ig/ref :app.http/session)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:audit (ig/ref :app.loggers.audit/collector)
|
||||
:http-client (ig/ref :app.http/client)
|
||||
:executors (ig/ref :app.worker/executors)}
|
||||
|
||||
:app.worker/worker
|
||||
{:executor (ig/ref :app.worker/executor)
|
||||
{:executor (ig/ref [::worker :app.worker/executor])
|
||||
:tasks (ig/ref :app.worker/registry)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
:pool (ig/ref :app.db/pool)}
|
||||
|
||||
:app.worker/scheduler
|
||||
{:executor (ig/ref :app.worker/executor)
|
||||
:app.worker/cron
|
||||
{:executor (ig/ref [::worker :app.worker/executor])
|
||||
:scheduler (ig/ref :app.worker/scheduler)
|
||||
:tasks (ig/ref :app.worker/registry)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:schedule
|
||||
:entries
|
||||
[{:cron #app/cron "0 0 0 * * ?" ;; daily
|
||||
:task :file-media-gc}
|
||||
:task :file-gc}
|
||||
|
||||
{:cron #app/cron "0 0 * * * ?" ;; hourly
|
||||
:task :file-xlog-gc}
|
||||
@@ -183,56 +205,48 @@
|
||||
{:cron #app/cron "0 0 0 * * ?" ;; daily
|
||||
:task :session-gc}
|
||||
|
||||
{:cron #app/cron "0 0 * * * ?" ;; hourly
|
||||
:task :storage-recheck}
|
||||
|
||||
{:cron #app/cron "0 0 0 * * ?" ;; daily
|
||||
:task :objects-gc}
|
||||
|
||||
{:cron #app/cron "0 0 0 * * ?" ;; daily
|
||||
:task :tasks-gc}
|
||||
|
||||
{:cron #app/cron "0 30 */3,23 * * ?"
|
||||
:task :telemetry}
|
||||
|
||||
(when (cf/get :fdata-storage-backed)
|
||||
{:cron #app/cron "0 0 * * * ?" ;; hourly
|
||||
:task :file-offload})
|
||||
|
||||
(when (cf/get :audit-archive-enabled)
|
||||
{:cron #app/cron "0 0 * * * ?" ;; every 1h
|
||||
:task :audit-archive})
|
||||
(when (contains? cf/flags :audit-log-archive)
|
||||
{:cron #app/cron "0 */5 * * * ?" ;; every 5m
|
||||
:task :audit-log-archive})
|
||||
|
||||
(when (cf/get :audit-archive-gc-enabled)
|
||||
{:cron #app/cron "0 0 * * * ?" ;; every 1h
|
||||
:task :audit-archive-gc})
|
||||
|
||||
(when (cf/get :telemetry-enabled)
|
||||
{:cron #app/cron "0 0 */6 * * ?" ;; every 6h
|
||||
:task :telemetry})]}
|
||||
(when (contains? cf/flags :audit-log-gc)
|
||||
{:cron #app/cron "0 0 0 * * ?" ;; daily
|
||||
:task :audit-log-gc})]}
|
||||
|
||||
:app.worker/registry
|
||||
{:metrics (ig/ref :app.metrics/metrics)
|
||||
:tasks
|
||||
{:sendmail (ig/ref :app.emails/sendmail-handler)
|
||||
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
|
||||
:delete-object (ig/ref :app.tasks.delete-object/handler)
|
||||
:delete-profile (ig/ref :app.tasks.delete-profile/handler)
|
||||
:file-media-gc (ig/ref :app.tasks.file-media-gc/handler)
|
||||
:file-gc (ig/ref :app.tasks.file-gc/handler)
|
||||
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
|
||||
:storage-deleted-gc (ig/ref :app.storage/gc-deleted-task)
|
||||
:storage-touched-gc (ig/ref :app.storage/gc-touched-task)
|
||||
:storage-recheck (ig/ref :app.storage/recheck-task)
|
||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
||||
:session-gc (ig/ref :app.http.session/gc-task)
|
||||
:file-offload (ig/ref :app.tasks.file-offload/handler)
|
||||
:audit-archive (ig/ref :app.loggers.audit/archive-task)
|
||||
:audit-archive-gc (ig/ref :app.loggers.audit/archive-gc-task)}}
|
||||
:audit-log-archive (ig/ref :app.loggers.audit/archive-task)
|
||||
:audit-log-gc (ig/ref :app.loggers.audit/gc-task)}}
|
||||
|
||||
:app.emails/sendmail-handler
|
||||
{:host (cf/get :smtp-host)
|
||||
:port (cf/get :smtp-port)
|
||||
:ssl (cf/get :smtp-ssl)
|
||||
:tls (cf/get :smtp-tls)
|
||||
:enabled (cf/get :smtp-enabled)
|
||||
:username (cf/get :smtp-username)
|
||||
:password (cf/get :smtp-password)
|
||||
:metrics (ig/ref :app.metrics/metrics)
|
||||
@@ -243,19 +257,12 @@
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:max-age cf/deletion-delay}
|
||||
|
||||
:app.tasks.delete-object/handler
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:storage (ig/ref :app.storage/storage)}
|
||||
|
||||
:app.tasks.objects-gc/handler
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:max-age cf/deletion-delay}
|
||||
|
||||
:app.tasks.delete-profile/handler
|
||||
{:pool (ig/ref :app.db/pool)}
|
||||
|
||||
:app.tasks.file-media-gc/handler
|
||||
:app.tasks.file-gc/handler
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:max-age cf/deletion-delay}
|
||||
|
||||
@@ -273,7 +280,8 @@
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:version (:full cf/version)
|
||||
:uri (cf/get :telemetry-uri)
|
||||
:sprops (ig/ref :app.setup/props)}
|
||||
:sprops (ig/ref :app.setup/props)
|
||||
:http-client (ig/ref :app.http/client)}
|
||||
|
||||
:app.srepl/server
|
||||
{:port (cf/get :srepl-port)
|
||||
@@ -289,59 +297,67 @@
|
||||
:app.loggers.zmq/receiver
|
||||
{:endpoint (cf/get :loggers-zmq-uri)}
|
||||
|
||||
:app.loggers.audit/http-handler
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
:app.loggers.audit/collector
|
||||
{:enabled (cf/get :audit-enabled false)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref :app.worker/executor)}
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref [::worker :app.worker/executor])}
|
||||
|
||||
:app.loggers.audit/archive-task
|
||||
{:uri (cf/get :audit-archive-uri)
|
||||
:enabled (cf/get :audit-archive-enabled false)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:pool (ig/ref :app.db/pool)}
|
||||
{:uri (cf/get :audit-log-archive-uri)
|
||||
:tokens (ig/ref :app.tokens/tokens)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:http-client (ig/ref :app.http/client)}
|
||||
|
||||
:app.loggers.audit/archive-gc-task
|
||||
{:enabled (cf/get :audit-archive-gc-enabled false)
|
||||
:max-age (cf/get :audit-archive-gc-max-age cf/deletion-delay)
|
||||
:app.loggers.audit/gc-task
|
||||
{:max-age (cf/get :audit-log-gc-max-age cf/deletion-delay)
|
||||
:pool (ig/ref :app.db/pool)}
|
||||
|
||||
:app.loggers.loki/reporter
|
||||
{:uri (cf/get :loggers-loki-uri)
|
||||
:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:executor (ig/ref :app.worker/executor)}
|
||||
{:uri (cf/get :loggers-loki-uri)
|
||||
:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:http-client (ig/ref :app.http/client)}
|
||||
|
||||
:app.loggers.mattermost/reporter
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref :app.worker/executor)}
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:http-client (ig/ref :app.http/client)}
|
||||
|
||||
:app.loggers.mattermost/handler
|
||||
{:pool (ig/ref :app.db/pool)}
|
||||
:app.loggers.database/reporter
|
||||
{:receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref [::worker :app.worker/executor])}
|
||||
|
||||
:app.storage/storage
|
||||
{:pool (ig/ref :app.db/pool)
|
||||
:executor (ig/ref :app.worker/executor)
|
||||
:executor (ig/ref [::default :app.worker/executor])
|
||||
|
||||
:backends {
|
||||
:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
|
||||
:assets-db (ig/ref [::assets :app.storage.db/backend])
|
||||
:assets-fs (ig/ref [::assets :app.storage.fs/backend])
|
||||
:tmp (ig/ref [::tmp :app.storage.fs/backend])
|
||||
:fdata-s3 (ig/ref [::fdata :app.storage.s3/backend])
|
||||
:backends
|
||||
{:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
|
||||
:assets-db (ig/ref [::assets :app.storage.db/backend])
|
||||
:assets-fs (ig/ref [::assets :app.storage.fs/backend])
|
||||
|
||||
;; keep this for backward compatibility
|
||||
:s3 (ig/ref [::assets :app.storage.s3/backend])
|
||||
:fs (ig/ref [::assets :app.storage.fs/backend])}}
|
||||
:tmp (ig/ref [::tmp :app.storage.fs/backend])
|
||||
:fdata-s3 (ig/ref [::fdata :app.storage.s3/backend])
|
||||
|
||||
;; keep this for backward compatibility
|
||||
:s3 (ig/ref [::assets :app.storage.s3/backend])
|
||||
:fs (ig/ref [::assets :app.storage.fs/backend])}}
|
||||
|
||||
[::fdata :app.storage.s3/backend]
|
||||
{:region (cf/get :storage-fdata-s3-region)
|
||||
:bucket (cf/get :storage-fdata-s3-bucket)
|
||||
:prefix (cf/get :storage-fdata-s3-prefix)}
|
||||
{:region (cf/get :storage-fdata-s3-region)
|
||||
:bucket (cf/get :storage-fdata-s3-bucket)
|
||||
:endpoint (cf/get :storage-fdata-s3-endpoint)
|
||||
:prefix (cf/get :storage-fdata-s3-prefix)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
[::assets :app.storage.s3/backend]
|
||||
{:region (cf/get :storage-assets-s3-region)
|
||||
:bucket (cf/get :storage-assets-s3-bucket)}
|
||||
{:region (cf/get :storage-assets-s3-region)
|
||||
:endpoint (cf/get :storage-assets-s3-endpoint)
|
||||
:bucket (cf/get :storage-assets-s3-bucket)
|
||||
:executor (ig/ref [::default :app.worker/executor])}
|
||||
|
||||
[::assets :app.storage.fs/backend]
|
||||
{:directory (cf/get :storage-assets-fs-directory)}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
[app.common.media :as cm]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.rlimits :as rlm]
|
||||
[app.rpc.queries.svg :as svg]
|
||||
[app.util.svg :as svg]
|
||||
[buddy.core.bytes :as bb]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.java.io :as io]
|
||||
@@ -29,28 +28,30 @@
|
||||
org.im4java.core.IMOperation
|
||||
org.im4java.core.Info))
|
||||
|
||||
(s/def ::image-content-type cm/valid-image-types)
|
||||
(s/def ::font-content-type cm/valid-font-types)
|
||||
|
||||
(s/def :internal.http.upload/filename ::us/string)
|
||||
(s/def :internal.http.upload/size ::us/integer)
|
||||
(s/def :internal.http.upload/content-type ::us/string)
|
||||
(s/def :internal.http.upload/tempfile any?)
|
||||
(s/def ::path fs/path?)
|
||||
(s/def ::filename string?)
|
||||
(s/def ::size integer?)
|
||||
(s/def ::headers (s/map-of string? string?))
|
||||
(s/def ::mtype string?)
|
||||
|
||||
(s/def ::upload
|
||||
(s/keys :req-un [:internal.http.upload/filename
|
||||
:internal.http.upload/size
|
||||
:internal.http.upload/tempfile
|
||||
:internal.http.upload/content-type]))
|
||||
(s/keys :req-un [::filename ::size ::path]
|
||||
:opt-un [::mtype ::headers]))
|
||||
|
||||
(defn validate-media-type
|
||||
([mtype] (validate-media-type mtype cm/valid-image-types))
|
||||
([mtype allowed]
|
||||
(when-not (contains? allowed mtype)
|
||||
;; A subset of fields from the ::upload spec
|
||||
(s/def ::input
|
||||
(s/keys :req-un [::path]
|
||||
:opt-un [::mtype]))
|
||||
|
||||
(defn validate-media-type!
|
||||
([upload] (validate-media-type! upload cm/valid-image-types))
|
||||
([upload allowed]
|
||||
(when-not (contains? allowed (:mtype upload))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "Seems like you are uploading an invalid media object"))))
|
||||
:hint "Seems like you are uploading an invalid media object"))
|
||||
|
||||
upload))
|
||||
|
||||
(defmulti process :cmd)
|
||||
(defmulti process-error class)
|
||||
@@ -66,39 +67,23 @@
|
||||
(throw error))
|
||||
|
||||
(defn run
|
||||
[{:keys [rlimits] :as cfg} {:keys [rlimit] :or {rlimit :image} :as params}]
|
||||
(us/assert map? rlimits)
|
||||
(let [rlimit (get rlimits rlimit)]
|
||||
(when-not rlimit
|
||||
(ex/raise :type :internal
|
||||
:code :rlimit-not-configured
|
||||
:hint ":image rlimit not configured"))
|
||||
(try
|
||||
(rlm/execute rlimit (process params))
|
||||
(catch Throwable e
|
||||
(process-error e)))))
|
||||
[params]
|
||||
(try
|
||||
(process params)
|
||||
(catch Throwable e
|
||||
(process-error e))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; --- Thumbnails Generation
|
||||
;; IMAGE THUMBNAILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::cmd keyword?)
|
||||
|
||||
(s/def ::path (s/or :path fs/path?
|
||||
:string string?
|
||||
:file fs/file?))
|
||||
|
||||
(s/def ::input
|
||||
(s/keys :req-un [::path]
|
||||
:opt-un [::cm/mtype]))
|
||||
|
||||
(s/def ::width integer?)
|
||||
(s/def ::height integer?)
|
||||
(s/def ::format #{:jpeg :webp :png})
|
||||
(s/def ::quality #(< 0 % 101))
|
||||
|
||||
(s/def ::thumbnail-params
|
||||
(s/keys :req-un [::cmd ::input ::format ::width ::height]))
|
||||
(s/keys :req-un [::input ::format ::width ::height]))
|
||||
|
||||
;; Related info on how thumbnails generation
|
||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
||||
@@ -180,12 +165,12 @@
|
||||
(us/assert ::input input)
|
||||
(let [{:keys [path mtype]} input]
|
||||
(if (= mtype "image/svg+xml")
|
||||
(let [info (some-> path slurp svg/parse get-basic-info-from-svg)]
|
||||
(let [info (some-> path slurp svg/pre-process svg/parse get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(assoc info :mtype mtype))
|
||||
(merge input info))
|
||||
|
||||
(let [instance (Info. (str path))
|
||||
mtype' (.getProperty instance "Mime type")]
|
||||
@@ -198,9 +183,9 @@
|
||||
;; For an animated GIF, getImageWidth/Height returns the delta size of one frame (if no frame given
|
||||
;; it returns size of the last one), whereas getPageWidth/Height always return the full size of
|
||||
;; any frame.
|
||||
{:width (.getPageWidth instance)
|
||||
:height (.getPageHeight instance)
|
||||
:mtype mtype}))))
|
||||
(assoc input
|
||||
:width (.getPageWidth instance)
|
||||
:height (.getPageHeight instance))))))
|
||||
|
||||
(defmethod process-error org.im4java.core.InfoException
|
||||
[error]
|
||||
@@ -210,11 +195,9 @@
|
||||
:cause error))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; --- Fonts Generation
|
||||
;; FONTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def all-fotmats #{"font/woff2", "font/woff", "font/otf", "font/ttf"})
|
||||
|
||||
(defmethod process :generate-fonts
|
||||
[{:keys [input] :as params}]
|
||||
(letfn [(ttf->otf [data]
|
||||
@@ -334,10 +317,11 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn configure-assets-storage
|
||||
"Given storage map, returns a storage configured with the apropriate
|
||||
backend for assets."
|
||||
[storage conn]
|
||||
(-> storage
|
||||
(assoc :conn conn)
|
||||
(assoc :backend (cf/get :assets-storage-backend :assets-fs))))
|
||||
|
||||
"Given storage map, returns a storage configured with the appropriate
|
||||
backend for assets and optional connection attached."
|
||||
([storage]
|
||||
(assoc storage :backend (cf/get :assets-storage-backend :assets-fs)))
|
||||
([storage conn]
|
||||
(-> storage
|
||||
(assoc :conn conn)
|
||||
(assoc :backend (cf/get :assets-storage-backend :assets-fs)))))
|
||||
|
||||
@@ -5,48 +5,130 @@
|
||||
;; Copyright (c) UXBOX Labs SL
|
||||
|
||||
(ns app.metrics
|
||||
(:refer-clojure :exclude [run!])
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.util.logging :as l]
|
||||
[app.common.logging :as l]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
io.prometheus.client.CollectorRegistry
|
||||
io.prometheus.client.Counter
|
||||
io.prometheus.client.Counter$Child
|
||||
io.prometheus.client.Gauge
|
||||
io.prometheus.client.Gauge$Child
|
||||
io.prometheus.client.Summary
|
||||
io.prometheus.client.Summary$Child
|
||||
io.prometheus.client.Summary$Builder
|
||||
io.prometheus.client.Histogram
|
||||
io.prometheus.client.Histogram$Child
|
||||
io.prometheus.client.exporter.common.TextFormat
|
||||
io.prometheus.client.hotspot.DefaultExports
|
||||
io.prometheus.client.jetty.JettyStatisticsCollector
|
||||
org.eclipse.jetty.server.handler.StatisticsHandler
|
||||
java.io.StringWriter))
|
||||
|
||||
(declare instrument-vars!)
|
||||
(declare instrument)
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(declare create-registry)
|
||||
(declare create)
|
||||
(declare handler)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Entry Point
|
||||
;; METRICS SERVICE PROVIDER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- handler
|
||||
[registry _request]
|
||||
(let [samples (.metricFamilySamples ^CollectorRegistry registry)
|
||||
writer (StringWriter.)]
|
||||
(TextFormat/write004 writer samples)
|
||||
{:headers {"content-type" TextFormat/CONTENT_TYPE_004}
|
||||
:body (.toString writer)}))
|
||||
(def default-metrics
|
||||
{:update-file-changes
|
||||
{:name "rpc_update_file_changes_total"
|
||||
:help "A total number of changes submitted to update-file."
|
||||
:type :counter}
|
||||
|
||||
(s/def ::definitions
|
||||
(s/map-of keyword? map?))
|
||||
:update-file-bytes-processed
|
||||
{:name "rpc_update_file_bytes_processed_total"
|
||||
:help "A total number of bytes processed by update-file."
|
||||
:type :counter}
|
||||
|
||||
(defmethod ig/pre-init-spec ::metrics [_]
|
||||
(s/keys :opt-un [::definitions]))
|
||||
:rpc-mutation-timing
|
||||
{:name "rpc_mutation_timing"
|
||||
:help "RPC mutation method call timming."
|
||||
:labels ["name"]
|
||||
:type :histogram}
|
||||
|
||||
:rpc-query-timing
|
||||
{:name "rpc_query_timing"
|
||||
:help "RPC query method call timing."
|
||||
:labels ["name"]
|
||||
:type :histogram}
|
||||
|
||||
:websocket-active-connections
|
||||
{:name "websocket_active_connections"
|
||||
:help "Active websocket connections gauge"
|
||||
:type :gauge}
|
||||
|
||||
:websocket-messages-total
|
||||
{:name "websocket_message_total"
|
||||
:help "Counter of processed messages."
|
||||
:labels ["op"]
|
||||
:type :counter}
|
||||
|
||||
:websocket-session-timing
|
||||
{:name "websocket_session_timing"
|
||||
:help "Websocket session timing (seconds)."
|
||||
:type :summary}
|
||||
|
||||
:session-update-total
|
||||
{:name "http_session_update_total"
|
||||
:help "A counter of session update batch events."
|
||||
:type :counter}
|
||||
|
||||
:tasks-timing
|
||||
{:name "penpot_tasks_timing"
|
||||
:help "Background tasks timing (milliseconds)."
|
||||
:labels ["name"]
|
||||
:type :summary}
|
||||
|
||||
:rlimit-queued-submissions
|
||||
{:name "penpot_rlimit_queued_submissions"
|
||||
:help "Current number of queued submissions on RLIMIT."
|
||||
:labels ["name"]
|
||||
:type :gauge}
|
||||
|
||||
:rlimit-used-permits
|
||||
{:name "penpot_rlimit_used_permits"
|
||||
:help "Current number of used permits on RLIMIT."
|
||||
:labels ["name"]
|
||||
:type :gauge}
|
||||
|
||||
:rlimit-acquires-total
|
||||
{:name "penpot_rlimit_acquires_total"
|
||||
:help "Total number of acquire operations on RLIMIT."
|
||||
:labels ["name"]
|
||||
:type :counter}
|
||||
|
||||
:executors-active-threads
|
||||
{:name "penpot_executors_active_threads"
|
||||
:help "Current number of threads available in the executor service."
|
||||
:labels ["name"]
|
||||
:type :gauge}
|
||||
|
||||
:executors-completed-tasks
|
||||
{:name "penpot_executors_completed_tasks_total"
|
||||
:help "Aproximate number of completed tasks by the executor."
|
||||
:labels ["name"]
|
||||
:type :counter}
|
||||
|
||||
:executors-running-threads
|
||||
{:name "penpot_executors_running_threads"
|
||||
:help "Current number of threads with state RUNNING."
|
||||
:labels ["name"]
|
||||
:type :gauge}
|
||||
|
||||
:executors-queued-submissions
|
||||
{:name "penpot_executors_queued_submissions"
|
||||
:help "Current number of queued submissions."
|
||||
:labels ["name"]
|
||||
:type :gauge}})
|
||||
|
||||
(defmethod ig/init-key ::metrics
|
||||
[_ {:keys [definitions] :as cfg}]
|
||||
[_ _]
|
||||
(l/info :action "initialize metrics")
|
||||
(let [registry (create-registry)
|
||||
definitions (reduce-kv (fn [res k v]
|
||||
@@ -54,7 +136,7 @@
|
||||
(create)
|
||||
(assoc res k)))
|
||||
{}
|
||||
definitions)]
|
||||
default-metrics)]
|
||||
{:handler (partial handler registry)
|
||||
:definitions definitions
|
||||
:registry registry}))
|
||||
@@ -64,24 +146,45 @@
|
||||
(s/def ::metrics
|
||||
(s/keys :req-un [::registry ::handler]))
|
||||
|
||||
(defn- handler
|
||||
[registry _ respond _]
|
||||
(let [samples (.metricFamilySamples ^CollectorRegistry registry)
|
||||
writer (StringWriter.)]
|
||||
(TextFormat/write004 writer samples)
|
||||
(respond {:headers {"content-type" TextFormat/CONTENT_TYPE_004}
|
||||
:body (.toString writer)})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Implementation
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def default-empty-labels (into-array String []))
|
||||
|
||||
(def default-quantiles
|
||||
[[0.5 0.01]
|
||||
[0.90 0.01]
|
||||
[0.99 0.001]])
|
||||
|
||||
(def default-histogram-buckets
|
||||
[1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500])
|
||||
|
||||
(defn run!
|
||||
[{:keys [definitions]} {:keys [id] :as params}]
|
||||
(when-let [mobj (get definitions id)]
|
||||
((::fn mobj) params)
|
||||
true))
|
||||
|
||||
(defn create-registry
|
||||
[]
|
||||
(let [registry (CollectorRegistry.)]
|
||||
(DefaultExports/register registry)
|
||||
registry))
|
||||
|
||||
(defmacro with-measure
|
||||
[& {:keys [expr cb]}]
|
||||
`(let [start# (System/nanoTime)
|
||||
tdown# ~cb]
|
||||
(try
|
||||
~expr
|
||||
(finally
|
||||
(tdown# (/ (- (System/nanoTime) start#) 1000000))))))
|
||||
(defn- is-array?
|
||||
[o]
|
||||
(let [oc (class o)]
|
||||
(and (.isArray ^Class oc)
|
||||
(= (.getComponentType oc) String))))
|
||||
|
||||
(defn make-counter
|
||||
[{:keys [name help registry reg labels] :as props}]
|
||||
@@ -92,18 +195,11 @@
|
||||
_ (when (seq labels)
|
||||
(.labelNames instance (into-array String labels)))
|
||||
instance (.register instance registry)]
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] instance)
|
||||
|
||||
clojure.lang.IFn
|
||||
(invoke [_ cmd]
|
||||
(.inc ^Counter instance))
|
||||
|
||||
(invoke [_ cmd labels]
|
||||
(.. ^Counter instance
|
||||
(labels (into-array String labels))
|
||||
(inc))))))
|
||||
{::instance instance
|
||||
::fn (fn [{:keys [inc labels] :or {inc 1 labels default-empty-labels}}]
|
||||
(let [instance (.labels instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(.inc ^Counter$Child instance (double inc))))}))
|
||||
|
||||
(defn make-gauge
|
||||
[{:keys [name help registry reg labels] :as props}]
|
||||
@@ -114,57 +210,33 @@
|
||||
_ (when (seq labels)
|
||||
(.labelNames instance (into-array String labels)))
|
||||
instance (.register instance registry)]
|
||||
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] instance)
|
||||
|
||||
clojure.lang.IFn
|
||||
(invoke [_ cmd]
|
||||
(case cmd
|
||||
:inc (.inc ^Gauge instance)
|
||||
:dec (.dec ^Gauge instance)))
|
||||
|
||||
(invoke [_ cmd labels]
|
||||
(let [labels (into-array String [labels])]
|
||||
(case cmd
|
||||
:inc (.. ^Gauge instance (labels labels) (inc))
|
||||
:dec (.. ^Gauge instance (labels labels) (dec))))))))
|
||||
|
||||
(def default-quantiles
|
||||
[[0.75 0.02]
|
||||
[0.99 0.001]])
|
||||
{::instance instance
|
||||
::fn (fn [{:keys [inc dec labels val] :or {labels default-empty-labels}}]
|
||||
(let [instance (.labels ^Gauge instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(cond (number? inc) (.inc ^Gauge$Child instance (double inc))
|
||||
(number? dec) (.dec ^Gauge$Child instance (double dec))
|
||||
(number? val) (.set ^Gauge$Child instance (double val)))))}))
|
||||
|
||||
(defn make-summary
|
||||
[{:keys [name help registry reg labels max-age quantiles buckets]
|
||||
:or {max-age 3600 buckets 6 quantiles default-quantiles} :as props}]
|
||||
:or {max-age 3600 buckets 12 quantiles default-quantiles} :as props}]
|
||||
(let [registry (or registry reg)
|
||||
instance (doto (Summary/build)
|
||||
builder (doto (Summary/build)
|
||||
(.name name)
|
||||
(.help help))
|
||||
_ (when (seq quantiles)
|
||||
(.maxAgeSeconds ^Summary instance max-age)
|
||||
(.ageBuckets ^Summary instance buckets))
|
||||
(.maxAgeSeconds ^Summary$Builder builder ^long max-age)
|
||||
(.ageBuckets ^Summary$Builder builder buckets))
|
||||
_ (doseq [[q e] quantiles]
|
||||
(.quantile ^Summary instance q e))
|
||||
(.quantile ^Summary$Builder builder q e))
|
||||
_ (when (seq labels)
|
||||
(.labelNames instance (into-array String labels)))
|
||||
instance (.register instance registry)]
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] instance)
|
||||
(.labelNames ^Summary$Builder builder (into-array String labels)))
|
||||
instance (.register ^Summary$Builder builder registry)]
|
||||
|
||||
clojure.lang.IFn
|
||||
(invoke [_ cmd val]
|
||||
(.observe ^Summary instance val))
|
||||
|
||||
(invoke [_ cmd val labels]
|
||||
(.. ^Summary instance
|
||||
(labels (into-array String labels))
|
||||
(observe val))))))
|
||||
|
||||
(def default-histogram-buckets
|
||||
[1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500])
|
||||
{::instance instance
|
||||
::fn (fn [{:keys [val labels] :or {labels default-empty-labels}}]
|
||||
(let [instance (.labels ^Summary instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(.observe ^Summary$Child instance val)))}))
|
||||
|
||||
(defn make-histogram
|
||||
[{:keys [name help registry reg labels buckets]
|
||||
@@ -177,18 +249,11 @@
|
||||
_ (when (seq labels)
|
||||
(.labelNames instance (into-array String labels)))
|
||||
instance (.register instance registry)]
|
||||
(reify
|
||||
clojure.lang.IDeref
|
||||
(deref [_] instance)
|
||||
|
||||
clojure.lang.IFn
|
||||
(invoke [_ cmd val]
|
||||
(.observe ^Histogram instance val))
|
||||
|
||||
(invoke [_ cmd val labels]
|
||||
(.. ^Histogram instance
|
||||
(labels (into-array String labels))
|
||||
(observe val))))))
|
||||
{::instance instance
|
||||
::fn (fn [{:keys [val labels] :or {labels default-empty-labels}}]
|
||||
(let [instance (.labels ^Histogram instance (if (is-array? labels) labels (into-array String labels)))]
|
||||
(.observe ^Histogram$Child instance val)))}))
|
||||
|
||||
(defn create
|
||||
[{:keys [type] :as props}]
|
||||
@@ -197,118 +262,3 @@
|
||||
:gauge (make-gauge props)
|
||||
:summary (make-summary props)
|
||||
:histogram (make-histogram props)))
|
||||
|
||||
(defn wrap-counter
|
||||
([rootf mobj]
|
||||
(let [mdata (meta rootf)
|
||||
origf (::original mdata rootf)]
|
||||
(with-meta
|
||||
(fn
|
||||
([a]
|
||||
(mobj :inc)
|
||||
(origf a))
|
||||
([a b]
|
||||
(mobj :inc)
|
||||
(origf a b))
|
||||
([a b c]
|
||||
(mobj :inc)
|
||||
(origf a b c))
|
||||
([a b c d]
|
||||
(mobj :inc)
|
||||
(origf a b c d))
|
||||
([a b c d & more]
|
||||
(mobj :inc)
|
||||
(apply origf a b c d more)))
|
||||
(assoc mdata ::original origf))))
|
||||
([rootf mobj labels]
|
||||
(let [mdata (meta rootf)
|
||||
origf (::original mdata rootf)]
|
||||
(with-meta
|
||||
(fn
|
||||
([a]
|
||||
(mobj :inc labels)
|
||||
(origf a))
|
||||
([a b]
|
||||
(mobj :inc labels)
|
||||
(origf a b))
|
||||
([a b & more]
|
||||
(mobj :inc labels)
|
||||
(apply origf a b more)))
|
||||
(assoc mdata ::original origf)))))
|
||||
|
||||
(defn wrap-summary
|
||||
([rootf mobj]
|
||||
(let [mdata (meta rootf)
|
||||
origf (::original mdata rootf)]
|
||||
(with-meta
|
||||
(fn
|
||||
([a]
|
||||
(with-measure
|
||||
:expr (origf a)
|
||||
:cb #(mobj :observe %)))
|
||||
([a b]
|
||||
(with-measure
|
||||
:expr (origf a b)
|
||||
:cb #(mobj :observe %)))
|
||||
([a b & more]
|
||||
(with-measure
|
||||
:expr (apply origf a b more)
|
||||
:cb #(mobj :observe %))))
|
||||
(assoc mdata ::original origf))))
|
||||
|
||||
([rootf mobj labels]
|
||||
(let [mdata (meta rootf)
|
||||
origf (::original mdata rootf)]
|
||||
(with-meta
|
||||
(fn
|
||||
([a]
|
||||
(with-measure
|
||||
:expr (origf a)
|
||||
:cb #(mobj :observe % labels)))
|
||||
([a b]
|
||||
(with-measure
|
||||
:expr (origf a b)
|
||||
:cb #(mobj :observe % labels)))
|
||||
([a b & more]
|
||||
(with-measure
|
||||
:expr (apply origf a b more)
|
||||
:cb #(mobj :observe % labels))))
|
||||
(assoc mdata ::original origf)))))
|
||||
|
||||
(defn instrument-vars!
|
||||
[vars {:keys [wrap] :as props}]
|
||||
(let [obj (create props)]
|
||||
(cond
|
||||
(instance? Counter @obj)
|
||||
(doseq [var vars]
|
||||
(alter-var-root var (or wrap wrap-counter) obj))
|
||||
|
||||
(instance? Summary @obj)
|
||||
(doseq [var vars]
|
||||
(alter-var-root var (or wrap wrap-summary) obj))
|
||||
|
||||
:else
|
||||
(ex/raise :type :not-implemented))))
|
||||
|
||||
(defn instrument
|
||||
[f {:keys [wrap] :as props}]
|
||||
(let [obj (create props)]
|
||||
(cond
|
||||
(instance? Counter @obj)
|
||||
((or wrap wrap-counter) f obj)
|
||||
|
||||
(instance? Summary @obj)
|
||||
((or wrap wrap-summary) f obj)
|
||||
|
||||
(instance? Histogram @obj)
|
||||
((or wrap wrap-summary) f obj)
|
||||
|
||||
:else
|
||||
(ex/raise :type :not-implemented))))
|
||||
|
||||
(defn instrument-jetty!
|
||||
[^CollectorRegistry registry ^StatisticsHandler handler]
|
||||
(doto (JettyStatisticsCollector. handler)
|
||||
(.register registry))
|
||||
nil)
|
||||
|
||||
|
||||
@@ -193,6 +193,39 @@
|
||||
|
||||
{:name "0061-mod-file-table"
|
||||
:fn (mg/resource "app/migrations/sql/0061-mod-file-table.sql")}
|
||||
|
||||
{:name "0062-fix-metadata-media"
|
||||
:fn (mg/resource "app/migrations/sql/0062-fix-metadata-media.sql")}
|
||||
|
||||
{:name "0063-add-share-link-table"
|
||||
:fn (mg/resource "app/migrations/sql/0063-add-share-link-table.sql")}
|
||||
|
||||
{:name "0064-mod-audit-log-table"
|
||||
:fn (mg/resource "app/migrations/sql/0064-mod-audit-log-table.sql")}
|
||||
|
||||
{:name "0065-add-trivial-spelling-fixes"
|
||||
:fn (mg/resource "app/migrations/sql/0065-add-trivial-spelling-fixes.sql")}
|
||||
|
||||
{:name "0066-add-frame-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0066-add-frame-thumbnail-table.sql")}
|
||||
|
||||
{:name "0067-add-team-invitation-table"
|
||||
:fn (mg/resource "app/migrations/sql/0067-add-team-invitation-table.sql")}
|
||||
|
||||
{:name "0068-mod-storage-object-table"
|
||||
:fn (mg/resource "app/migrations/sql/0068-mod-storage-object-table.sql")}
|
||||
|
||||
{:name "0069-add-file-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0069-add-file-thumbnail-table.sql")}
|
||||
|
||||
{:name "0070-del-frame-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0070-del-frame-thumbnail-table.sql")}
|
||||
|
||||
{:name "0071-add-file-object-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0071-add-file-object-thumbnail-table.sql")}
|
||||
|
||||
{:name "0072-mod-file-object-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0072-mod-file-object-thumbnail-table.sql")}
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE storage_data (
|
||||
CREATE INDEX storage_data__id__idx ON storage_data(id);
|
||||
|
||||
-- Table used for store inflight upload ids, for later recheck and
|
||||
-- delete possible staled files that exists on the phisical storage
|
||||
-- delete possible staled files that exists on the physical storage
|
||||
-- but does not exists in the 'storage_object' table.
|
||||
|
||||
CREATE TABLE storage_pending (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Fix problem with content-type inconherence
|
||||
-- Fix problem with content-type incoherence
|
||||
|
||||
UPDATE storage_object so
|
||||
SET metadata = jsonb_set(metadata, '{~:content-type}', to_jsonb(fmo.mtype))
|
||||
|
||||
12
backend/src/app/migrations/sql/0063-add-share-link-table.sql
Normal file
12
backend/src/app/migrations/sql/0063-add-share-link-table.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE share_link (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
owner_id uuid NULL REFERENCES profile(id) ON DELETE SET NULL DEFERRABLE,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
pages uuid[],
|
||||
flags text[]
|
||||
);
|
||||
|
||||
CREATE INDEX share_link_file_id_idx ON share_link(file_id);
|
||||
CREATE INDEX share_link_owner_id_idx ON share_link(owner_id);
|
||||
13
backend/src/app/migrations/sql/0064-mod-audit-log-table.sql
Normal file
13
backend/src/app/migrations/sql/0064-mod-audit-log-table.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE audit_log
|
||||
ADD COLUMN tracked_at timestamptz NULL DEFAULT clock_timestamp(),
|
||||
ADD COLUMN source text NULL,
|
||||
ADD COLUMN context jsonb NULL;
|
||||
|
||||
ALTER TABLE audit_log
|
||||
ALTER COLUMN source SET STORAGE external,
|
||||
ALTER COLUMN context SET STORAGE external;
|
||||
|
||||
UPDATE audit_log SET source = 'backend', tracked_at=created_at;
|
||||
|
||||
-- ALTER TABLE audit_log ALTER COLUMN source SET NOT NULL;
|
||||
-- ALTER TABLE audit_log ALTER COLUMN tracked_at SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER INDEX file__modified_at__has_media_trimed__idx RENAME TO file__modified_at__has_media_trimmed__idx;
|
||||
ALTER INDEX media_bject__file_id__idx RENAME TO media_object__file_id__idx;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE file_frame_thumbnail (
|
||||
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE,
|
||||
frame_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
data text NULL,
|
||||
|
||||
PRIMARY KEY(file_id, frame_id)
|
||||
);
|
||||
|
||||
ALTER TABLE file_frame_thumbnail
|
||||
ALTER COLUMN data SET STORAGE external;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE team_invitation (
|
||||
team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
email_to text NOT NULL,
|
||||
role text NOT NULL,
|
||||
valid_until timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY(team_id, email_to)
|
||||
);
|
||||
|
||||
ALTER TABLE team_invitation
|
||||
ALTER COLUMN email_to SET STORAGE external,
|
||||
ALTER COLUMN role SET STORAGE external;
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE INDEX storage_object__hash_backend_bucket__idx
|
||||
ON storage_object ((metadata->>'~:hash'), (metadata->>'~:bucket'), backend)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE file_thumbnail (
|
||||
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE,
|
||||
revn bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
data text NULL,
|
||||
props jsonb NULL,
|
||||
PRIMARY KEY(file_id, revn)
|
||||
);
|
||||
|
||||
ALTER TABLE file_thumbnail
|
||||
ALTER COLUMN data SET STORAGE external,
|
||||
ALTER COLUMN props SET STORAGE external;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE file_frame_thumbnail;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE file_object_thumbnail (
|
||||
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE,
|
||||
object_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
data text NULL,
|
||||
|
||||
PRIMARY KEY(file_id, object_id)
|
||||
);
|
||||
|
||||
ALTER TABLE file_object_thumbnail
|
||||
ALTER COLUMN data SET STORAGE external;
|
||||
@@ -0,0 +1,4 @@
|
||||
TRUNCATE TABLE file_object_thumbnail;
|
||||
|
||||
ALTER TABLE file_object_thumbnail
|
||||
ALTER COLUMN object_id TYPE text;
|
||||
@@ -1,5 +1,5 @@
|
||||
--- This is a second migration but it should be applied when manual
|
||||
--- migration intervention is alteady executed.
|
||||
--- migration intervention is already executed.
|
||||
|
||||
ALTER TABLE file_media_object ALTER COLUMN media_id SET NOT NULL;
|
||||
DROP TABLE file_media_thumbnail;
|
||||
|
||||
@@ -7,18 +7,20 @@
|
||||
(ns app.msgbus
|
||||
"The msgbus abstraction implemented using redis as underlying backend."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.transit :as t]
|
||||
[app.config :as cfg]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.logging :as l]
|
||||
[app.util.async :as aa]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p])
|
||||
(:import
|
||||
java.time.Duration
|
||||
io.lettuce.core.RedisClient
|
||||
io.lettuce.core.RedisURI
|
||||
io.lettuce.core.api.StatefulConnection
|
||||
@@ -29,7 +31,12 @@
|
||||
io.lettuce.core.codec.StringCodec
|
||||
io.lettuce.core.pubsub.RedisPubSubListener
|
||||
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
||||
io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands))
|
||||
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
|
||||
io.lettuce.core.resource.ClientResources
|
||||
io.lettuce.core.resource.DefaultClientResources
|
||||
java.time.Duration))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private prefix (cfg/get :tenant))
|
||||
|
||||
@@ -37,266 +44,254 @@
|
||||
[topic]
|
||||
(str prefix "." topic))
|
||||
|
||||
(def xform-prefix (map prefix-topic))
|
||||
(def xform-topics (map (fn [m] (update m :topics #(into #{} xform-prefix %)))))
|
||||
(def xform-topic (map (fn [m] (update m :topic prefix-topic))))
|
||||
(def ^:private xform-prefix-topic
|
||||
(map (fn [obj] (update obj :topic prefix-topic))))
|
||||
|
||||
(s/def ::redis-uri ::us/string)
|
||||
(s/def ::buffer-size ::us/integer)
|
||||
|
||||
(defmulti init-backend :backend)
|
||||
(defmulti stop-backend :backend)
|
||||
(defmulti init-pub-loop :backend)
|
||||
(defmulti init-sub-loop :backend)
|
||||
|
||||
(defmethod ig/pre-init-spec ::msgbus [_]
|
||||
(s/keys :opt-un [::buffer-size ::redis-uri]))
|
||||
(declare ^:private redis-connect)
|
||||
(declare ^:private redis-disconnect)
|
||||
(declare ^:private start-io-loop)
|
||||
(declare ^:private subscribe)
|
||||
(declare ^:private purge)
|
||||
(declare ^:private redis-pub)
|
||||
(declare ^:private redis-sub)
|
||||
(declare ^:private redis-unsub)
|
||||
|
||||
(defmethod ig/prep-key ::msgbus
|
||||
[_ cfg]
|
||||
(merge {:buffer-size 128} cfg))
|
||||
(merge {:buffer-size 128
|
||||
:timeout (dt/duration {:seconds 30})}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(s/def ::timeout ::dt/duration)
|
||||
(s/def ::redis-uri ::us/string)
|
||||
(s/def ::buffer-size ::us/integer)
|
||||
|
||||
(defmethod ig/pre-init-spec ::msgbus [_]
|
||||
(s/keys :req-un [::buffer-size ::redis-uri ::timeout ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::msgbus
|
||||
[_ {:keys [backend buffer-size] :as cfg}]
|
||||
(l/debug :action "initialize msgbus"
|
||||
:backend (name backend))
|
||||
(let [cfg (init-backend cfg)
|
||||
[_ {:keys [buffer-size redis-uri] :as cfg}]
|
||||
(l/info :hint "initialize msgbus"
|
||||
:buffer-size buffer-size
|
||||
:redis-uri redis-uri)
|
||||
(let [cmd-ch (a/chan buffer-size)
|
||||
rcv-ch (a/chan (a/dropping-buffer buffer-size))
|
||||
pub-ch (a/chan (a/dropping-buffer buffer-size) xform-prefix-topic)
|
||||
state (agent {} :error-handler #(l/error :cause % :hint "unexpected error on agent" ::l/async false))
|
||||
cfg (-> (redis-connect cfg)
|
||||
(assoc ::cmd-ch cmd-ch)
|
||||
(assoc ::rcv-ch rcv-ch)
|
||||
(assoc ::pub-ch pub-ch)
|
||||
(assoc ::state state))]
|
||||
|
||||
;; Channel used for receive publications from the application.
|
||||
pub-ch (-> (a/dropping-buffer buffer-size)
|
||||
(a/chan xform-topic))
|
||||
|
||||
;; Channel used for receive subscription requests.
|
||||
sub-ch (a/chan 1 xform-topics)
|
||||
|
||||
cfg (-> cfg
|
||||
(assoc ::pub-ch pub-ch)
|
||||
(assoc ::sub-ch sub-ch))]
|
||||
|
||||
(init-pub-loop cfg)
|
||||
(init-sub-loop cfg)
|
||||
(start-io-loop cfg)
|
||||
|
||||
(with-meta
|
||||
(fn run
|
||||
([command] (run command nil))
|
||||
([command params]
|
||||
(a/go
|
||||
(case command
|
||||
:pub (a/>! pub-ch params)
|
||||
:sub (a/>! sub-ch params)))))
|
||||
(fn [& {:keys [cmd] :as params}]
|
||||
(a/go
|
||||
(case cmd
|
||||
:pub (a/>! pub-ch params)
|
||||
:sub (a/<! (subscribe cfg params))
|
||||
:purge (a/<! (purge cfg params))
|
||||
(l/error :hint "unexpeced error on msgbus command processing" :params params))))
|
||||
cfg)))
|
||||
|
||||
(defmethod ig/halt-key! ::msgbus
|
||||
[_ f]
|
||||
(let [mdata (meta f)]
|
||||
(stop-backend mdata)
|
||||
(a/close! (::pub-ch mdata))
|
||||
(a/close! (::sub-ch mdata))))
|
||||
(redis-disconnect mdata)
|
||||
(a/close! (::cmd-ch mdata))
|
||||
(a/close! (::rcv-ch mdata))))
|
||||
|
||||
;; --- IN-MEMORY BACKEND IMPL
|
||||
;; --- IMPL
|
||||
|
||||
(defmethod init-backend :memory [cfg] cfg)
|
||||
(defmethod stop-backend :memory [_])
|
||||
(defmethod init-pub-loop :memory [_])
|
||||
(defn- redis-connect
|
||||
[{:keys [redis-uri timeout] :as cfg}]
|
||||
(let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE)
|
||||
|
||||
(defmethod init-sub-loop :memory
|
||||
[{:keys [::sub-ch ::pub-ch]}]
|
||||
(a/go-loop [state {}]
|
||||
(let [[val port] (a/alts! [pub-ch sub-ch])]
|
||||
(cond
|
||||
(and (= port sub-ch) (some? val))
|
||||
(let [{:keys [topics chan]} val]
|
||||
(recur (reduce #(update %1 %2 (fnil conj #{}) chan) state topics)))
|
||||
resources (.. (DefaultClientResources/builder)
|
||||
(ioThreadPoolSize 4)
|
||||
(computationThreadPoolSize 4)
|
||||
(build))
|
||||
|
||||
(and (= port pub-ch) (some? val))
|
||||
(let [topic (:topic val)
|
||||
message (:message val)
|
||||
state (loop [state state
|
||||
chans (get state topic)]
|
||||
(if-let [c (first chans)]
|
||||
(if (a/>! c message)
|
||||
(recur state (rest chans))
|
||||
(recur (update state topic disj c)
|
||||
(rest chans)))
|
||||
state))]
|
||||
(recur state))
|
||||
uri (RedisURI/create redis-uri)
|
||||
rclient (RedisClient/create ^ClientResources resources ^RedisURI uri)
|
||||
|
||||
:else
|
||||
(->> (vals state)
|
||||
(mapcat identity)
|
||||
(run! a/close!))))))
|
||||
pconn (.connect ^RedisClient rclient ^RedisCodec codec)
|
||||
sconn (.connectPubSub ^RedisClient rclient ^RedisCodec codec)]
|
||||
|
||||
|
||||
;; Add a unique listener to connection
|
||||
|
||||
;; --- REDIS BACKEND IMPL
|
||||
|
||||
(declare impl-redis-open?)
|
||||
(declare impl-redis-pub)
|
||||
(declare impl-redis-sub)
|
||||
(declare impl-redis-unsub)
|
||||
|
||||
(defmethod init-backend :redis
|
||||
[{:keys [redis-uri] :as cfg}]
|
||||
(let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE)
|
||||
|
||||
uri (RedisURI/create redis-uri)
|
||||
rclient (RedisClient/create ^RedisURI uri)
|
||||
|
||||
pub-conn (.connect ^RedisClient rclient ^RedisCodec codec)
|
||||
sub-conn (.connectPubSub ^RedisClient rclient ^RedisCodec codec)]
|
||||
|
||||
(.setTimeout ^StatefulRedisConnection pub-conn ^Duration (dt/duration {:seconds 10}))
|
||||
(.setTimeout ^StatefulRedisPubSubConnection sub-conn ^Duration (dt/duration {:seconds 10}))
|
||||
(.setTimeout ^StatefulRedisConnection pconn ^Duration timeout)
|
||||
(.setTimeout ^StatefulRedisPubSubConnection sconn ^Duration timeout)
|
||||
|
||||
(-> cfg
|
||||
(assoc ::pub-conn pub-conn)
|
||||
(assoc ::sub-conn sub-conn))))
|
||||
(assoc ::resources resources)
|
||||
(assoc ::pconn pconn)
|
||||
(assoc ::sconn sconn))))
|
||||
|
||||
(defmethod stop-backend :redis
|
||||
[{:keys [::pub-conn ::sub-conn] :as cfg}]
|
||||
(.close ^StatefulRedisConnection pub-conn)
|
||||
(.close ^StatefulRedisPubSubConnection sub-conn))
|
||||
(defn- redis-disconnect
|
||||
[{:keys [::pconn ::sconn ::resources] :as cfg}]
|
||||
(.. ^StatefulConnection pconn close)
|
||||
(.. ^StatefulConnection sconn close)
|
||||
(.shutdown ^ClientResources resources))
|
||||
|
||||
(defmethod init-pub-loop :redis
|
||||
[{:keys [::pub-conn ::pub-ch]}]
|
||||
(let [rac (.async ^StatefulRedisConnection pub-conn)]
|
||||
(a/go-loop []
|
||||
(when-let [val (a/<! pub-ch)]
|
||||
(let [result (a/<! (impl-redis-pub rac val))]
|
||||
(when (and (impl-redis-open? pub-conn)
|
||||
(ex/exception? result))
|
||||
(l/error :cause result
|
||||
:hint "unexpected error on publish message to redis")))
|
||||
(recur)))))
|
||||
(defn- conj-subscription
|
||||
"A low level function that is responsible to create on-demand
|
||||
subscriptions on redis. It reuses the same subscription if it is
|
||||
already established. Intended to be executed in agent."
|
||||
[nsubs cfg topic chan]
|
||||
(let [nsubs (if (nil? nsubs) #{chan} (conj nsubs chan))]
|
||||
(when (= 1 (count nsubs))
|
||||
(l/trace :hint "open subscription" :topic topic ::l/async false)
|
||||
(redis-sub cfg topic))
|
||||
nsubs))
|
||||
|
||||
(defmethod init-sub-loop :redis
|
||||
[{:keys [::sub-conn ::sub-ch buffer-size]}]
|
||||
(let [rcv-ch (a/chan (a/dropping-buffer buffer-size))
|
||||
chans (agent {} :error-handler #(l/error :cause % :hint "unexpected error on agent"))
|
||||
rac (.async ^StatefulRedisPubSubConnection sub-conn)]
|
||||
(defn- disj-subscription
|
||||
"A low level function responsible on removing subscriptions. The
|
||||
subscription is trully removed from redis once no single local
|
||||
subscription is look for it. Intended to be executed in agent."
|
||||
[nsubs cfg topic chan]
|
||||
(let [nsubs (disj nsubs chan)]
|
||||
(when (empty? nsubs)
|
||||
(l/trace :hint "close subscription" :topic topic ::l/async false)
|
||||
(redis-unsub cfg topic))
|
||||
nsubs))
|
||||
|
||||
;; Add a unique listener to connection
|
||||
(.addListener sub-conn
|
||||
(reify RedisPubSubListener
|
||||
(message [it pattern topic message])
|
||||
(message [it topic message]
|
||||
;; There are no back pressure, so we use a slidding
|
||||
;; buffer for cases when the pubsub broker sends
|
||||
;; more messages that we can process.
|
||||
(let [val {:topic topic :message (blob/decode message)}]
|
||||
(when-not (a/offer! rcv-ch val)
|
||||
(l/warn :msg "dropping message on subscription loop"))))
|
||||
(psubscribed [it pattern count])
|
||||
(punsubscribed [it pattern count])
|
||||
(subscribed [it topic count])
|
||||
(unsubscribed [it topic count])))
|
||||
(defn- subscribe-to-topics
|
||||
"Function responsible to attach local subscription to the
|
||||
state. Intended to be used in agent."
|
||||
[state cfg topics chan done-ch]
|
||||
(l/trace :hint "subscribe-to-topics" :topics topics ::l/async false)
|
||||
(aa/with-closing done-ch
|
||||
(let [state (update state :chans assoc chan topics)]
|
||||
(reduce (fn [state topic]
|
||||
(update-in state [:topics topic] conj-subscription cfg topic chan))
|
||||
state
|
||||
topics))))
|
||||
|
||||
(letfn [(subscribe-to-single-topic [nsubs topic chan]
|
||||
(let [nsubs (if (nil? nsubs) #{chan} (conj nsubs chan))]
|
||||
(when (= 1 (count nsubs))
|
||||
(let [result (a/<!! (impl-redis-sub rac topic))]
|
||||
(l/trace :action "open subscription"
|
||||
:topic topic)
|
||||
(when (ex/exception? result)
|
||||
(l/error :cause result
|
||||
:hint "unexpected exception on subscribing"
|
||||
:topic topic))))
|
||||
nsubs))
|
||||
(defn- unsubscribe-single-channel
|
||||
"Auxiliar function responsible on removing a single local
|
||||
subscription from the state."
|
||||
[state cfg chan]
|
||||
(let [topics (get-in state [:chans chan])
|
||||
state (update state :chans dissoc chan)]
|
||||
(reduce (fn [state topic]
|
||||
(update-in state [:topics topic] disj-subscription cfg topic chan))
|
||||
state
|
||||
topics)))
|
||||
|
||||
(subscribe-to-topics [state topics chan]
|
||||
(let [state (update state :chans assoc chan topics)]
|
||||
(reduce (fn [state topic]
|
||||
(update-in state [:topics topic] subscribe-to-single-topic topic chan))
|
||||
state
|
||||
topics)))
|
||||
(defn- unsubscribe-channels
|
||||
"Function responsible from detach from state a seq of channels,
|
||||
useful when client disconnects or in-bulk unsubscribe
|
||||
operations. Intended to be executed in agent."
|
||||
[state cfg channels done-ch]
|
||||
(l/trace :hint "unsubscribe-channels" :chans (count channels) ::l/async false)
|
||||
(aa/with-closing done-ch
|
||||
(reduce #(unsubscribe-single-channel %1 cfg %2) state channels)))
|
||||
|
||||
(unsubscribe-from-single-topic [nsubs topic chan]
|
||||
(let [nsubs (disj nsubs chan)]
|
||||
(when (empty? nsubs)
|
||||
(let [result (a/<!! (impl-redis-unsub rac topic))]
|
||||
(l/trace :action "close subscription"
|
||||
:topic topic)
|
||||
(when (and (impl-redis-open? sub-conn)
|
||||
(ex/exception? result))
|
||||
(l/error :cause result
|
||||
:hint "unexpected exception on unsubscribing"
|
||||
:topic topic))))
|
||||
nsubs))
|
||||
(defn- subscribe
|
||||
[{:keys [::state executor] :as cfg} {:keys [topic topics chan]}]
|
||||
(let [done-ch (a/chan)
|
||||
topics (into [] (map prefix-topic) (if topic [topic] topics))]
|
||||
(l/trace :hint "subscribe" :topics topics)
|
||||
(send-via executor state subscribe-to-topics cfg topics chan done-ch)
|
||||
done-ch))
|
||||
|
||||
(unsubscribe-channels [state pending]
|
||||
(reduce (fn [state ch]
|
||||
(let [topics (get-in state [:chans ch])
|
||||
state (update state :chans dissoc ch)]
|
||||
(reduce (fn [state topic]
|
||||
(update-in state [:topics topic] unsubscribe-from-single-topic topic ch))
|
||||
state
|
||||
topics)))
|
||||
state
|
||||
pending))]
|
||||
(defn- purge
|
||||
[{:keys [::state executor] :as cfg} {:keys [chans]}]
|
||||
(l/trace :hint "purge" :chans (count chans))
|
||||
(let [done-ch (a/chan)]
|
||||
(send-via executor state unsubscribe-channels cfg chans done-ch)
|
||||
done-ch))
|
||||
|
||||
;; Asynchronous subscription loop;
|
||||
(a/go-loop []
|
||||
(if-let [{:keys [topics chan]} (a/<! sub-ch)]
|
||||
(do
|
||||
(send-off chans subscribe-to-topics topics chan)
|
||||
(recur))
|
||||
(a/close! rcv-ch)))
|
||||
(defn- create-listener
|
||||
[rcv-ch]
|
||||
(reify RedisPubSubListener
|
||||
(message [_ _pattern _topic _message])
|
||||
(message [_ topic message]
|
||||
;; There are no back pressure, so we use a slidding
|
||||
;; buffer for cases when the pubsub broker sends
|
||||
;; more messages that we can process.
|
||||
(let [val {:topic topic :message (t/decode message)}]
|
||||
(when-not (a/offer! rcv-ch val)
|
||||
(l/warn :msg "dropping message on subscription loop"))))
|
||||
(psubscribed [_ _pattern _count])
|
||||
(punsubscribed [_ _pattern _count])
|
||||
(subscribed [_ _topic _count])
|
||||
(unsubscribed [_ _topic _count])))
|
||||
|
||||
;; Asyncrhonous message processing loop;x
|
||||
(a/go-loop []
|
||||
(if-let [{:keys [topic message]} (a/<! rcv-ch)]
|
||||
;; This means we receive data from redis and we need to
|
||||
;; forward it to the underlying subscriptions.
|
||||
(let [pending (loop [chans (seq (get-in @chans [:topics topic]))
|
||||
pending #{}]
|
||||
(if-let [ch (first chans)]
|
||||
(if (a/>! ch message)
|
||||
(recur (rest chans) pending)
|
||||
(recur (rest chans) (conj pending ch)))
|
||||
pending))]
|
||||
(some->> (seq pending)
|
||||
(send-off chans unsubscribe-channels))
|
||||
(defn start-io-loop
|
||||
[{:keys [::sconn ::rcv-ch ::pub-ch ::state executor] :as cfg}]
|
||||
|
||||
(recur))
|
||||
;; Add a single listener to the pubsub connection
|
||||
(.addListener ^StatefulRedisPubSubConnection sconn
|
||||
^RedisPubSubListener (create-listener rcv-ch))
|
||||
|
||||
;; Stop condition; close all underlying subscriptions and
|
||||
;; exit. The close operation is performed asynchronously.
|
||||
(send-off chans (fn [state]
|
||||
(->> (vals state)
|
||||
(mapcat identity)
|
||||
(filter some?)
|
||||
(run! a/close!)))))))))
|
||||
(letfn [(send-to-topic [topic message]
|
||||
(a/go-loop [chans (seq (get-in @state [:topics topic]))
|
||||
closed #{}]
|
||||
(if-let [ch (first chans)]
|
||||
(if (a/>! ch message)
|
||||
(recur (rest chans) closed)
|
||||
(recur (rest chans) (conj closed ch)))
|
||||
(seq closed))))
|
||||
|
||||
(process-incoming [{:keys [topic message]}]
|
||||
(a/go
|
||||
(when-let [closed (a/<! (send-to-topic topic message))]
|
||||
(send-via executor state unsubscribe-channels cfg closed nil))))
|
||||
]
|
||||
|
||||
(defn- impl-redis-open?
|
||||
[^StatefulConnection conn]
|
||||
(.isOpen conn))
|
||||
(a/go-loop []
|
||||
(let [[val port] (a/alts! [pub-ch rcv-ch])]
|
||||
(cond
|
||||
(nil? val)
|
||||
(do
|
||||
(l/trace :hint "stoping io-loop, nil received")
|
||||
(send-via executor state (fn [state]
|
||||
(->> (vals state)
|
||||
(mapcat identity)
|
||||
(filter some?)
|
||||
(run! a/close!))
|
||||
nil)))
|
||||
|
||||
(defn- impl-redis-pub
|
||||
[^RedisAsyncCommands rac {:keys [topic message]}]
|
||||
(let [message (blob/encode message)
|
||||
res (a/chan 1)]
|
||||
(-> (.publish rac ^String topic ^bytes message)
|
||||
(p/finally (fn [_ e]
|
||||
(when e (a/>!! res e))
|
||||
(= port rcv-ch)
|
||||
(do
|
||||
(a/<! (process-incoming val))
|
||||
(recur))
|
||||
|
||||
(= port pub-ch)
|
||||
(let [result (a/<! (redis-pub cfg val))]
|
||||
(when (ex/exception? result)
|
||||
(l/error :hint "unexpected error on publishing" :message val
|
||||
:cause result))
|
||||
(recur)))))))
|
||||
|
||||
(defn- redis-pub
|
||||
"Publish a message to the redis server. Asynchronous operation,
|
||||
intended to be used in core.async go blocks."
|
||||
[{:keys [::pconn] :as cfg} {:keys [topic message]}]
|
||||
(let [message (t/encode message)
|
||||
res (a/chan 1)
|
||||
pcomm (.async ^StatefulRedisConnection pconn)]
|
||||
(-> (.publish ^RedisAsyncCommands pcomm ^String topic ^bytes message)
|
||||
(p/finally (fn [_ cause]
|
||||
(when (and cause (.isOpen ^StatefulConnection pconn))
|
||||
(a/offer! res cause))
|
||||
(a/close! res))))
|
||||
res))
|
||||
|
||||
(defn impl-redis-sub
|
||||
[^RedisPubSubAsyncCommands rac topic]
|
||||
(let [res (a/chan 1)]
|
||||
(-> (.subscribe rac (into-array String [topic]))
|
||||
(p/finally (fn [_ e]
|
||||
(when e (a/>!! res e))
|
||||
(a/close! res))))
|
||||
res))
|
||||
(defn redis-sub
|
||||
"Create redis subscription. Blocking operation, intended to be used
|
||||
inside an agent."
|
||||
[{:keys [::sconn] :as cfg} topic]
|
||||
(let [topic (into-array String [topic])
|
||||
scomm (.sync ^StatefulRedisPubSubConnection sconn)]
|
||||
(.subscribe ^RedisPubSubCommands scomm topic)))
|
||||
|
||||
(defn impl-redis-unsub
|
||||
[rac topic]
|
||||
(let [res (a/chan 1)]
|
||||
(-> (.unsubscribe rac (into-array String [topic]))
|
||||
(p/finally (fn [_ e]
|
||||
(when e (a/>!! res e))
|
||||
(a/close! res))))
|
||||
res))
|
||||
(defn redis-unsub
|
||||
"Removes redis subscription. Blocking operation, intended to be used
|
||||
inside an agent."
|
||||
[{:keys [::sconn] :as cfg} topic]
|
||||
(let [topic (into-array String [topic])
|
||||
scomm (.sync ^StatefulRedisPubSubConnection sconn)]
|
||||
(.unsubscribe ^RedisPubSubCommands scomm topic)))
|
||||
|
||||
@@ -1,280 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.notifications
|
||||
"A websocket based notifications mechanism."
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.common.transit :as t]
|
||||
[app.db :as db]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.async :as aa]
|
||||
[app.util.logging :as l]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[ring.adapter.jetty9 :as jetty]
|
||||
[ring.middleware.cookies :refer [wrap-cookies]]
|
||||
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
|
||||
[ring.middleware.params :refer [wrap-params]]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Http Handler
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare retrieve-file)
|
||||
(declare websocket)
|
||||
(declare handler)
|
||||
|
||||
(s/def ::session map?)
|
||||
(s/def ::msgbus fn?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::msgbus ::db/pool ::session ::mtx/metrics ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [session metrics] :as cfg}]
|
||||
(let [wrap-session (:middleware session)
|
||||
|
||||
mtx-active-connections
|
||||
(mtx/create
|
||||
{:name "websocket_active_connections"
|
||||
:registry (:registry metrics)
|
||||
:type :gauge
|
||||
:help "Active websocket connections."})
|
||||
|
||||
mtx-messages
|
||||
(mtx/create
|
||||
{:name "websocket_message_total"
|
||||
:registry (:registry metrics)
|
||||
:labels ["op"]
|
||||
:type :counter
|
||||
:help "Counter of processed messages."})
|
||||
|
||||
mtx-sessions
|
||||
(mtx/create
|
||||
{:name "websocket_session_timing"
|
||||
:registry (:registry metrics)
|
||||
:quantiles []
|
||||
:help "Websocket session timing (seconds)."
|
||||
:type :summary})
|
||||
|
||||
cfg (assoc cfg
|
||||
:mtx-active-connections mtx-active-connections
|
||||
:mtx-messages mtx-messages
|
||||
:mtx-sessions mtx-sessions
|
||||
)]
|
||||
(-> #(handler cfg %)
|
||||
(wrap-session)
|
||||
(wrap-keyword-params)
|
||||
(wrap-cookies)
|
||||
(wrap-params))))
|
||||
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::session-id ::us/uuid)
|
||||
|
||||
(s/def ::websocket-handler-params
|
||||
(s/keys :req-un [::file-id ::session-id]))
|
||||
|
||||
(defn- handler
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id params] :as req}]
|
||||
(let [params (us/conform ::websocket-handler-params params)
|
||||
file (retrieve-file pool (:file-id params))
|
||||
cfg (merge cfg params
|
||||
{:profile-id profile-id
|
||||
:team-id (:team-id file)})]
|
||||
(cond
|
||||
(not profile-id)
|
||||
{:error {:code 403 :message "Authentication required"}}
|
||||
|
||||
(not file)
|
||||
{:error {:code 404 :message "File does not exists"}}
|
||||
|
||||
:else
|
||||
(websocket cfg))))
|
||||
|
||||
(def ^:private
|
||||
sql:retrieve-file
|
||||
"select f.id as id,
|
||||
p.team_id as team_id
|
||||
from file as f
|
||||
join project as p on (p.id = f.project_id)
|
||||
where f.id = ?")
|
||||
|
||||
(defn- retrieve-file
|
||||
[conn id]
|
||||
(db/exec-one! conn [sql:retrieve-file id]))
|
||||
|
||||
|
||||
;; --- WEBSOCKET INIT
|
||||
|
||||
(declare handle-connect)
|
||||
|
||||
(defn- ws-send
|
||||
[conn data]
|
||||
(try
|
||||
(when (jetty/connected? conn)
|
||||
(jetty/send! conn data)
|
||||
true)
|
||||
(catch java.lang.NullPointerException _e
|
||||
false)))
|
||||
|
||||
(defn websocket
|
||||
[{:keys [file-id team-id msgbus executor] :as cfg}]
|
||||
(let [rcv-ch (a/chan 32)
|
||||
out-ch (a/chan 32)
|
||||
mtx-aconn (:mtx-active-connections cfg)
|
||||
mtx-messages (:mtx-messages cfg)
|
||||
mtx-sessions (:mtx-sessions cfg)
|
||||
created-at (dt/now)
|
||||
ws-send (mtx/wrap-counter ws-send mtx-messages ["send"])]
|
||||
|
||||
(letfn [(on-connect [conn]
|
||||
(mtx-aconn :inc)
|
||||
;; A subscription channel should use a lossy buffer
|
||||
;; because we can't penalize normal clients when one
|
||||
;; slow client is connected to the room.
|
||||
(let [sub-ch (a/chan (a/dropping-buffer 128))
|
||||
cfg (assoc cfg
|
||||
:conn conn
|
||||
:rcv-ch rcv-ch
|
||||
:out-ch out-ch
|
||||
:sub-ch sub-ch)]
|
||||
|
||||
(l/trace :event "connect" :session (:session-id cfg))
|
||||
|
||||
;; Forward all messages from out-ch to the websocket
|
||||
;; connection
|
||||
(a/go-loop []
|
||||
(let [val (a/<! out-ch)]
|
||||
(when (some? val)
|
||||
(when (a/<! (aa/thread-call executor #(ws-send conn (t/encode-str val))))
|
||||
(recur)))))
|
||||
|
||||
(a/go
|
||||
;; Subscribe to corresponding topics
|
||||
(a/<! (msgbus :sub {:topics [file-id team-id] :chan sub-ch}))
|
||||
(a/<! (handle-connect cfg))
|
||||
|
||||
;; when connection is closed
|
||||
(mtx-aconn :dec)
|
||||
(mtx-sessions :observe (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0))
|
||||
|
||||
;; close subscription
|
||||
(a/close! sub-ch))))
|
||||
|
||||
(on-error [_conn _e]
|
||||
(l/trace :event "error" :session (:session-id cfg))
|
||||
|
||||
(a/close! out-ch)
|
||||
(a/close! rcv-ch))
|
||||
|
||||
(on-close [_conn _status _reason]
|
||||
(l/trace :event "close" :session (:session-id cfg))
|
||||
|
||||
(a/close! out-ch)
|
||||
(a/close! rcv-ch))
|
||||
|
||||
(on-message [_ws message]
|
||||
(let [message (t/decode-str message)]
|
||||
(when-not (a/offer! rcv-ch message)
|
||||
(l/warn :msg "drop messages"))))]
|
||||
|
||||
{:on-connect on-connect
|
||||
:on-error on-error
|
||||
:on-close on-close
|
||||
:on-text (mtx/wrap-counter on-message mtx-messages ["recv"])
|
||||
:on-bytes (constantly nil)})))
|
||||
|
||||
;; --- CONNECTION INIT
|
||||
|
||||
(declare send-presence)
|
||||
(declare handle-message)
|
||||
(declare start-loop!)
|
||||
|
||||
(defn- handle-connect
|
||||
[cfg]
|
||||
(a/go
|
||||
(a/<! (handle-message cfg {:type :connect}))
|
||||
(a/<! (start-loop! cfg))
|
||||
(a/<! (handle-message cfg {:type :disconnect}))))
|
||||
|
||||
(defn- start-loop!
|
||||
[{:keys [rcv-ch out-ch sub-ch session-id] :as cfg}]
|
||||
(a/go-loop []
|
||||
(let [timeout (a/timeout 30000)
|
||||
[val port] (a/alts! [rcv-ch sub-ch timeout])]
|
||||
(cond
|
||||
;; Process message coming from connected client
|
||||
(and (= port rcv-ch) (some? val))
|
||||
(do
|
||||
(a/<! (handle-message cfg val))
|
||||
(recur))
|
||||
|
||||
;; Process message coming from pubsub.
|
||||
(and (= port sub-ch) (some? val))
|
||||
(do
|
||||
(when-not (= (:session-id val) session-id)
|
||||
;; If we receive a connect message of other user, we need
|
||||
;; to send an update presence to all participants.
|
||||
(when (= :connect (:type val))
|
||||
(a/<! (send-presence cfg :presence)))
|
||||
|
||||
;; Then, just forward the message
|
||||
(a/>! out-ch val))
|
||||
(recur))
|
||||
|
||||
;; When timeout channel is signaled, we need to send a ping
|
||||
;; message to the output channel. TODO: we need to make this
|
||||
;; more smart.
|
||||
(= port timeout)
|
||||
(do
|
||||
(a/>! out-ch {:type :ping})
|
||||
(recur))))))
|
||||
|
||||
(defn send-presence
|
||||
([cfg] (send-presence cfg :presence))
|
||||
([{:keys [msgbus session-id profile-id file-id]} type]
|
||||
(a/go
|
||||
(a/<! (msgbus :pub {:topic file-id
|
||||
:message {:type type
|
||||
:session-id session-id
|
||||
:profile-id profile-id}})))))
|
||||
|
||||
;; --- INCOMING MSG PROCESSING
|
||||
|
||||
(defmulti handle-message
|
||||
(fn [_ message] (:type message)))
|
||||
|
||||
(defmethod handle-message :connect
|
||||
[cfg _]
|
||||
(send-presence cfg :connect))
|
||||
|
||||
(defmethod handle-message :disconnect
|
||||
[cfg _]
|
||||
(send-presence cfg :disconnect))
|
||||
|
||||
(defmethod handle-message :keepalive
|
||||
[_ _]
|
||||
(a/go :nothing))
|
||||
|
||||
(defmethod handle-message :pointer-update
|
||||
[{:keys [profile-id file-id session-id msgbus] :as cfg} message]
|
||||
(let [message (assoc message
|
||||
:profile-id profile-id
|
||||
:session-id session-id)]
|
||||
(msgbus :pub {:topic file-id
|
||||
:message message})))
|
||||
|
||||
(defmethod handle-message :default
|
||||
[_ws message]
|
||||
(a/go
|
||||
(l/log :level :warn
|
||||
:msg "received unexpected message"
|
||||
:message message)))
|
||||
|
||||
@@ -1,45 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.rlimits
|
||||
"Resource usage limits (in other words: semaphores)."
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
java.util.concurrent.Semaphore))
|
||||
|
||||
(s/def ::rlimit #(instance? Semaphore %))
|
||||
(s/def ::rlimits (s/map-of ::us/keyword ::rlimit))
|
||||
|
||||
(derive ::password ::instance)
|
||||
(derive ::image ::instance)
|
||||
(derive ::font ::instance)
|
||||
|
||||
(defmethod ig/pre-init-spec ::instance [_]
|
||||
(s/spec int?))
|
||||
|
||||
(defmethod ig/init-key ::instance
|
||||
[_ permits]
|
||||
(Semaphore. (int permits)))
|
||||
|
||||
(defn acquire!
|
||||
[sem]
|
||||
(.acquire ^Semaphore sem))
|
||||
|
||||
(defn release!
|
||||
[sem]
|
||||
(.release ^Semaphore sem))
|
||||
|
||||
(defmacro execute
|
||||
[rlinst & body]
|
||||
`(try
|
||||
(acquire! ~rlinst)
|
||||
~@body
|
||||
(finally
|
||||
(release! ~rlinst))))
|
||||
|
||||
@@ -8,173 +8,206 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.metrics :as mtx]
|
||||
[app.rlimits :as rlm]
|
||||
[app.util.logging :as l]
|
||||
[app.rpc.retry :as retry]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.util.async :as async]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(defn- default-handler
|
||||
[_]
|
||||
(ex/raise :type :not-found))
|
||||
(p/rejected (ex/error :type :not-found)))
|
||||
|
||||
(defn- run-hook
|
||||
[hook-fn response]
|
||||
(ex/ignoring (hook-fn))
|
||||
(defn- handle-response-transformation
|
||||
[response request mdata]
|
||||
(if-let [transform-fn (:transform-response mdata)]
|
||||
(p/do (transform-fn request response))
|
||||
(p/resolved response)))
|
||||
|
||||
(defn- handle-before-comple-hook
|
||||
[response mdata]
|
||||
(when-let [hook-fn (:before-complete mdata)]
|
||||
(ex/ignoring (hook-fn)))
|
||||
response)
|
||||
|
||||
(defn- rpc-query-handler
|
||||
[methods {:keys [profile-id] :as request}]
|
||||
(let [type (keyword (get-in request [:path-params :type]))
|
||||
"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]
|
||||
(letfn [(handle-response [result]
|
||||
(let [mdata (meta result)]
|
||||
(-> (yrs/response 200 result)
|
||||
(handle-response-transformation request mdata))))]
|
||||
|
||||
data (merge (:params request)
|
||||
(:body-params request)
|
||||
(:uploads request)
|
||||
{::request request})
|
||||
(let [type (keyword (:type params))
|
||||
data (into {::request request} params)
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id ::session-id session-id)
|
||||
(dissoc data :profile-id))
|
||||
method (get methods type default-handler)]
|
||||
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id)
|
||||
(dissoc data :profile-id))
|
||||
|
||||
result ((get methods type default-handler) data)
|
||||
mdata (meta result)]
|
||||
|
||||
(cond->> {:status 200 :body result}
|
||||
(fn? (:transform-response mdata))
|
||||
((:transform-response mdata) request))))
|
||||
(-> (method data)
|
||||
(p/then handle-response)
|
||||
(p/then respond)
|
||||
(p/catch (fn [cause]
|
||||
(let [context {:profile-id profile-id}]
|
||||
(raise (ex/wrap-with-context cause context)))))))))
|
||||
|
||||
(defn- rpc-mutation-handler
|
||||
[methods {:keys [profile-id] :as request}]
|
||||
(let [type (keyword (get-in request [:path-params :type]))
|
||||
data (merge (:params request)
|
||||
(:body-params request)
|
||||
(:uploads request)
|
||||
{::request request})
|
||||
"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]
|
||||
(letfn [(handle-response [result]
|
||||
(let [mdata (meta result)]
|
||||
(p/-> (yrs/response 200 result)
|
||||
(handle-response-transformation request mdata)
|
||||
(handle-before-comple-hook mdata))))]
|
||||
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id)
|
||||
(dissoc data :profile-id))
|
||||
(let [type (keyword (:type params))
|
||||
data (into {::request request} params)
|
||||
data (if profile-id
|
||||
(assoc data :profile-id profile-id ::session-id session-id)
|
||||
(dissoc data :profile-id))
|
||||
|
||||
result ((get methods type default-handler) data)
|
||||
mdata (meta result)]
|
||||
(cond->> {:status 200 :body result}
|
||||
(fn? (:transform-response mdata))
|
||||
((:transform-response mdata) request)
|
||||
method (get methods type default-handler)]
|
||||
(-> (method data)
|
||||
(p/then handle-response)
|
||||
(p/then respond)
|
||||
(p/catch (fn [cause]
|
||||
(let [context {:profile-id profile-id}]
|
||||
(raise (ex/wrap-with-context cause context)))))))))
|
||||
|
||||
(fn? (:before-complete mdata))
|
||||
(run-hook (:before-complete mdata)))))
|
||||
(defn- wrap-metrics
|
||||
"Wrap service method with metrics measurement."
|
||||
[{:keys [metrics ::metrics-id]} f mdata]
|
||||
(let [labels (into-array String [(::sv/name mdata)])]
|
||||
(fn [cfg params]
|
||||
(let [start (System/nanoTime)]
|
||||
(p/finally
|
||||
(f cfg params)
|
||||
(fn [_ _]
|
||||
(mtx/run! metrics
|
||||
{:id metrics-id
|
||||
:val (/ (- (System/nanoTime) start) 1000000)
|
||||
:labels labels})))))))
|
||||
|
||||
(defn- wrap-with-metrics
|
||||
[cfg f mdata]
|
||||
(mtx/wrap-summary f (::mobj cfg) [(::sv/name mdata)]))
|
||||
(defn- wrap-dispatch
|
||||
"Wraps service method into async flow, with the ability to dispatching
|
||||
it to a preconfigured executor service."
|
||||
[{:keys [executors] :as cfg} f mdata]
|
||||
(let [dname (::async/dispatch mdata :default)]
|
||||
(if (= :none dname)
|
||||
(with-meta
|
||||
(fn [cfg params]
|
||||
(p/do (f cfg params)))
|
||||
mdata)
|
||||
|
||||
;; Wrap the rpc handler with a semaphore if it is specified in the
|
||||
;; metadata asocciated with the handler.
|
||||
(defn- wrap-with-rlimits
|
||||
[cfg f mdata]
|
||||
(if-let [key (:rlimit mdata)]
|
||||
(let [rlinst (get-in cfg [:rlimits key])]
|
||||
(when-not rlinst
|
||||
(ex/raise :type :internal
|
||||
:code :rlimit-not-configured
|
||||
:hint (str/fmt "%s rlimit not configured" key)))
|
||||
(l/trace :action "add rlimit"
|
||||
:handler (::sv/name mdata))
|
||||
(fn [cfg params]
|
||||
(rlm/execute rlinst (f cfg params))))
|
||||
(let [executor (get executors dname)]
|
||||
(when-not executor
|
||||
(ex/raise :type :internal
|
||||
:code :executor-not-configured
|
||||
:hint (format "executor %s not configured" dname)))
|
||||
(with-meta
|
||||
(fn [cfg params]
|
||||
(-> (px/submit! executor #(f cfg params))
|
||||
(p/bind p/wrap)))
|
||||
mdata)))))
|
||||
|
||||
(defn- wrap-audit
|
||||
[{:keys [audit] :as cfg} f mdata]
|
||||
(if audit
|
||||
(with-meta
|
||||
(fn [cfg {:keys [::request] :as params}]
|
||||
(p/finally (f cfg params)
|
||||
(fn [result _]
|
||||
(when result
|
||||
(let [resultm (meta result)
|
||||
profile-id (or (:profile-id params)
|
||||
(:profile-id result)
|
||||
(::audit/profile-id resultm))
|
||||
props (d/merge params (::audit/props resultm))]
|
||||
(audit :cmd :submit
|
||||
:type (or (::audit/type resultm)
|
||||
(::type cfg))
|
||||
:name (or (::audit/name resultm)
|
||||
(::sv/name mdata))
|
||||
:profile-id profile-id
|
||||
:ip-addr (some-> request audit/parse-client-ip)
|
||||
:props (dissoc props ::request)))))))
|
||||
mdata)
|
||||
f))
|
||||
|
||||
(defn- wrap-impl
|
||||
[{:keys [audit] :as cfg} f mdata]
|
||||
(let [f (wrap-with-rlimits cfg f mdata)
|
||||
f (wrap-with-metrics cfg f mdata)
|
||||
spec (or (::sv/spec mdata) (s/spec any?))
|
||||
auth? (:auth mdata true)]
|
||||
(defn- wrap
|
||||
[cfg f mdata]
|
||||
(let [f (as-> f $
|
||||
(wrap-dispatch cfg $ mdata)
|
||||
(rlimit/wrap-rlimit cfg $ mdata)
|
||||
(retry/wrap-retry cfg $ mdata)
|
||||
(wrap-audit cfg $ mdata)
|
||||
(wrap-metrics cfg $ mdata)
|
||||
)
|
||||
|
||||
spec (or (::sv/spec mdata) (s/spec any?))
|
||||
auth? (:auth mdata true)]
|
||||
|
||||
(l/trace :action "register" :name (::sv/name mdata))
|
||||
(fn [params]
|
||||
(with-meta
|
||||
(fn [{:keys [::request] :as 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))))))
|
||||
|
||||
;; Raise authentication error when rpc method requires auth but
|
||||
;; no profile-id is found in the request.
|
||||
(when (and auth? (not (uuid? (:profile-id params))))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint"))
|
||||
|
||||
(let [params' (dissoc params ::request)
|
||||
params' (us/conform spec params')
|
||||
result (f cfg params')]
|
||||
|
||||
;; When audit log is enabled (default false).
|
||||
(when (fn? audit)
|
||||
(let [resultm (meta result)
|
||||
request (::request params)
|
||||
profile-id (or (:profile-id params')
|
||||
(:profile-id result)
|
||||
(::audit/profile-id resultm))
|
||||
props (d/merge params (::audit/props resultm))]
|
||||
(audit :cmd :submit
|
||||
:type (::type cfg)
|
||||
:name (or (::audit/name resultm)
|
||||
(::sv/name mdata))
|
||||
:profile-id profile-id
|
||||
:ip-addr (audit/parse-client-ip request)
|
||||
:props (audit/profile->props props))))
|
||||
|
||||
result))))
|
||||
mdata)))
|
||||
|
||||
(defn- process-method
|
||||
[cfg vfn]
|
||||
(let [mdata (meta vfn)]
|
||||
[(keyword (::sv/name mdata))
|
||||
(wrap-impl cfg (deref vfn) mdata)]))
|
||||
(wrap cfg (deref vfn) mdata)]))
|
||||
|
||||
(defn- resolve-query-methods
|
||||
[cfg]
|
||||
(let [mobj (mtx/create
|
||||
{:name "rpc_query_timing"
|
||||
:labels ["name"]
|
||||
:registry (get-in cfg [:metrics :registry])
|
||||
:type :histogram
|
||||
:help "Timing of query services."})
|
||||
cfg (assoc cfg ::mobj mobj ::type "query")]
|
||||
(let [cfg (assoc cfg ::type "query" ::metrics-id :rpc-query-timing)]
|
||||
(->> (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.recent-files
|
||||
'app.rpc.queries.viewer
|
||||
'app.rpc.queries.fonts
|
||||
'app.rpc.queries.svg)
|
||||
'app.rpc.queries.fonts)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
|
||||
(defn- resolve-mutation-methods
|
||||
[cfg]
|
||||
(let [mobj (mtx/create
|
||||
{:name "rpc_mutation_timing"
|
||||
:labels ["name"]
|
||||
:registry (get-in cfg [:metrics :registry])
|
||||
:type :histogram
|
||||
:help "Timing of mutation services."})
|
||||
cfg (assoc cfg ::mobj mobj ::type "mutation")]
|
||||
(let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)]
|
||||
(->> (sv/scan-ns 'app.rpc.mutations.demo
|
||||
'app.rpc.mutations.media
|
||||
'app.rpc.mutations.profile
|
||||
'app.rpc.mutations.files
|
||||
'app.rpc.mutations.comments
|
||||
'app.rpc.mutations.projects
|
||||
'app.rpc.mutations.viewer
|
||||
'app.rpc.mutations.teams
|
||||
'app.rpc.mutations.management
|
||||
'app.rpc.mutations.ldap
|
||||
'app.rpc.mutations.fonts
|
||||
'app.rpc.mutations.share-link
|
||||
'app.rpc.mutations.verify-token)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
@@ -183,15 +216,16 @@
|
||||
(s/def ::session map?)
|
||||
(s/def ::tokens fn?)
|
||||
(s/def ::audit (s/nilable fn?))
|
||||
(s/def ::executors (s/map-of keyword? ::wrk/executor))
|
||||
|
||||
(defmethod ig/pre-init-spec ::rpc [_]
|
||||
(s/keys :req-un [::storage ::session ::tokens ::audit
|
||||
::mtx/metrics ::rlm/rlimits ::db/pool]))
|
||||
::executors ::mtx/metrics ::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::rpc
|
||||
[_ cfg]
|
||||
(let [mq (resolve-query-methods cfg)
|
||||
mm (resolve-mutation-methods cfg)]
|
||||
{:methods {:query mq :mutation mm}
|
||||
:query-handler #(rpc-query-handler mq %)
|
||||
:mutation-handler #(rpc-mutation-handler mm %)}))
|
||||
:query-handler (partial rpc-query-handler mq)
|
||||
:mutation-handler (partial rpc-mutation-handler mm)}))
|
||||
|
||||
16
backend/src/app/rpc/helpers.clj
Normal file
16
backend/src/app/rpc/helpers.clj
Normal file
@@ -0,0 +1,16 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.helpers
|
||||
"General purpose RPC helpers."
|
||||
(:require [app.common.data.macros :as dm]))
|
||||
|
||||
(defn http-cache
|
||||
[{:keys [max-age]}]
|
||||
(fn [_ response]
|
||||
(let [exp (if (integer? max-age) max-age (inst-ms max-age))
|
||||
val (dm/fmt "max-age=%" (int (/ exp 1000.0)))]
|
||||
(update response :headers assoc "cache-control" val))))
|
||||
@@ -7,10 +7,12 @@
|
||||
(ns app.rpc.mutations.comments
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.queries.comments :as comments]
|
||||
[app.rpc.queries.files :as files]
|
||||
[app.rpc.retry :as retry]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -25,13 +27,15 @@
|
||||
(s/def ::page-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::position ::us/point)
|
||||
(s/def ::position ::gpt/point)
|
||||
(s/def ::content ::us/string)
|
||||
|
||||
(s/def ::create-comment-thread
|
||||
(s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id]))
|
||||
|
||||
(sv/defmethod ::create-comment-thread
|
||||
{::retry/max-retries 3
|
||||
::retry/matches retry/conflict-db-insert?}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
@@ -43,7 +47,7 @@
|
||||
res (db/exec-one! conn [sql file-id])]
|
||||
(:next-seqn res)))
|
||||
|
||||
(defn- create-comment-thread*
|
||||
(defn- create-comment-thread
|
||||
[conn {:keys [profile-id file-id page-id position content] :as params}]
|
||||
(let [seqn (retrieve-next-seqn conn file-id)
|
||||
now (dt/now)
|
||||
@@ -78,24 +82,6 @@
|
||||
|
||||
(select-keys thread [:id :file-id :page-id])))
|
||||
|
||||
(defn- create-comment-thread
|
||||
[conn params]
|
||||
(loop [sp (db/savepoint conn)
|
||||
rc 0]
|
||||
(let [res (ex/try (create-comment-thread* conn params))]
|
||||
(cond
|
||||
(and (instance? Throwable res)
|
||||
(< rc 3))
|
||||
(do
|
||||
(db/rollback! conn sp)
|
||||
(recur (db/savepoint conn)
|
||||
(inc rc)))
|
||||
|
||||
(instance? Throwable res)
|
||||
(throw res)
|
||||
|
||||
:else res))))
|
||||
|
||||
(defn- retrieve-page-name
|
||||
[conn {:keys [file-id page-id]}]
|
||||
(let [{:keys [data]} (db/get-by-id conn :file file-id)
|
||||
@@ -188,7 +174,7 @@
|
||||
:content content})]
|
||||
|
||||
;; NOTE: this is done in SQL instead of using db/update!
|
||||
;; helper bacause currently the helper does not allow pass raw
|
||||
;; 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.
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cfg]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.mutations.profile :as profile]
|
||||
[app.setup.initial-data :as sid]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[buddy.core.codecs :as bc]
|
||||
@@ -34,20 +33,20 @@
|
||||
params {:id id
|
||||
:email email
|
||||
:fullname fullname
|
||||
:is-demo true
|
||||
:deleted-at (dt/in-future cfg/deletion-delay)
|
||||
:is-active true
|
||||
:deleted-at (dt/in-future cf/deletion-delay)
|
||||
:password password
|
||||
:props {:onboarding-viewed true}}]
|
||||
:props {}
|
||||
}]
|
||||
|
||||
(when-not (cfg/get :allow-demo-users)
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
:code :demo-users-not-allowed
|
||||
:hint "Demo users are disabled by config."))
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(->> (#'profile/create-profile conn params)
|
||||
(#'profile/create-profile-relations conn)
|
||||
(sid/load-initial-project! conn))
|
||||
(#'profile/create-profile-relations conn))
|
||||
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
|
||||
@@ -11,20 +11,26 @@
|
||||
[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.metrics :as mtx]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.files :as files]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage.impl :as simpl]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
[clojure.spec.alpha :as s]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(declare create-file)
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
(s/def ::frame-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
@@ -52,8 +58,9 @@
|
||||
(db/insert! conn :file-profile-rel))))
|
||||
|
||||
(defn create-file
|
||||
[conn {:keys [id name project-id is-shared data deleted-at]
|
||||
[conn {:keys [id name project-id is-shared data deleted-at revn]
|
||||
:or {is-shared false
|
||||
revn 0
|
||||
deleted-at nil}
|
||||
:as params}]
|
||||
(let [id (or id (:id data) (uuid/next))
|
||||
@@ -62,6 +69,7 @@
|
||||
{:id id
|
||||
:project-id project-id
|
||||
:name name
|
||||
:revn revn
|
||||
:is-shared is-shared
|
||||
:data (blob/encode data)
|
||||
:deleted-at deleted-at})]
|
||||
@@ -121,7 +129,6 @@
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
|
||||
(mark-file-deleted conn params)))
|
||||
|
||||
(defn mark-file-deleted
|
||||
@@ -180,7 +187,7 @@
|
||||
:library-file-id library-id}))
|
||||
|
||||
|
||||
;; --- Mutation: Update syncrhonization status of a link
|
||||
;; --- Mutation: Update synchronization status of a link
|
||||
|
||||
(declare update-sync)
|
||||
|
||||
@@ -268,6 +275,7 @@
|
||||
(contains? o :changes-with-metadata)))))
|
||||
|
||||
(sv/defmethod ::update-file
|
||||
{::rlimit/permits (cf/get :rlimit-file-update)}
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/xact-lock! conn id)
|
||||
@@ -279,19 +287,22 @@
|
||||
(defn- take-snapshot?
|
||||
"Defines the rule when file `data` snapshot should be saved."
|
||||
[{:keys [revn modified-at] :as file}]
|
||||
;; The snapshot will be saved every 20 changes or if the last
|
||||
;; modification is older than 3 hour.
|
||||
(or (zero? (mod revn 20))
|
||||
(> (inst-ms (dt/diff modified-at (dt/now)))
|
||||
(inst-ms (dt/duration {:hours 3})))))
|
||||
(let [freq (or (cf/get :file-change-snapshot-every) 20)
|
||||
timeout (or (cf/get :file-change-snapshot-timeout)
|
||||
(dt/duration {:hours 1}))]
|
||||
(or (= 1 freq)
|
||||
(zero? (mod revn freq))
|
||||
(> (inst-ms (dt/diff modified-at (dt/now)))
|
||||
(inst-ms timeout)))))
|
||||
|
||||
(defn- delete-from-storage
|
||||
[{:keys [storage] :as cfg} file]
|
||||
(when-let [backend (simpl/resolve-backend storage (:data-backend file))]
|
||||
(simpl/del-object backend file)))
|
||||
(p/do
|
||||
(when-let [backend (simpl/resolve-backend storage (:data-backend file))]
|
||||
(simpl/del-object backend file))))
|
||||
|
||||
(defn- update-file
|
||||
[{:keys [conn] :as cfg} {:keys [file changes changes-with-metadata session-id profile-id] :as params}]
|
||||
[{:keys [conn metrics] :as cfg} {:keys [file changes changes-with-metadata session-id profile-id] :as params}]
|
||||
(when (> (:revn params)
|
||||
(:revn file))
|
||||
|
||||
@@ -305,10 +316,17 @@
|
||||
(mapcat :changes changes-with-metadata)
|
||||
changes)
|
||||
|
||||
changes (vec changes)
|
||||
|
||||
;; Trace the number of changes processed
|
||||
_ (mtx/run! metrics {:id :update-file-changes :inc (count changes)})
|
||||
|
||||
ts (dt/now)
|
||||
file (-> (files/retrieve-data cfg file)
|
||||
file (-> file
|
||||
(update :revn inc)
|
||||
(update :data (fn [data]
|
||||
;; Trace the length of bytes of processed data
|
||||
(mtx/run! metrics {:id :update-file-bytes-processed :inc (alength data)})
|
||||
(-> data
|
||||
(blob/decode)
|
||||
(assoc :id (:id file))
|
||||
@@ -338,7 +356,7 @@
|
||||
|
||||
;; We need to delete the data from external storage backend
|
||||
(when-not (nil? (:data-backend file))
|
||||
(delete-from-storage cfg file))
|
||||
@(delete-from-storage cfg file))
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at ts}
|
||||
@@ -370,38 +388,41 @@
|
||||
(assoc :changes []))))))))
|
||||
|
||||
(defn- send-notifications
|
||||
[{:keys [msgbus conn] :as cfg} {:keys [file changes session-id] :as params}]
|
||||
(let [lchanges (filter library-change? changes)]
|
||||
[{:keys [conn] :as cfg} {:keys [file changes session-id] :as params}]
|
||||
(let [lchanges (filter library-change? changes)
|
||||
msgbus-fn (:msgbus cfg)]
|
||||
|
||||
|
||||
;; Asynchronously publish message to the msgbus
|
||||
(msgbus :pub {:topic (:id file)
|
||||
:message
|
||||
{:type :file-change
|
||||
:profile-id (:profile-id params)
|
||||
:file-id (:id file)
|
||||
:session-id (:session-id params)
|
||||
:revn (:revn file)
|
||||
:changes changes}})
|
||||
(msgbus-fn :cmd :pub
|
||||
:topic (:id file)
|
||||
:message {:type :file-change
|
||||
:profile-id (:profile-id params)
|
||||
:file-id (:id file)
|
||||
:session-id (:session-id params)
|
||||
:revn (:revn file)
|
||||
:changes changes})
|
||||
|
||||
(when (and (:is-shared file) (seq lchanges))
|
||||
(let [team-id (retrieve-team-id conn (:project-id file))]
|
||||
;; Asynchronously publish message to the msgbus
|
||||
(msgbus :pub {:topic team-id
|
||||
:message
|
||||
{:type :library-change
|
||||
:profile-id (:profile-id params)
|
||||
:file-id (:id file)
|
||||
:session-id session-id
|
||||
:revn (:revn file)
|
||||
:modified-at (dt/now)
|
||||
:changes lchanges}})))))
|
||||
(msgbus-fn :cmd :pub
|
||||
:topic team-id
|
||||
:message {:type :library-change
|
||||
:profile-id (:profile-id params)
|
||||
:file-id (:id file)
|
||||
:session-id session-id
|
||||
:revn (:revn file)
|
||||
:modified-at (dt/now)
|
||||
:changes lchanges})))))
|
||||
|
||||
(defn- retrieve-team-id
|
||||
[conn project-id]
|
||||
(:team-id (db/get-by-id conn :project project-id {:columns [:team-id]})))
|
||||
|
||||
|
||||
;; TEMPORARY FILE CREATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TEMPORARY FILES (behaves differently)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::create-temp-file ::create-file)
|
||||
|
||||
@@ -411,6 +432,23 @@
|
||||
(proj/check-edition-permissions! conn profile-id project-id)
|
||||
(create-file conn (assoc params :deleted-at (dt/in-future {:days 1})))))
|
||||
|
||||
(s/def ::update-temp-file
|
||||
(s/keys :req-un [::changes ::revn ::session-id ::id]))
|
||||
|
||||
(sv/defmethod ::update-temp-file
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id session-id id revn changes] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert! conn :file-change
|
||||
{:id (uuid/next)
|
||||
:session-id session-id
|
||||
:profile-id profile-id
|
||||
:created-at (dt/now)
|
||||
:file-id id
|
||||
:revn revn
|
||||
:data nil
|
||||
:changes (blob/encode changes)})
|
||||
nil))
|
||||
|
||||
(s/def ::persist-temp-file
|
||||
(s/keys :req-un [::id ::profile-id]))
|
||||
|
||||
@@ -418,6 +456,68 @@
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :file
|
||||
{:deleted-at nil}
|
||||
{:id id})))
|
||||
(let [file (db/get-by-id conn :file id)
|
||||
revs (db/query conn :file-change
|
||||
{:file-id id}
|
||||
{:order-by [[:revn :asc]]})
|
||||
revn (count revs)]
|
||||
|
||||
(when (nil? (:deleted-at file))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-persist-already-persisted-file))
|
||||
|
||||
(loop [revs (seq revs)
|
||||
data (blob/decode (:data file))]
|
||||
(if-let [rev (first revs)]
|
||||
(recur (rest revs)
|
||||
(->> rev :changes blob/decode (cp/process-changes data)))
|
||||
(db/update! conn :file
|
||||
{:deleted-at nil
|
||||
:revn revn
|
||||
:data (blob/encode data)}
|
||||
{:id id})))
|
||||
nil)))
|
||||
|
||||
;; --- Mutation: upsert object thumbnail
|
||||
|
||||
(def sql:upsert-object-thumbnail
|
||||
"insert into file_object_thumbnail(file_id, object_id, data)
|
||||
values (?, ?, ?)
|
||||
on conflict(file_id, object_id) do
|
||||
update set data = ?;")
|
||||
|
||||
(s/def ::data (s/nilable ::us/string))
|
||||
(s/def ::object-id ::us/string)
|
||||
(s/def ::upsert-file-object-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id ::object-id ::data]))
|
||||
|
||||
(sv/defmethod ::upsert-file-object-thumbnail
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id object-id data]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(if data
|
||||
(db/exec-one! conn [sql:upsert-object-thumbnail file-id object-id data data])
|
||||
(db/delete! conn :file-object-thumbnail {:file-id file-id :object-id object-id}))
|
||||
nil))
|
||||
|
||||
;; --- Mutation: upsert file thumbnail
|
||||
|
||||
(def sql:upsert-file-thumbnail
|
||||
"insert into file_thumbnail (file_id, revn, data, props)
|
||||
values (?, ?, ?, ?::jsonb)
|
||||
on conflict(file_id, revn) do
|
||||
update set data = ?, props=?, updated_at=now();")
|
||||
|
||||
(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]))
|
||||
|
||||
(sv/defmethod ::upsert-file-thumbnail
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id revn data props]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(let [props (db/tjson (or props {}))]
|
||||
(db/exec-one! conn [sql:upsert-file-thumbnail
|
||||
file-id revn data props data props])
|
||||
nil)))
|
||||
|
||||
@@ -6,17 +6,21 @@
|
||||
|
||||
(ns app.rpc.mutations.fonts
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.media :as media]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]))
|
||||
[clojure.spec.alpha :as s]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(declare create-font-variant)
|
||||
|
||||
@@ -30,7 +34,6 @@
|
||||
(s/def ::weight valid-weight)
|
||||
(s/def ::style valid-style)
|
||||
(s/def ::font-id ::us/uuid)
|
||||
(s/def ::content-type ::media/font-content-type)
|
||||
(s/def ::data (s/map-of ::us/string any?))
|
||||
|
||||
(s/def ::create-font-variant
|
||||
@@ -38,44 +41,76 @@
|
||||
::font-id ::font-family ::font-weight ::font-style]))
|
||||
|
||||
(sv/defmethod ::create-font-variant
|
||||
{::rlimit/permits (cf/get :rlimit-font)}
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg :conn conn)]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(create-font-variant cfg params))))
|
||||
(let [cfg (update cfg :storage media/configure-assets-storage)]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(create-font-variant cfg params)))
|
||||
|
||||
(defn create-font-variant
|
||||
[{:keys [conn storage] :as cfg} {:keys [data] :as params}]
|
||||
(let [data (media/run cfg {:cmd :generate-fonts :input data :rlimit :font})
|
||||
storage (media/configure-assets-storage storage conn)
|
||||
[{:keys [storage pool executors] :as cfg} {:keys [data] :as params}]
|
||||
(letfn [(generate-fonts [data]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(media/run {:cmd :generate-fonts :input data})))
|
||||
|
||||
otf (when-let [fdata (get data "font/otf")]
|
||||
(sto/put-object storage {:content (sto/content fdata)
|
||||
:content-type "font/otf"}))
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data. Even though it uses the hight
|
||||
;; performance BLAKE2b algorithm, we prefer to schedule it
|
||||
;; to be executed on the blocking executor.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(sto/calculate-hash data)))
|
||||
|
||||
ttf (when-let [fdata (get data "font/ttf")]
|
||||
(sto/put-object storage {:content (sto/content fdata)
|
||||
:content-type "font/ttf"}))
|
||||
(validate-data [data]
|
||||
(when (and (not (contains? data "font/otf"))
|
||||
(not (contains? data "font/ttf"))
|
||||
(not (contains? data "font/woff"))
|
||||
(not (contains? data "font/woff2")))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-font-upload))
|
||||
data)
|
||||
|
||||
woff1 (when-let [fdata (get data "font/woff")]
|
||||
(sto/put-object storage {:content (sto/content fdata)
|
||||
:content-type "font/woff"}))
|
||||
(persist-font-object [data mtype]
|
||||
(when-let [fdata (get data mtype)]
|
||||
(p/let [hash (calculate-hash fdata)
|
||||
content (-> (sto/content fdata)
|
||||
(sto/wrap-with-hash hash))]
|
||||
(sto/put-object! storage {::sto/content content
|
||||
::sto/touched-at (dt/now)
|
||||
::sto/deduplicate? true
|
||||
:content-type mtype
|
||||
:bucket "team-font-variant"}))))
|
||||
|
||||
woff2 (when-let [fdata (get data "font/woff2")]
|
||||
(sto/put-object storage {:content (sto/content fdata)
|
||||
:content-type "font/woff2"}))]
|
||||
(persist-fonts [data]
|
||||
(p/let [otf (persist-font-object data "font/otf")
|
||||
ttf (persist-font-object data "font/ttf")
|
||||
woff1 (persist-font-object data "font/woff")
|
||||
woff2 (persist-font-object data "font/woff2")]
|
||||
|
||||
(db/insert! conn :team-font-variant
|
||||
{:id (uuid/next)
|
||||
:team-id (:team-id params)
|
||||
:font-id (:font-id params)
|
||||
:font-family (:font-family params)
|
||||
:font-weight (:font-weight params)
|
||||
:font-style (:font-style params)
|
||||
:woff1-file-id (:id woff1)
|
||||
:woff2-file-id (:id woff2)
|
||||
:otf-file-id (:id otf)
|
||||
:ttf-file-id (:id ttf)})))
|
||||
(d/without-nils
|
||||
{:otf otf
|
||||
:ttf ttf
|
||||
:woff1 woff1
|
||||
:woff2 woff2})))
|
||||
|
||||
(insert-into-db [{:keys [woff1 woff2 otf ttf]}]
|
||||
(db/insert! pool :team-font-variant
|
||||
{:id (uuid/next)
|
||||
:team-id (:team-id params)
|
||||
:font-id (:font-id params)
|
||||
:font-family (:font-family params)
|
||||
:font-weight (:font-weight params)
|
||||
:font-style (:font-style params)
|
||||
:woff1-file-id (:id woff1)
|
||||
:woff2-file-id (:id woff2)
|
||||
:otf-file-id (:id otf)
|
||||
:ttf-file-id (:id ttf)}))
|
||||
]
|
||||
|
||||
(-> (generate-fonts data)
|
||||
(p/then validate-data)
|
||||
(p/then persist-fonts (:default executors))
|
||||
(p/then insert-into-db (:default executors)))))
|
||||
|
||||
;; --- UPDATE FONT FAMILY
|
||||
|
||||
@@ -120,13 +155,6 @@
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
|
||||
;; Schedule object deletion
|
||||
(wrk/submit! {::wrk/task :delete-object
|
||||
::wrk/delay cf/deletion-delay
|
||||
::wrk/conn conn
|
||||
:id id
|
||||
:type :team-font-variant})
|
||||
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :team-id team-id})
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.rpc.mutations.ldap
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cfg]
|
||||
[app.db :as db]
|
||||
@@ -18,6 +19,14 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.string]))
|
||||
|
||||
|
||||
(s/def ::fullname ::us/not-empty-string)
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::backend ::us/not-empty-string)
|
||||
|
||||
(s/def ::info-data
|
||||
(s/keys :req-un [::fullname ::email ::backend]))
|
||||
|
||||
(defn ^java.lang.AutoCloseable connect
|
||||
[]
|
||||
(let [params {:ssl? (cfg/get :ldap-ssl)
|
||||
@@ -47,7 +56,7 @@
|
||||
(s/keys :req-un [::email ::password]
|
||||
:opt-un [::invitation-token]))
|
||||
|
||||
(sv/defmethod ::login-with-ldap {:auth false :rlimit :password}
|
||||
(sv/defmethod ::login-with-ldap {:auth false}
|
||||
[{:keys [pool session tokens] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [info (authenticate params)
|
||||
@@ -57,6 +66,13 @@
|
||||
(ex/raise :type :validation
|
||||
:code :wrong-credentials))
|
||||
|
||||
(when-not (s/valid? ::info-data info)
|
||||
(let [explain (s/explain-str ::info-data info)]
|
||||
(l/warn ::l/raw (str "invalid response from ldap, looks like ldap is not configured correctly\n" explain))
|
||||
(ex/raise :type :restriction
|
||||
:code :wrong-ldap-response
|
||||
:reason explain)))
|
||||
|
||||
(let [profile (login-or-register cfg {:email (:email info)
|
||||
:backend (:backend info)
|
||||
:fullname (:fullname info)})]
|
||||
@@ -94,7 +110,9 @@
|
||||
(cfg/get :ldap-attrs-fullname)]
|
||||
|
||||
base-dn (cfg/get :ldap-base-dn)
|
||||
params {:filter query :sizelimit 1 :attributes attrs}]
|
||||
params {:filter query
|
||||
:sizelimit 1
|
||||
:attributes attrs}]
|
||||
(first (ldap/search cpool base-dn params))))
|
||||
|
||||
(defn- authenticate
|
||||
|
||||
@@ -39,18 +39,30 @@
|
||||
[file index]
|
||||
(letfn [(process-form [form]
|
||||
(cond-> form
|
||||
;; Relink Components
|
||||
;; Relink library items
|
||||
(and (map? form)
|
||||
(uuid? (:component-file form)))
|
||||
(update :component-file #(get index % %))
|
||||
|
||||
(and (map? form)
|
||||
(uuid? (:fill-color-ref-file form)))
|
||||
(update :fill-color-ref-file #(get index % %))
|
||||
|
||||
(and (map? form)
|
||||
(uuid? (:stroke-color-ref-file form)))
|
||||
(update :stroke-color-ref-file #(get index % %))
|
||||
|
||||
(and (map? form)
|
||||
(uuid? (:typography-ref-file form)))
|
||||
(update :typography-ref-file #(get index % %))
|
||||
|
||||
;; Relink Image Shapes
|
||||
(and (map? form)
|
||||
(map? (:metadata form))
|
||||
(= :image (:type form)))
|
||||
(update-in [:metadata :id] #(get index % %))))
|
||||
|
||||
;; A function responsible to analize all file data and
|
||||
;; A function responsible to analyze all file data and
|
||||
;; replace the old :component-file reference with the new
|
||||
;; ones, using the provided file-index
|
||||
(relink-shapes [data]
|
||||
@@ -282,7 +294,7 @@
|
||||
;; move all files to the project
|
||||
(db/exec-one! conn [sql:move-files project-id fids])
|
||||
|
||||
;; delete posible broken relations on moved files
|
||||
;; delete possible broken relations on moved files
|
||||
(db/exec-one! conn [sql:delete-broken-relations pids])
|
||||
|
||||
nil)))
|
||||
@@ -317,7 +329,7 @@
|
||||
{:team-id team-id}
|
||||
{:id project-id})
|
||||
|
||||
;; delete posible broken relations on moved files
|
||||
;; delete possible broken relations on moved files
|
||||
(db/exec-one! conn [sql:delete-broken-relations pids])
|
||||
|
||||
nil)))
|
||||
|
||||
@@ -6,19 +6,22 @@
|
||||
|
||||
(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.media :as media]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage :as sto]
|
||||
[app.util.http :as http]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[datoteka.core :as fs]))
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(def thumbnail-options
|
||||
{:width 100
|
||||
@@ -37,9 +40,7 @@
|
||||
(declare create-file-media-object)
|
||||
(declare select-file)
|
||||
|
||||
(s/def ::content-type ::media/image-content-type)
|
||||
(s/def ::content (s/and ::media/upload (s/keys :req-un [::content-type])))
|
||||
|
||||
(s/def ::content ::media/upload)
|
||||
(s/def ::is-local ::us/boolean)
|
||||
|
||||
(s/def ::upload-file-media-object
|
||||
@@ -47,12 +48,12 @@
|
||||
:opt-un [::id]))
|
||||
|
||||
(sv/defmethod ::upload-file-media-object
|
||||
{::rlimit/permits (cf/get :rlimit-image)}
|
||||
[{: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)
|
||||
(create-file-media-object 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 cfg params)))
|
||||
|
||||
(defn- big-enough-for-thumbnail?
|
||||
"Checks if the provided image info is big enough for
|
||||
@@ -65,89 +66,165 @@
|
||||
[info]
|
||||
(= (:mtype info) "image/svg+xml"))
|
||||
|
||||
(defn- fetch-url
|
||||
[url]
|
||||
(try
|
||||
(http/get! url {:as :byte-array})
|
||||
(catch Exception e
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-access-to-url
|
||||
:cause e))))
|
||||
;; 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.
|
||||
|
||||
(defn- download-media
|
||||
[{:keys [storage] :as cfg} url]
|
||||
(let [result (fetch-url url)
|
||||
data (:body result)
|
||||
mtype (get (:headers result) "content-type")
|
||||
format (cm/mtype->format mtype)]
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "Seems like the url points to an invalid media object."))
|
||||
(-> (assoc storage :backend :tmp)
|
||||
(sto/put-object {:content (sto/content data)
|
||||
:content-type mtype
|
||||
:expired-at (dt/in-future {:minutes 30})}))))
|
||||
(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 analisis 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 [conn storage] :as cfg} {:keys [id file-id is-local name content] :as params}]
|
||||
(media/validate-media-type (:content-type content))
|
||||
(let [storage (media/configure-assets-storage storage conn)
|
||||
source-path (fs/path (:tempfile content))
|
||||
source-mtype (:content-type content)
|
||||
source-info (media/run cfg {:cmd :info :input {:path source-path :mtype source-mtype}})
|
||||
[{:keys [storage pool executors] :as cfg} {:keys [id file-id is-local name content] :as params}]
|
||||
(media/validate-media-type! content)
|
||||
|
||||
thumb (when (and (not (svg-image? source-info))
|
||||
(big-enough-for-thumbnail? source-info))
|
||||
(media/run cfg (assoc thumbnail-options
|
||||
:cmd :generic-thumbnail
|
||||
:input {:mtype (:mtype source-info)
|
||||
:path source-path})))
|
||||
(letfn [;; Function responsible to retrieve the file information, as
|
||||
;; it is synchronous operation it should be wrapped into
|
||||
;; with-dispatch macro.
|
||||
(get-info [content]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
image (if (= (:mtype source-info) "image/svg+xml")
|
||||
(let [data (slurp source-path)]
|
||||
(sto/put-object storage {:content (sto/content data)
|
||||
:content-type (:mtype source-info)}))
|
||||
(sto/put-object storage {:content (sto/content source-path)
|
||||
:content-type (:mtype source-info)}))
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data. Even though it uses the hight
|
||||
;; performance BLAKE2b algorithm, we prefer to schedule it
|
||||
;; to be executed on the blocking executor.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(sto/calculate-hash data)))
|
||||
|
||||
thumb (when thumb
|
||||
(sto/put-object storage {:content (sto/content (:data thumb) (:size thumb))
|
||||
:content-type (:mtype thumb)}))]
|
||||
(db/insert! conn :file-media-object
|
||||
{:id (or id (uuid/next))
|
||||
:file-id file-id
|
||||
:is-local is-local
|
||||
:name name
|
||||
:media-id (:id image)
|
||||
:thumbnail-id (:id thumb)
|
||||
:width (:width source-info)
|
||||
:height (:height source-info)
|
||||
:mtype source-mtype})))
|
||||
;; Function responsible of generating thumnail. As it is synchronous
|
||||
;; opetation, it should be wrapped into with-dispatch macro
|
||||
(generate-thumbnail [info]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(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 (cond-> (:path info) (= (:mtype info) "image/svg+xml") slurp)
|
||||
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 (:default executors)
|
||||
(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-un [::profile-id ::file-id ::is-local ::url]
|
||||
:opt-un [::id ::name]))
|
||||
|
||||
(sv/defmethod ::create-file-media-object-from-url
|
||||
[{:keys [pool storage] :as cfg} {:keys [profile-id file-id url name] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [file (select-file conn file-id)]
|
||||
(teams/check-edition-permissions! conn profile-id (:team-id file))
|
||||
(let [mobj (download-media cfg url)
|
||||
content {:filename "tempfile"
|
||||
:size (:size mobj)
|
||||
:tempfile (sto/get-object-path storage mobj)
|
||||
:content-type (:content-type (meta mobj))}
|
||||
params' (merge params {:content content
|
||||
:name (or name (:filename content))})]
|
||||
(-> (assoc cfg :conn conn)
|
||||
(create-file-media-object params'))))))
|
||||
{::rlimit/permits (cf/get :rlimit-image)}
|
||||
[{: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)))
|
||||
|
||||
(def max-download-file-size
|
||||
(* 1024 1024 100)) ; 100MiB
|
||||
|
||||
(defn- create-file-media-object-from-url
|
||||
[{:keys [storage http-client] :as 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)]
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "Seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-download-file-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint "Seems like the url points to resource with size greater than 100MiB"))
|
||||
|
||||
(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}))
|
||||
|
||||
(get-upload-object [sobj]
|
||||
(p/let [path (sto/get-object-path storage sobj)
|
||||
mdata (meta sobj)]
|
||||
{:filename "tempfile"
|
||||
:size (:size sobj)
|
||||
:path path
|
||||
:mtype (:content-type mdata)}))
|
||||
|
||||
(download-media [uri]
|
||||
(p/let [{:keys [body headers]} (http-client {:method :get :uri uri} {:response-type :input-stream})
|
||||
{:keys [size mtype]} (parse-and-validate-size headers)]
|
||||
|
||||
(-> (assoc storage :backend :tmp)
|
||||
(sto/put-object! {::sto/content (sto/content body size)
|
||||
::sto/expired-at (dt/in-future {:minutes 30})
|
||||
:content-type mtype
|
||||
:bucket "file-media-object"})
|
||||
(p/then get-upload-object))))]
|
||||
|
||||
(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)
|
||||
|
||||
@@ -161,7 +238,6 @@
|
||||
(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)))))
|
||||
|
||||
@@ -179,7 +255,6 @@
|
||||
:height (:height mobj)
|
||||
:mtype (:mtype mobj)})))
|
||||
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
(def ^:private
|
||||
|
||||
@@ -6,31 +6,32 @@
|
||||
|
||||
(ns app.rpc.mutations.profile
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.emails :as eml]
|
||||
[app.http.oauth :refer [extract-props]]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.rpc.mutations.projects :as projects]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.setup.initial-data :as sid]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[buddy.hashers :as hashers]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::fullname ::us/not-empty-string)
|
||||
(s/def ::lang (s/nilable ::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)
|
||||
@@ -38,7 +39,6 @@
|
||||
(s/def ::theme ::us/string)
|
||||
(s/def ::invitation-token ::us/not-empty-string)
|
||||
|
||||
(declare annotate-profile-register)
|
||||
(declare check-profile-existence!)
|
||||
(declare create-profile)
|
||||
(declare create-profile-relations)
|
||||
@@ -99,9 +99,15 @@
|
||||
|
||||
(sv/defmethod ::prepare-register-profile {:auth false}
|
||||
[{:keys [pool tokens] :as cfg} params]
|
||||
(when-not (cf/get :registration-enabled)
|
||||
(ex/raise :type :restriction
|
||||
:code :registration-disabled))
|
||||
(when-not (contains? cf/flags :registration)
|
||||
(if-not (contains? params :invitation-token)
|
||||
(ex/raise :type :restriction
|
||||
:code :registration-disabled)
|
||||
(let [invitation (tokens :verify {:token (:invitation-token params) :iss :team-invitation})]
|
||||
(when-not (= (:email params) (:member-email invitation))
|
||||
(ex/raise :type :restriction
|
||||
:code :email-does-not-match-invitation
|
||||
:hint "email should match the invitation")))))
|
||||
|
||||
(when-let [domains (cf/get :registration-domain-whitelist)]
|
||||
(when-not (email-domain-in-whitelist? domains (:email params))
|
||||
@@ -109,7 +115,7 @@
|
||||
:code :email-domain-is-not-allowed)))
|
||||
|
||||
;; Don't allow proceed in preparing registration if the profile is
|
||||
;; already reported as spamer.
|
||||
;; already reported as spammer.
|
||||
(when (eml/has-bounce-reports? pool (:email params))
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
@@ -117,78 +123,75 @@
|
||||
|
||||
(check-profile-existence! pool params)
|
||||
|
||||
(let [params (assoc params
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:exp (dt/in-future "48h"))
|
||||
(when (= (str/lower (:email params))
|
||||
(str/lower (:password params)))
|
||||
(ex/raise :type :validation
|
||||
:code :email-as-password
|
||||
:hint "you can't use your email as password"))
|
||||
|
||||
(let [params {:email (:email params)
|
||||
:password (:password params)
|
||||
:invitation-token (:invitation-token params)
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:exp (dt/in-future "48h")}
|
||||
|
||||
token (tokens :generate params)]
|
||||
{:token token}))
|
||||
|
||||
;; --- MUTATION: Register Profile
|
||||
|
||||
(s/def ::accept-terms-and-privacy ::us/boolean)
|
||||
(s/def ::accept-newsletter-subscription ::us/boolean)
|
||||
(s/def ::token ::us/not-empty-string)
|
||||
|
||||
(s/def ::register-profile
|
||||
(s/keys :req-un [::token ::fullname
|
||||
::accept-terms-and-privacy]
|
||||
:opt-un [::accept-newsletter-subscription]))
|
||||
(s/keys :req-un [::token ::fullname]))
|
||||
|
||||
(sv/defmethod ::register-profile {:auth false :rlimit :password}
|
||||
(sv/defmethod ::register-profile
|
||||
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(when-not (:accept-terms-and-privacy params)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-terms-and-privacy))
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg :conn conn)]
|
||||
(register-profile cfg params))))
|
||||
|
||||
(defn- annotate-profile-register
|
||||
"A helper for properly increase the profile-register metric once the
|
||||
transaction is completed."
|
||||
[metrics]
|
||||
(fn []
|
||||
((get-in metrics [:definitions :profile-register]) :inc)))
|
||||
(-> (assoc cfg :conn conn)
|
||||
(register-profile params))))
|
||||
|
||||
(defn register-profile
|
||||
[{:keys [conn tokens session metrics] :as cfg} {:keys [token] :as params}]
|
||||
(let [claims (tokens :verify {:token token :iss :prepared-register})
|
||||
params (merge params claims)]
|
||||
[{:keys [conn tokens session] :as cfg} {:keys [token] :as params}]
|
||||
(let [claims (tokens :verify {:token token :iss :prepared-register})
|
||||
params (merge params claims)]
|
||||
(check-profile-existence! conn params)
|
||||
(let [profile (->> params
|
||||
(create-profile conn)
|
||||
(create-profile-relations conn)
|
||||
(decode-profile-row))]
|
||||
(sid/load-initial-project! conn profile)
|
||||
|
||||
(let [is-active (or (:is-active params)
|
||||
(contains? cf/flags :insecure-register))
|
||||
profile (->> (assoc params :is-active is-active)
|
||||
(create-profile conn)
|
||||
(create-profile-relations conn)
|
||||
(decode-profile-row))
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens :verify {:token token :iss :team-invitation}))]
|
||||
(cond
|
||||
;; If invitation token comes in params, this is because the
|
||||
;; 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).
|
||||
(some? (:invitation-token params))
|
||||
(let [token (:invitation-token params)
|
||||
claims (tokens :verify {:token token :iss :team-invitation})
|
||||
claims (assoc claims
|
||||
:member-id (:id profile)
|
||||
:member-email (:email profile))
|
||||
;; If invitation token comes in params, this is because the 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). This happens only if the invitation email matches with the register email.
|
||||
(and (some? invitation) (= (:email profile) (:member-email invitation)))
|
||||
(let [claims (assoc invitation :member-id (:id profile))
|
||||
token (tokens :generate claims)
|
||||
resp {:invitation-token token}]
|
||||
(with-meta resp
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
:before-complete (annotate-profile-register metrics)
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
;; If auth backend is different from "penpot" means user is
|
||||
;; registring using third party auth mechanism; in this case
|
||||
;; registering using third party auth mechanism; in this case
|
||||
;; we need to mark this session as logged.
|
||||
(not= "penpot" (:auth-backend profile))
|
||||
(with-meta (profile/strip-private-attrs profile)
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
:before-complete (annotate-profile-register metrics)
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})
|
||||
|
||||
;; If the `:enable-insecure-register` flag is set, we proceed
|
||||
;; to sign in the user directly, without email verification.
|
||||
(true? is-active)
|
||||
(with-meta (profile/strip-private-attrs profile)
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})
|
||||
|
||||
@@ -202,7 +205,6 @@
|
||||
ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/register
|
||||
:public-uri (:public-uri cfg)
|
||||
@@ -212,8 +214,7 @@
|
||||
:extra-data ptoken})
|
||||
|
||||
(with-meta profile
|
||||
{:before-complete (annotate-profile-register metrics)
|
||||
::audit/props (audit/profile->props profile)
|
||||
{::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))))
|
||||
|
||||
(defn create-profile
|
||||
@@ -222,23 +223,22 @@
|
||||
[conn params]
|
||||
(let [id (or (:id params) (uuid/next))
|
||||
|
||||
props (-> (extract-props params)
|
||||
props (-> (audit/extract-utm-params params)
|
||||
(merge (:props params))
|
||||
(assoc :accept-terms-and-privacy (:accept-terms-and-privacy params true))
|
||||
(assoc :accept-newsletter-subscription (:accept-newsletter-subscription params false))
|
||||
(db/tjson))
|
||||
|
||||
password (if-let [password (:password params)]
|
||||
(derive-password password)
|
||||
"!")
|
||||
|
||||
locale (as-> (:locale params) locale
|
||||
(and (string? locale) (not (str/blank? locale)) locale))
|
||||
locale (:locale params)
|
||||
locale (when (and (string? locale) (not (str/blank? locale)))
|
||||
locale)
|
||||
|
||||
backend (:backend params "penpot")
|
||||
is-demo (:is-demo params false)
|
||||
is-muted (:is-muted params false)
|
||||
is-active (:is-active params (or (not= "penpot" backend) is-demo))
|
||||
is-active (:is-active params false)
|
||||
email (str/lower (:email params))
|
||||
|
||||
params {:id id
|
||||
@@ -263,28 +263,15 @@
|
||||
:code :email-already-exists
|
||||
:cause e)))))))
|
||||
|
||||
|
||||
(defn create-profile-relations
|
||||
[conn profile]
|
||||
(let [team (teams/create-team conn {:profile-id (:id profile)
|
||||
:name "Default"
|
||||
:is-default true})
|
||||
project (projects/create-project conn {:profile-id (:id profile)
|
||||
:team-id (:id team)
|
||||
:name "Drafts"
|
||||
:is-default true})
|
||||
params {:team-id (:id team)
|
||||
:profile-id (:id profile)
|
||||
:project-id (:id project)
|
||||
:role :owner}]
|
||||
|
||||
(teams/create-team-role conn params)
|
||||
(projects/create-project-role conn params)
|
||||
|
||||
(let [team (teams/create-team conn {:profile-id (:id profile)
|
||||
:name "Default"
|
||||
:is-default true})]
|
||||
(-> profile
|
||||
(profile/strip-private-attrs)
|
||||
(assoc :default-team-id (:id team))
|
||||
(assoc :default-project-id (:id project)))))
|
||||
(assoc :default-project-id (:default-project-id team)))))
|
||||
|
||||
;; --- MUTATION: Login
|
||||
|
||||
@@ -295,8 +282,15 @@
|
||||
(s/keys :req-un [::email ::password]
|
||||
:opt-un [::scope ::invitation-token]))
|
||||
|
||||
(sv/defmethod ::login {:auth false :rlimit :password}
|
||||
(sv/defmethod ::login
|
||||
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
|
||||
[{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}]
|
||||
|
||||
(when-not (contains? cf/flags :login)
|
||||
(ex/raise :type :restriction
|
||||
:code :login-disabled
|
||||
:hint "login is disabled in this instance"))
|
||||
|
||||
(letfn [(check-password [profile password]
|
||||
(when (= (:password profile) "!")
|
||||
(ex/raise :type :validation
|
||||
@@ -316,78 +310,106 @@
|
||||
profile)]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(let [profile (->> (profile/retrieve-profile-data-by-email conn email)
|
||||
(validate-profile)
|
||||
(profile/strip-private-attrs)
|
||||
(profile/populate-additional-data conn)
|
||||
(decode-profile-row))]
|
||||
(if-let [token (:invitation-token params)]
|
||||
;; If the request comes with an invitation token, this means
|
||||
;; that user wants to accept it with different user. A very
|
||||
;; strange case but still can happen. In this case, we
|
||||
;; proceed in the same way as in register: regenerate the
|
||||
;; invitation token and return it to the user for proper
|
||||
;; invitation acceptation.
|
||||
(let [claims (tokens :verify {:token token :iss :team-invitation})
|
||||
claims (assoc claims
|
||||
:member-id (:id profile)
|
||||
:member-email (:email profile))
|
||||
token (tokens :generate claims)]
|
||||
(with-meta {:invitation-token token}
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))
|
||||
(let [profile (->> (profile/retrieve-profile-data-by-email conn email)
|
||||
(validate-profile)
|
||||
(profile/strip-private-attrs)
|
||||
(profile/populate-additional-data conn)
|
||||
(decode-profile-row))
|
||||
|
||||
(with-meta profile
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))))
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens :verify {: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 loging 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)]
|
||||
|
||||
(with-meta response
|
||||
{:transform-response ((:create session) (:id profile))
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})))))
|
||||
|
||||
;; --- MUTATION: Logout
|
||||
|
||||
(s/def ::logout
|
||||
(s/keys :req-un [::profile-id]))
|
||||
(s/keys :opt-un [::profile-id]))
|
||||
|
||||
(sv/defmethod ::logout
|
||||
(sv/defmethod ::logout {:auth false}
|
||||
[{:keys [session] :as cfg} _]
|
||||
(with-meta {}
|
||||
{:transform-response (:delete session)}))
|
||||
|
||||
;; --- MUTATION: Update Profile (own)
|
||||
|
||||
(defn- update-profile
|
||||
[conn {:keys [id fullname lang theme] :as params}]
|
||||
(db/update! conn :profile
|
||||
{:fullname fullname
|
||||
:lang lang
|
||||
:theme theme}
|
||||
{:id id}))
|
||||
|
||||
(s/def ::newsletter-subscribed ::us/boolean)
|
||||
(s/def ::update-profile
|
||||
(s/keys :req-un [::id ::fullname]
|
||||
:opt-un [::lang ::theme]))
|
||||
(s/keys :req-un [::fullname ::profile-id]
|
||||
:opt-un [::lang ::theme ::newsletter-subscribed]))
|
||||
|
||||
(sv/defmethod ::update-profile
|
||||
[{:keys [pool] :as cfg} params]
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id fullname lang theme newsletter-subscribed] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(update-profile conn params)
|
||||
nil))
|
||||
;; NOTE: we need to retrieve the profile independently if we use
|
||||
;; it or not for explicit locking and avoid concurrent updates of
|
||||
;; the same row/object.
|
||||
(let [profile (-> (db/get-by-id conn :profile profile-id {:for-update true})
|
||||
(profile/decode-profile-row))
|
||||
|
||||
;; Update the profile map with direct params
|
||||
profile (-> profile
|
||||
(assoc :fullname fullname)
|
||||
(assoc :lang lang)
|
||||
(assoc :theme theme))
|
||||
|
||||
;; Update profile props if the indirect prop is coming in
|
||||
;; the params map and update the profile props data
|
||||
;; acordingly.
|
||||
profile (cond-> profile
|
||||
(some? newsletter-subscribed)
|
||||
(update :props assoc :newsletter-subscribed newsletter-subscribed))]
|
||||
|
||||
(db/update! conn :profile
|
||||
{:fullname fullname
|
||||
:lang lang
|
||||
:theme theme
|
||||
:props (db/tjson (:props profile))}
|
||||
{:id profile-id})
|
||||
|
||||
(with-meta (-> profile profile/strip-private-attrs d/without-nils)
|
||||
{::audit/props (audit/profile->props profile)}))))
|
||||
|
||||
;; --- MUTATION: Update Password
|
||||
|
||||
(declare validate-password!)
|
||||
(declare update-profile-password!)
|
||||
(declare invalidate-profile-session!)
|
||||
|
||||
(s/def ::update-profile-password
|
||||
(s/keys :req-un [::profile-id ::password ::old-password]))
|
||||
|
||||
(sv/defmethod ::update-profile-password {:rlimit :password}
|
||||
(sv/defmethod ::update-profile-password
|
||||
{::rlimit/permits (cf/get :rlimit-password)}
|
||||
[{:keys [pool] :as cfg} {:keys [password] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [profile (validate-password! conn params)]
|
||||
(let [profile (validate-password! conn params)
|
||||
session-id (:app.rpc/session-id params)]
|
||||
(when (= (str/lower (:email profile))
|
||||
(str/lower (:password params)))
|
||||
(ex/raise :type :validation
|
||||
:code :email-as-password
|
||||
:hint "you can't use your email as password"))
|
||||
(update-profile-password! conn (assoc profile :password password))
|
||||
(invalidate-profile-session! conn (:id profile) session-id)
|
||||
nil)))
|
||||
|
||||
(defn- invalidate-profile-session!
|
||||
"Removes all sessions except the current one."
|
||||
[conn profile-id session-id]
|
||||
(let [sql "delete from http_session where profile_id = ? and id != ?"]
|
||||
(:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id]))))
|
||||
|
||||
(defn- validate-password!
|
||||
[conn {:keys [profile-id old-password] :as params}]
|
||||
(let [profile (db/get-by-id conn :profile profile-id)]
|
||||
@@ -402,48 +424,42 @@
|
||||
{:password (derive-password password)}
|
||||
{:id id}))
|
||||
|
||||
|
||||
;; --- MUTATION: Update Photo
|
||||
|
||||
(declare update-profile-photo)
|
||||
|
||||
(s/def ::content-type ::media/image-content-type)
|
||||
(s/def ::file (s/and ::media/upload (s/keys :req-un [::content-type])))
|
||||
|
||||
(s/def ::file ::media/upload)
|
||||
(s/def ::update-profile-photo
|
||||
(s/keys :req-un [::profile-id ::file]))
|
||||
|
||||
(sv/defmethod ::update-profile-photo
|
||||
[{:keys [pool storage] :as cfg} {:keys [profile-id file] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(media/validate-media-type (:content-type file) #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/run cfg {:cmd :info :input {:path (:tempfile file)
|
||||
:mtype (:content-type file)}})
|
||||
{::rlimit/permits (cf/get :rlimit-image)}
|
||||
[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-profile-photo cfg params)))
|
||||
|
||||
(let [profile (db/get-by-id conn :profile profile-id)
|
||||
storage (media/configure-assets-storage storage conn)
|
||||
cfg (assoc cfg :storage storage)
|
||||
(defn update-profile-photo
|
||||
[{:keys [pool storage executors] :as cfg} {:keys [profile-id] :as params}]
|
||||
(p/let [profile (px/with-dispatch (:default executors)
|
||||
(db/get-by-id pool :profile profile-id))
|
||||
photo (teams/upload-photo cfg params)]
|
||||
|
||||
;; Schedule deletion of old photo
|
||||
(when-let [id (:photo-id profile)]
|
||||
(sto/del-object storage id))
|
||||
|
||||
;; Save new photo
|
||||
(update-profile-photo conn profile-id photo))))
|
||||
|
||||
(defn- update-profile-photo
|
||||
[conn profile-id sobj]
|
||||
(db/update! conn :profile
|
||||
{:photo-id (:id sobj)}
|
||||
{:id profile-id})
|
||||
nil)
|
||||
;; Schedule deletion of old photo
|
||||
(when-let [id (:photo-id profile)]
|
||||
(sto/touch-object! storage id))
|
||||
|
||||
;; Save new photo
|
||||
(db/update! pool :profile
|
||||
{:photo-id (:id photo)}
|
||||
{:id profile-id})
|
||||
nil))
|
||||
|
||||
;; --- MUTATION: Request Email Change
|
||||
|
||||
(declare request-email-change)
|
||||
(declare change-email-inmediatelly)
|
||||
(declare change-email-immediately)
|
||||
|
||||
(s/def ::request-email-change
|
||||
(s/keys :req-un [::email]))
|
||||
@@ -456,11 +472,12 @@
|
||||
params (assoc params
|
||||
:profile profile
|
||||
:email (str/lower email))]
|
||||
(if (cf/get :smtp-enabled)
|
||||
(if (or (cf/get :smtp-enabled)
|
||||
(contains? cf/flags :smtp))
|
||||
(request-email-change cfg params)
|
||||
(change-email-inmediatelly cfg params)))))
|
||||
(change-email-immediately cfg params)))))
|
||||
|
||||
(defn- change-email-inmediatelly
|
||||
(defn- change-email-immediately
|
||||
[{:keys [conn]} {:keys [profile email] :as params}]
|
||||
(when (not= email (:email profile))
|
||||
(check-profile-existence! conn params))
|
||||
@@ -563,7 +580,8 @@
|
||||
(s/def ::recover-profile
|
||||
(s/keys :req-un [::token ::password]))
|
||||
|
||||
(sv/defmethod ::recover-profile {:auth false :rlimit :password}
|
||||
(sv/defmethod ::recover-profile
|
||||
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
|
||||
[{:keys [pool tokens] :as cfg} {:keys [token password]}]
|
||||
(letfn [(validate-token [token]
|
||||
(let [tdata (tokens :verify {:token token :iss :password-recovery})]
|
||||
@@ -589,15 +607,20 @@
|
||||
(db/with-atomic [conn pool]
|
||||
(let [profile (profile/retrieve-profile-data conn profile-id)
|
||||
props (reduce-kv (fn [props k v]
|
||||
(if (nil? v)
|
||||
(dissoc props k)
|
||||
(assoc props k v)))
|
||||
;; We don't accept namespaced keys
|
||||
(if (simple-ident? k)
|
||||
(if (nil? v)
|
||||
(dissoc props k)
|
||||
(assoc props k v))
|
||||
props))
|
||||
(:props profile)
|
||||
props)]
|
||||
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
{:id profile-id})
|
||||
nil)))
|
||||
|
||||
(profile/filter-profile-props props))))
|
||||
|
||||
|
||||
;; --- MUTATION: Delete Profile
|
||||
@@ -638,7 +661,7 @@
|
||||
(let [rows (db/exec! conn [sql:owned-teams profile-id])]
|
||||
;; If we found owned teams with more than one profile we don't
|
||||
;; allow delete profile until the user properly transfer ownership
|
||||
;; or explictly removes all participants from the team.
|
||||
;; or explicitly removes all participants from the team.
|
||||
(when (some #(> (:num-profiles %) 1) rows)
|
||||
(ex/raise :type :validation
|
||||
:code :owner-teams-with-people
|
||||
|
||||
@@ -117,11 +117,15 @@
|
||||
(s/def ::delete-project
|
||||
(s/keys :req-un [::id ::profile-id]))
|
||||
|
||||
;; TODO: right now, we just don't allow delete default projects, in a
|
||||
;; future we need to ensure raise a correct exception signaling that
|
||||
;; this is not allowed.
|
||||
|
||||
(sv/defmethod ::delete-project
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :project
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id})
|
||||
{:id id :is-default false})
|
||||
nil))
|
||||
|
||||
72
backend/src/app/rpc/mutations/share_link.clj
Normal file
72
backend/src/app/rpc/mutations/share_link.clj
Normal file
@@ -0,0 +1,72 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.mutations.share-link
|
||||
"Share link related rpc mutation methods."
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc.queries.files :as files]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::flags (s/every ::us/string :kind set?))
|
||||
(s/def ::pages (s/every ::us/uuid :kind set?))
|
||||
|
||||
;; --- Mutation: Create Share Link
|
||||
|
||||
(declare create-share-link)
|
||||
|
||||
(s/def ::create-share-link
|
||||
(s/keys :req-un [::profile-id ::file-id ::flags]
|
||||
:opt-un [::pages]))
|
||||
|
||||
(sv/defmethod ::create-share-link
|
||||
"Creates a share-link object.
|
||||
|
||||
Share links are resources that allows external users access to
|
||||
specific files with specific permissions (flags)."
|
||||
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(create-share-link conn params)))
|
||||
|
||||
(defn create-share-link
|
||||
[conn {:keys [profile-id file-id pages flags]}]
|
||||
(let [pages (db/create-array conn "uuid" pages)
|
||||
flags (->> (map name flags)
|
||||
(db/create-array conn "text"))
|
||||
slink (db/insert! conn :share-link
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
:flags flags
|
||||
:pages pages
|
||||
:owner-id profile-id})]
|
||||
(-> slink
|
||||
(update :pages db/decode-pgarray #{})
|
||||
(update :flags db/decode-pgarray #{}))))
|
||||
|
||||
;; --- Mutation: Delete Share Link
|
||||
|
||||
(declare delete-share-link)
|
||||
|
||||
(s/def ::delete-share-link
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
|
||||
(sv/defmethod ::delete-share-link
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [slink (db/get-by-id conn :share-link id)]
|
||||
(files/check-edition-permissions! conn profile-id (:file-id slink))
|
||||
(db/delete! conn :share-link {:id id})
|
||||
nil)))
|
||||
@@ -8,20 +8,26 @@
|
||||
(: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.mutations.projects :as projects]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[datoteka.core :as fs]))
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
@@ -32,6 +38,7 @@
|
||||
;; --- Mutation: Create Team
|
||||
|
||||
(declare create-team)
|
||||
(declare create-team-entry)
|
||||
(declare create-team-role)
|
||||
(declare create-team-default-project)
|
||||
|
||||
@@ -42,15 +49,21 @@
|
||||
(sv/defmethod ::create-team
|
||||
[{:keys [pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [team (create-team conn params)
|
||||
params (assoc params
|
||||
:team-id (:id team)
|
||||
:role :owner)]
|
||||
(create-team-role conn params)
|
||||
(create-team-default-project conn params)
|
||||
team)))
|
||||
(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)]
|
||||
@@ -59,23 +72,24 @@
|
||||
:name name
|
||||
:is-default is-default})))
|
||||
|
||||
(defn create-team-role
|
||||
(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
|
||||
(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}]
|
||||
(projects/create-project conn project)
|
||||
:is-default true}
|
||||
project (projects/create-project conn project)]
|
||||
(projects/create-project-role conn {:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:role :owner})))
|
||||
:role :owner})
|
||||
project))
|
||||
|
||||
;; --- Mutation: Update Team
|
||||
|
||||
@@ -94,24 +108,53 @@
|
||||
|
||||
;; --- Mutation: Leave Team
|
||||
|
||||
(declare role->params)
|
||||
|
||||
(s/def ::reassign-to ::us/uuid)
|
||||
(s/def ::leave-team
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/keys :req-un [::profile-id ::id]
|
||||
:opt-un [::reassign-to]))
|
||||
|
||||
(sv/defmethod ::leave-team
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id reassign-to]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-read-permissions! conn profile-id id)
|
||||
(let [perms (teams/get-permissions conn profile-id id)
|
||||
members (teams/retrieve-team-members conn id)]
|
||||
|
||||
(when (some :is-owner perms)
|
||||
(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 "reasing owner before leave"))
|
||||
|
||||
(when-not (> (count members) 1)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-leave-team
|
||||
:context {:members (count members)}))
|
||||
:hint "releasing owner before leave"))
|
||||
|
||||
(db/delete! conn :team-profile-rel
|
||||
{:profile-id profile-id
|
||||
@@ -119,30 +162,32 @@
|
||||
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- 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.
|
||||
|
||||
(sv/defmethod ::delete-team
|
||||
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-edition-permissions! conn profile-id id)]
|
||||
(when-not (some :is-owner perms)
|
||||
(let [perms (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})
|
||||
{:id id :is-default false})
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- Mutation: Team Update Role
|
||||
|
||||
(declare retrieve-team-member)
|
||||
(declare role->params)
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::member-id ::us/uuid)
|
||||
@@ -157,17 +202,16 @@
|
||||
(sv/defmethod ::update-team-member-role
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id member-id role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-read-permissions! conn profile-id team-id)
|
||||
|
||||
(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 bocomes a bottleneck or problematic,
|
||||
;; we will change it to more efficient fetch mechanims.
|
||||
;; 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? (some :is-owner perms)
|
||||
is-admin? (some :is-admin perms)]
|
||||
is-owner? (:is-owner perms)
|
||||
is-admin? (:is-admin perms)]
|
||||
|
||||
;; If no member is found, just 404
|
||||
(when-not member
|
||||
@@ -220,9 +264,9 @@
|
||||
(sv/defmethod ::delete-team-member
|
||||
[{:keys [pool] :as cfg} {:keys [team-id profile-id member-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-read-permissions! conn profile-id team-id)]
|
||||
(when-not (or (some :is-owner perms)
|
||||
(some :is-admin perms))
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)]
|
||||
(when-not (or (:is-owner perms)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
@@ -235,84 +279,96 @@
|
||||
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- Mutation: Update Team Photo
|
||||
|
||||
(declare upload-photo)
|
||||
|
||||
(s/def ::content-type ::media/image-content-type)
|
||||
(s/def ::file (s/and ::media/upload (s/keys :req-un [::content-type])))
|
||||
(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]))
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
[{:keys [pool storage] :as cfg} {:keys [profile-id file team-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(media/validate-media-type (:content-type file) #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/run cfg {:cmd :info :input {:path (:tempfile file)
|
||||
:mtype (:content-type file)}})
|
||||
{::rlimit/permits (cf/get :rlimit-image)}
|
||||
[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)))
|
||||
|
||||
(let [team (teams/retrieve-team conn profile-id team-id)
|
||||
storage (media/configure-assets-storage storage conn)
|
||||
cfg (assoc cfg :storage storage)
|
||||
photo (upload-photo cfg params)]
|
||||
(defn update-team-photo
|
||||
[{:keys [pool storage executors] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||
(p/let [team (px/with-dispatch (:default executors)
|
||||
(teams/retrieve-team pool profile-id team-id))
|
||||
photo (upload-photo cfg params)]
|
||||
|
||||
;; Schedule deletion of old photo
|
||||
(when-let [id (:photo-id team)]
|
||||
(sto/del-object storage id))
|
||||
;; 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! conn :team
|
||||
{:photo-id (:id photo)}
|
||||
{:id team-id})
|
||||
;; Save new photo
|
||||
(db/update! pool :team
|
||||
{:photo-id (:id photo)}
|
||||
{:id team-id})
|
||||
|
||||
(assoc team :photo-id (:id photo)))))
|
||||
(assoc team :photo-id (:id photo))))
|
||||
|
||||
(defn upload-photo
|
||||
[{:keys [storage] :as cfg} {:keys [file]}]
|
||||
(let [thumb (media/run cfg
|
||||
{:cmd :profile-thumbnail
|
||||
:format :jpeg
|
||||
:quality 85
|
||||
:width 256
|
||||
:height 256
|
||||
:input {:path (fs/path (:tempfile file))
|
||||
:mtype (:content-type file)}})]
|
||||
[{:keys [storage executors] :as cfg} {:keys [file]}]
|
||||
(letfn [(get-info [content]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(media/run {:cmd :info :input content})))
|
||||
|
||||
(generate-thumbnail [info]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(media/run {:cmd :profile-thumbnail
|
||||
:format :jpeg
|
||||
:quality 85
|
||||
:width 256
|
||||
:height 256
|
||||
:input info})))
|
||||
|
||||
(sto/put-object storage
|
||||
{:content (sto/content (:data thumb) (:size thumb))
|
||||
:content-type (:mtype thumb)})))
|
||||
;; Function responsible of calculating cryptographyc hash of
|
||||
;; the provided data. Even though it uses the hight
|
||||
;; performance BLAKE2b algorithm, we prefer to schedule it
|
||||
;; to be executed on the blocking executor.
|
||||
(calculate-hash [data]
|
||||
(px/with-dispatch (:blocking executors)
|
||||
(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: Invite Member
|
||||
|
||||
(declare create-team-invitation)
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::emails ::us/set-of-emails)
|
||||
(s/def ::invite-team-member
|
||||
(s/keys :req-un [::profile-id ::team-id ::email ::role]))
|
||||
(s/keys :req-un [::profile-id ::team-id ::role]
|
||||
:opt-un [::email ::emails]))
|
||||
|
||||
(sv/defmethod ::invite-team-member
|
||||
[{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
||||
"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}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-edition-permissions! conn profile-id team-id)
|
||||
(let [perms (teams/get-permissions conn profile-id team-id)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
member (profile/retrieve-profile-data-by-email conn email)
|
||||
team (db/get-by-id conn :team team-id)
|
||||
itoken (tokens :generate
|
||||
{:iss :team-invitation
|
||||
:exp (dt/in-future "48h")
|
||||
:profile-id (:id profile)
|
||||
:role role
|
||||
:team-id team-id
|
||||
:member-email (:email member email)
|
||||
:member-id (:id member)})
|
||||
ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
emails (cond-> (or emails #{}) (string? email) (conj email))]
|
||||
|
||||
(when-not (some :is-admin perms)
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
@@ -322,24 +378,138 @@
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
|
||||
(when (and member (not (eml/allow-send-emails? conn member)))
|
||||
(ex/raise :type :validation
|
||||
:code :member-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
(doseq [email emails]
|
||||
(create-team-invitation
|
||||
(assoc cfg
|
||||
:email email
|
||||
:conn conn
|
||||
:team team
|
||||
:profile profile
|
||||
:role role))
|
||||
)
|
||||
|
||||
;; 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
|
||||
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
|
||||
(with-meta {}
|
||||
{::audit/props {:invitations (count emails)}}))))
|
||||
|
||||
(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})
|
||||
(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 tokens team profile role email] :as cfg}]
|
||||
(let [member (profile/retrieve-profile-data-by-email conn email)
|
||||
token-exp (dt/in-future "48h")
|
||||
itoken (tokens :generate
|
||||
{: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-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/trace :hint "invitation token" :token itoken))
|
||||
|
||||
(when (and member (not (eml/allow-send-emails? conn member)))
|
||||
(ex/raise :type :validation
|
||||
:code :member-is-muted
|
||||
:email email
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent 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 "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
|
||||
|
||||
(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})))
|
||||
|
||||
;; --- Mutation: Create Team & Invite Members
|
||||
|
||||
(s/def ::emails ::us/set-of-emails)
|
||||
(s/def ::create-team-and-invite-members
|
||||
(s/and ::create-team (s/keys :req-un [::emails ::role])))
|
||||
|
||||
(sv/defmethod ::create-team-and-invite-members
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [team (create-team conn params)
|
||||
audit-fn (:audit cfg)
|
||||
profile (db/get-by-id conn :profile profile-id)]
|
||||
|
||||
;; Create invitations for all provided emails.
|
||||
(doseq [email emails]
|
||||
(create-team-invitation
|
||||
(assoc cfg
|
||||
:conn conn
|
||||
:team team
|
||||
:profile profile
|
||||
:email email
|
||||
:role role)))
|
||||
|
||||
(with-meta team
|
||||
{::audit/props {:invitations (count emails)}
|
||||
|
||||
:before-complete
|
||||
#(audit-fn :cmd :submit
|
||||
:type "mutation"
|
||||
:name "invite-team-member"
|
||||
:profile-id profile-id
|
||||
:props {:emails emails
|
||||
:role role
|
||||
:profile-id profile-id
|
||||
:invitations (count emails)})}))))
|
||||
|
||||
;; --- Mutation: Update invitation role
|
||||
|
||||
(s/def ::update-team-invitation-role
|
||||
(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}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/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-un [::profile-id ::team-id ::email]))
|
||||
|
||||
(sv/defmethod ::delete-team-invitation
|
||||
[{:keys [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)]
|
||||
|
||||
(when-not (:is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(db/delete! conn :team-invitation
|
||||
{:team-id team-id :email-to (str/lower email)})
|
||||
nil)))
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.mutations.teams :as teams]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defmulti process-token (fn [_ _ claims] (:iss claims)))
|
||||
|
||||
@@ -32,20 +34,18 @@
|
||||
(when (profile/retrieve-profile-data-by-email conn email)
|
||||
(ex/raise :type :validation
|
||||
:code :email-already-exists))
|
||||
|
||||
(db/update! conn :profile
|
||||
{:email email}
|
||||
{:id profile-id})
|
||||
claims)
|
||||
|
||||
(defn- annotate-profile-activation
|
||||
"A helper for properly increase the profile-activation metric once the
|
||||
transaction is completed."
|
||||
[metrics]
|
||||
(fn []
|
||||
((get-in metrics [:definitions :profile-activation]) :inc)))
|
||||
(with-meta claims
|
||||
{::audit/name "update-profile-email"
|
||||
::audit/props {:email email}
|
||||
::audit/profile-id profile-id}))
|
||||
|
||||
(defmethod process-token :verify-email
|
||||
[{:keys [conn session metrics] :as cfg} _ {:keys [profile-id] :as claims}]
|
||||
[{:keys [conn session] :as cfg} _ {:keys [profile-id] :as claims}]
|
||||
(let [profile (profile/retrieve-profile conn profile-id)
|
||||
claims (assoc claims :profile profile)]
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
|
||||
(with-meta claims
|
||||
{:transform-response ((:create session) profile-id)
|
||||
:before-complete (annotate-profile-activation metrics)})))
|
||||
::audit/name "verify-profile-email"
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
(defmethod process-token :auth
|
||||
[{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}]
|
||||
@@ -89,11 +91,18 @@
|
||||
:opt-un [:internal.tokens.team-invitation/member-id]))
|
||||
|
||||
(defn- accept-invitation
|
||||
[{:keys [conn] :as cfg} {:keys [member-id team-id role] :as claims}]
|
||||
(let [params (merge {:team-id team-id
|
||||
[{:keys [conn] :as cfg} {:keys [member-id team-id role member-email] :as claims}]
|
||||
(let [
|
||||
member (profile/retrieve-profile conn member-id)
|
||||
invitation (db/get-by-params conn :team-invitation
|
||||
{:team-id team-id :email-to (str/lower member-email)}
|
||||
{:check-not-found false})
|
||||
;; Update the role if there is an invitation
|
||||
role (or (some-> invitation :role keyword) role)
|
||||
params (merge {:team-id team-id
|
||||
:profile-id member-id}
|
||||
(teams/role->params role))
|
||||
member (profile/retrieve-profile conn member-id)]
|
||||
]
|
||||
|
||||
;; Insert the invited member to the team
|
||||
(db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true})
|
||||
@@ -104,64 +113,57 @@
|
||||
(db/update! conn :profile
|
||||
{:is-active true}
|
||||
{:id member-id}))
|
||||
(assoc member :is-active true)))
|
||||
(assoc member :is-active true)
|
||||
|
||||
;; Delete the invitation
|
||||
(db/delete! conn :team-invitation
|
||||
{:team-id team-id :email-to (str/lower member-email)})))
|
||||
|
||||
|
||||
(defmethod process-token :team-invitation
|
||||
[{:keys [session] :as cfg} {:keys [profile-id token]} {:keys [member-id] :as claims}]
|
||||
[cfg {:keys [profile-id token]} {:keys [member-id] :as claims}]
|
||||
(us/assert ::team-invitation-claims claims)
|
||||
(let [conn (:conn cfg)
|
||||
team-id (:team-id claims)
|
||||
member-email (:member-email claims)
|
||||
invitation (db/get-by-params conn :team-invitation
|
||||
{:team-id team-id :email-to (str/lower member-email)}
|
||||
{:check-not-found false})]
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token)))
|
||||
|
||||
(cond
|
||||
;; This happens when token is filled with member-id and current
|
||||
;; user is already logged in with some account.
|
||||
(and (uuid? profile-id)
|
||||
(uuid? member-id))
|
||||
(do
|
||||
(accept-invitation cfg claims)
|
||||
(if (= member-id profile-id)
|
||||
;; If the current session is already matches the invited
|
||||
;; member, then just return the token and leave the frontend
|
||||
;; app redirect to correct team.
|
||||
(assoc claims :state :created)
|
||||
|
||||
;; If the session does not matches the invited member, replace
|
||||
;; the session with a new one matching the invited member.
|
||||
;; This techinique should be considered secure because the
|
||||
;; user clicking the link he already has access to the email
|
||||
;; account.
|
||||
(with-meta
|
||||
(assoc claims :state :created)
|
||||
{:transform-response ((:create session) member-id)})))
|
||||
|
||||
;; This happens when member-id is not filled in the invitation but
|
||||
;; the user already has an account (probably with other mail) and
|
||||
;; is already logged-in.
|
||||
(and (uuid? profile-id)
|
||||
(nil? member-id))
|
||||
(do
|
||||
(accept-invitation cfg (assoc claims :member-id profile-id))
|
||||
(assoc claims :state :created))
|
||||
|
||||
;; This happens when member-id is filled but the accessing user is
|
||||
;; not logged-in. In this case we proceed to accept invitation and
|
||||
;; leave the user logged-in.
|
||||
(and (nil? profile-id)
|
||||
(uuid? member-id))
|
||||
(do
|
||||
(accept-invitation cfg claims)
|
||||
;; user is already logged in with exactly invited account.
|
||||
(and (uuid? profile-id) (uuid? member-id) (= member-id profile-id))
|
||||
(let [profile (accept-invitation cfg claims)]
|
||||
(with-meta
|
||||
(assoc claims :state :created)
|
||||
{:transform-response ((:create session) member-id)}))
|
||||
{::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}))
|
||||
|
||||
;; In this case, we wait until frontend app redirect user to
|
||||
;; registeration page, the user is correctly registered and the
|
||||
;; register mutation call us again with the same token to finally
|
||||
;; create the corresponding team-profile relation from the first
|
||||
;; condition of this if.
|
||||
;; This case means that invitation token does not match with
|
||||
;; registred user, so we need to indicate to frontend to redirect
|
||||
;; it to register page.
|
||||
(nil? member-id)
|
||||
{:invitation-token token
|
||||
:iss :team-invitation
|
||||
:redirect-to :auth-register
|
||||
:state :pending}
|
||||
|
||||
;; In all other cases, just tell to fontend to redirect the user
|
||||
;; to the login page.
|
||||
:else
|
||||
{:invitation-token token
|
||||
:iss :team-invitation
|
||||
:redirect-to :auth-login
|
||||
:state :pending}))
|
||||
|
||||
|
||||
;; --- Default
|
||||
|
||||
(defmethod process-token :default
|
||||
|
||||
@@ -1,49 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.mutations.viewer
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.queries.files :as files]
|
||||
[app.util.services :as sv]
|
||||
[buddy.core.codecs :as bc]
|
||||
[buddy.core.nonce :as bn]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::page-id ::us/uuid)
|
||||
|
||||
(s/def ::create-file-share-token
|
||||
(s/keys :req-un [::profile-id ::file-id ::page-id]))
|
||||
|
||||
(sv/defmethod ::create-file-share-token
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id page-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(let [token (-> (bn/random-bytes 16)
|
||||
(bc/bytes->b64u)
|
||||
(bc/bytes->str))]
|
||||
(db/insert! conn :file-share-token
|
||||
{:file-id file-id
|
||||
:page-id page-id
|
||||
:token token})
|
||||
{:token token})))
|
||||
|
||||
|
||||
(s/def ::token ::us/not-empty-string)
|
||||
(s/def ::delete-file-share-token
|
||||
(s/keys :req-un [::profile-id ::file-id ::token]))
|
||||
|
||||
(sv/defmethod ::delete-file-share-token
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id token]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(db/delete! conn :file-share-token
|
||||
{:file-id file-id
|
||||
:token token})
|
||||
nil))
|
||||
@@ -37,28 +37,28 @@
|
||||
:is-admin false
|
||||
:can-edit false)))
|
||||
|
||||
(defn make-edition-check-fn
|
||||
"A simple factory for edition permission check functions."
|
||||
(defn make-edition-predicate-fn
|
||||
"A simple factory for edition permission predicate functions."
|
||||
[qfn]
|
||||
(us/assert fn? qfn)
|
||||
(fn [& args]
|
||||
(let [rows (apply qfn args)]
|
||||
(if (or (empty? rows)
|
||||
(not (or (some :can-edit rows)
|
||||
(some :is-admin rows)
|
||||
(some :is-owner rows))))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found")
|
||||
rows))))
|
||||
(fn check
|
||||
([perms] (:can-edit perms))
|
||||
([conn & args] (check (apply qfn conn args)))))
|
||||
|
||||
(defn make-read-check-fn
|
||||
"A simple factory for read permission check functions."
|
||||
(defn make-read-predicate-fn
|
||||
"A simple factory for read permission predicate functions."
|
||||
[qfn]
|
||||
(us/assert fn? qfn)
|
||||
(fn check
|
||||
([perms] (:can-read perms))
|
||||
([conn & args] (check (apply qfn conn args)))))
|
||||
|
||||
(defn make-check-fn
|
||||
"Helper that converts a predicate permission function to a check
|
||||
function (function that raises an exception)."
|
||||
[pred]
|
||||
(fn [& args]
|
||||
(let [rows (apply qfn args)]
|
||||
(if-not (seq rows)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found)
|
||||
rows))))
|
||||
(when-not (apply pred args)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))))
|
||||
|
||||
@@ -6,23 +6,30 @@
|
||||
|
||||
(ns app.rpc.queries.files
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.pages.helpers :as cph]
|
||||
[app.common.pages.migrations :as pmg]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.rpc.helpers :as rpch]
|
||||
[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.storage.impl :as simpl]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(declare decode-row)
|
||||
(declare decode-row-xf)
|
||||
|
||||
;; --- Helpers & Specs
|
||||
|
||||
(s/def ::frame-id ::us/uuid)
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
@@ -31,7 +38,6 @@
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::search-term ::us/string)
|
||||
|
||||
|
||||
;; --- Query: File Permissions
|
||||
|
||||
(def ^:private sql:file-permissions
|
||||
@@ -59,19 +65,51 @@
|
||||
where f.id = ?
|
||||
and ppr.profile_id = ?")
|
||||
|
||||
(defn- retrieve-file-permissions
|
||||
(defn retrieve-file-permissions
|
||||
[conn profile-id file-id]
|
||||
(db/exec! conn [sql:file-permissions
|
||||
file-id profile-id
|
||||
file-id profile-id
|
||||
file-id profile-id]))
|
||||
(when (and profile-id file-id)
|
||||
(db/exec! conn [sql:file-permissions
|
||||
file-id profile-id
|
||||
file-id profile-id
|
||||
file-id profile-id])))
|
||||
|
||||
(defn get-permissions
|
||||
([conn profile-id file-id]
|
||||
(let [rows (retrieve-file-permissions conn profile-id file-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)
|
||||
{:type :membership
|
||||
:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true})))
|
||||
([conn profile-id file-id share-id]
|
||||
(let [perms (get-permissions conn profile-id file-id)
|
||||
ldata (retrieve-share-link conn file-id share-id)]
|
||||
|
||||
;; NOTE: in a future when share-link becomes more powerful and
|
||||
;; will allow us specify which parts of the app is available, we
|
||||
;; will probably need to tweak this function in order to expose
|
||||
;; this flags to the frontend.
|
||||
(cond
|
||||
(some? perms) perms
|
||||
(some? ldata) {:type :share-link
|
||||
:can-read true
|
||||
:flags (:flags ldata)}))))
|
||||
|
||||
(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-edition-check-fn retrieve-file-permissions))
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
|
||||
(def check-read-permissions!
|
||||
(perms/make-read-check-fn retrieve-file-permissions))
|
||||
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; --- Query: Files search
|
||||
|
||||
@@ -124,29 +162,6 @@
|
||||
profile-id team-id
|
||||
search-term])))
|
||||
|
||||
|
||||
;; --- Query: Files
|
||||
|
||||
;; DEPRECATED: should be removed probably on 1.6.x
|
||||
|
||||
(def ^:private sql:files
|
||||
"select f.*
|
||||
from file as f
|
||||
where f.project_id = ?
|
||||
and f.deleted_at is null
|
||||
order by f.modified_at desc")
|
||||
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::files
|
||||
(s/keys :req-un [::profile-id ::project-id]))
|
||||
|
||||
(sv/defmethod ::files
|
||||
[{: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)
|
||||
(into [] decode-row-xf (db/exec! conn [sql:files project-id]))))
|
||||
|
||||
|
||||
;; --- Query: Project Files
|
||||
|
||||
(def ^:private sql:project-files
|
||||
@@ -155,6 +170,7 @@
|
||||
f.created_at,
|
||||
f.modified_at,
|
||||
f.name,
|
||||
f.revn,
|
||||
f.is_shared
|
||||
from file as f
|
||||
where f.project_id = ?
|
||||
@@ -172,21 +188,28 @@
|
||||
|
||||
;; --- Query: File (By ID)
|
||||
|
||||
(defn- retrieve-data*
|
||||
[{:keys [storage] :as cfg} file]
|
||||
(when-let [backend (simpl/resolve-backend storage (:data-backend file))]
|
||||
(simpl/get-object-bytes backend file)))
|
||||
(defn retrieve-object-thumbnails
|
||||
([{:keys [pool]} file-id]
|
||||
(let [sql (str/concat
|
||||
"select object_id, data "
|
||||
" from file_object_thumbnail"
|
||||
" where file_id=?")]
|
||||
(->> (db/exec! pool [sql file-id])
|
||||
(d/index-by :object-id :data))))
|
||||
|
||||
(defn retrieve-data
|
||||
[cfg file]
|
||||
(if (bytes? (:data file))
|
||||
file
|
||||
(assoc file :data (retrieve-data* cfg file))))
|
||||
([{:keys [pool]} file-id object-ids]
|
||||
(with-open [conn (db/open pool)]
|
||||
(let [sql (str/concat
|
||||
"select object_id, data "
|
||||
" from file_object_thumbnail"
|
||||
" where file_id=? and object_id = ANY(?)")
|
||||
ids (db/create-array conn "text" (seq object-ids))]
|
||||
(->> (db/exec! conn [sql file-id ids])
|
||||
(d/index-by :object-id :data))))))
|
||||
|
||||
(defn retrieve-file
|
||||
[{:keys [conn] :as cfg} id]
|
||||
(->> (db/get-by-id conn :file id)
|
||||
(retrieve-data cfg)
|
||||
[{:keys [pool] :as cfg} id]
|
||||
(->> (db/get-by-id pool :file id)
|
||||
(decode-row)
|
||||
(pmg/migrate-file)))
|
||||
|
||||
@@ -194,79 +217,148 @@
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
|
||||
(sv/defmethod ::file
|
||||
"Retrieve a file by its ID. Only authenticated users."
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg :conn conn)]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(retrieve-file cfg id))))
|
||||
(let [perms (get-permissions pool profile-id id)]
|
||||
(check-read-permissions! perms)
|
||||
(let [file (retrieve-file cfg id)
|
||||
thumbs (retrieve-object-thumbnails cfg id)]
|
||||
(-> file
|
||||
(assoc :thumbnails thumbs)
|
||||
(assoc :permissions perms)))))
|
||||
|
||||
|
||||
;; --- QUERY: page
|
||||
|
||||
(defn- prune-objects
|
||||
"Given the page data and the object-id returns the page data with all
|
||||
other not needed objects removed from the `:objects` data
|
||||
structure."
|
||||
[{:keys [objects] :as page} object-id]
|
||||
(let [objects (cph/get-children-with-self objects object-id)]
|
||||
(assoc page :objects (d/index-by :id objects))))
|
||||
|
||||
(defn- prune-thumbnails
|
||||
"Given the page data, removes the `:thumbnail` prop from all
|
||||
shapes."
|
||||
[page]
|
||||
(update page :objects d/update-vals #(dissoc % :thumbnail)))
|
||||
|
||||
(s/def ::page-id ::us/uuid)
|
||||
(s/def ::object-id ::us/uuid)
|
||||
|
||||
(s/def ::page
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
|
||||
(defn remove-thumbnails-frames
|
||||
"Removes from data the children for frames that have a thumbnail set up"
|
||||
[data]
|
||||
(let [filter-shape?
|
||||
(fn [objects [id shape]]
|
||||
(let [frame-id (:frame-id shape)]
|
||||
(or (= id uuid/zero)
|
||||
(= frame-id uuid/zero)
|
||||
(not (some? (get-in objects [frame-id :thumbnail]))))))
|
||||
|
||||
;; We need to remove from the attribute :shapes its childrens because
|
||||
;; they will not be sent in the data
|
||||
remove-frame-children
|
||||
(fn [[id shape]]
|
||||
[id (cond-> shape
|
||||
(some? (:thumbnail shape))
|
||||
(assoc :shapes []))])
|
||||
|
||||
update-objects
|
||||
(fn [objects]
|
||||
(into {}
|
||||
(comp (map remove-frame-children)
|
||||
(filter (partial filter-shape? objects)))
|
||||
objects))]
|
||||
|
||||
(update data :objects update-objects)))
|
||||
(s/and
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
:opt-un [::page-id ::object-id])
|
||||
(fn [obj]
|
||||
(if (contains? obj :object-id)
|
||||
(contains? obj :page-id)
|
||||
true))))
|
||||
|
||||
(sv/defmethod ::page
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id strip-thumbnails]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(let [cfg (assoc cfg :conn conn)
|
||||
file (retrieve-file cfg file-id)
|
||||
page-id (get-in file [:data :pages 0])]
|
||||
(cond-> (get-in file [:data :pages-index page-id])
|
||||
strip-thumbnails
|
||||
(remove-thumbnails-frames)))))
|
||||
"Retrieves the page data from file and returns it. If no page-id is
|
||||
specified, the first page will be returned. If object-id is
|
||||
specified, only that object and its children will be returned in the
|
||||
page objects data structure.
|
||||
|
||||
;; --- Query: Shared Library Files
|
||||
If you specify the object-id, the page-id parameter becomes
|
||||
mandatory.
|
||||
|
||||
;; DEPRECATED: and will be removed on 1.6.x
|
||||
Mainly used for rendering purposes."
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id page-id object-id] :as props}]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(let [file (retrieve-file cfg file-id)
|
||||
page-id (or page-id (-> file :data :pages first))
|
||||
page (get-in file [:data :pages-index page-id])]
|
||||
|
||||
(def ^:private sql:shared-files
|
||||
"select f.*
|
||||
from file as f
|
||||
inner join project as p on (p.id = f.project_id)
|
||||
where f.is_shared = true
|
||||
and f.deleted_at is null
|
||||
and p.deleted_at is null
|
||||
and p.team_id = ?
|
||||
order by f.modified_at desc")
|
||||
(cond-> (prune-thumbnails page)
|
||||
(uuid? object-id)
|
||||
(prune-objects object-id))))
|
||||
|
||||
(s/def ::shared-files
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
;; --- QUERY: file-data-for-thumbnail
|
||||
|
||||
(sv/defmethod ::shared-files
|
||||
[{:keys [pool] :as cfg} {:keys [team-id] :as params}]
|
||||
(into [] decode-row-xf (db/exec! pool [sql:shared-files team-id])))
|
||||
(defn- get-file-thumbnail-data
|
||||
[cfg {:keys [data id] :as file}]
|
||||
(letfn [;; function responsible on finding the frame marked to be
|
||||
;; used as thumbnail; the returned frame always have
|
||||
;; the :page-id set to the page that it belongs.
|
||||
(get-thumbnail-frame [data]
|
||||
(d/seek :use-for-thumbnail?
|
||||
(for [page (-> data :pages-index vals)
|
||||
frame (-> page :objects cph/get-frames)]
|
||||
(assoc frame :page-id (:id page)))))
|
||||
|
||||
;; function responsible to filter objects data strucuture of
|
||||
;; all unneded shapes if a concrete frame is provided. If no
|
||||
;; frame, the objects is returned untouched.
|
||||
(filter-objects [objects frame-id]
|
||||
(d/index-by :id (cph/get-children-with-self objects frame-id)))
|
||||
|
||||
;; function responsible of assoc available thumbnails
|
||||
;; to frames and remove all children shapes from objects if
|
||||
;; thumbnails is available
|
||||
(assoc-thumbnails [objects page-id thumbnails]
|
||||
(loop [objects objects
|
||||
frames (filter cph/frame-shape? (vals objects))]
|
||||
|
||||
(if-let [frame (-> frames first)]
|
||||
(let [frame-id (:id frame)
|
||||
object-id (str page-id frame-id)
|
||||
frame (if-let [thumb (get thumbnails object-id)]
|
||||
(assoc frame :thumbnail thumb :shapes [])
|
||||
(dissoc frame :thumbnail))]
|
||||
(if (:thumbnail frame)
|
||||
(recur (-> (assoc objects frame-id frame)
|
||||
(d/without-keys (cph/get-children-ids objects frame-id)))
|
||||
(rest frames))
|
||||
(recur (assoc objects frame-id frame)
|
||||
(rest frames))))
|
||||
|
||||
objects)))]
|
||||
|
||||
(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 (cph/get-frames (:objects page))))
|
||||
|
||||
obj-ids (map #(str page-id %) frame-ids)
|
||||
thumbs (retrieve-object-thumbnails cfg 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))
|
||||
|
||||
;; Assoc the available thumbnails and prune not visible shapes
|
||||
;; for avoid transfer unnecesary data.
|
||||
:always
|
||||
(update :objects assoc-thumbnails page-id thumbs)))))
|
||||
|
||||
(s/def ::file-data-for-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
|
||||
(sv/defmethod ::file-data-for-thumbnail
|
||||
"Retrieves the data for generate the thumbnail of the file. Used
|
||||
mainly for render thumbnails on dashboard."
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as props}]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(let [file (retrieve-file cfg file-id)]
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:page (get-file-thumbnail-data cfg file)}))
|
||||
|
||||
|
||||
;; --- Query: Shared Library Files
|
||||
|
||||
(def ^:private sql:team-shared-files
|
||||
"select f.id,
|
||||
f.revn,
|
||||
f.project_id,
|
||||
f.created_at,
|
||||
f.modified_at,
|
||||
@@ -315,28 +407,26 @@
|
||||
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
|
||||
|
||||
(defn retrieve-file-libraries
|
||||
[{:keys [conn] :as cfg} is-indirect file-id]
|
||||
[{:keys [pool] :as cfg} is-indirect file-id]
|
||||
(let [xform (comp
|
||||
(map #(assoc % :is-indirect is-indirect))
|
||||
(map #(retrieve-data cfg %))
|
||||
(map decode-row))]
|
||||
(into #{} xform (db/exec! conn [sql:file-libraries file-id]))))
|
||||
(into #{} xform (db/exec! pool [sql:file-libraries file-id]))))
|
||||
|
||||
(s/def ::file-libraries
|
||||
(s/keys :req-un [::profile-id ::file-id]))
|
||||
|
||||
(sv/defmethod ::file-libraries
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg :conn conn)]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(retrieve-file-libraries cfg false file-id))))
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(retrieve-file-libraries cfg false file-id))
|
||||
|
||||
;; --- QUERY: team-recent-files
|
||||
|
||||
(def sql:team-recent-files
|
||||
"with recent_files as (
|
||||
select f.id,
|
||||
f.revn,
|
||||
f.project_id,
|
||||
f.created_at,
|
||||
f.modified_at,
|
||||
@@ -353,14 +443,44 @@
|
||||
)
|
||||
select * from recent_files where row_num <= 10;")
|
||||
|
||||
|
||||
(s/def ::team-recent-files
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
|
||||
(sv/defmethod ::team-recent-files
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/exec! conn [sql:team-recent-files team-id])))
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(db/exec! pool [sql:team-recent-files team-id]))
|
||||
|
||||
;; --- QUERY: get file thumbnail
|
||||
|
||||
(s/def ::revn ::us/integer)
|
||||
|
||||
(s/def ::file-thumbnail
|
||||
(s/keys :req-un [::profile-id ::file-id]
|
||||
:opt-un [::revn]))
|
||||
|
||||
(sv/defmethod ::file-thumbnail
|
||||
[{:keys [pool]} {:keys [profile-id file-id revn]}]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(let [sql (sql/select :file-thumbnail
|
||||
(cond-> {:file-id file-id}
|
||||
revn (assoc :revn revn))
|
||||
{:limit 1
|
||||
:order-by [[:revn :desc]]})
|
||||
|
||||
row (db/exec-one! pool sql)]
|
||||
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :file-thumbnail-not-found))
|
||||
|
||||
(with-meta
|
||||
{:data (:data row)
|
||||
:props (some-> (:props row) db/decode-transit-pgobject)
|
||||
:revn (:revn row)
|
||||
:file-id (:file-id row)}
|
||||
{:transform-response (rpch/http-cache {:max-age (* 1000 60 60)})})))
|
||||
|
||||
;; --- Helpers
|
||||
|
||||
|
||||
@@ -35,12 +35,17 @@
|
||||
(s/def ::profile
|
||||
(s/keys :opt-un [::profile-id]))
|
||||
|
||||
(sv/defmethod ::profile {:auth false}
|
||||
(sv/defmethod ::profile
|
||||
{:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id] :as params}]
|
||||
(if profile-id
|
||||
(retrieve-profile pool profile-id)
|
||||
{:id uuid/zero
|
||||
:fullname "Anonymous User"}))
|
||||
;; 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
|
||||
;; cases we need to reraise the exception.
|
||||
(or (ex/try*
|
||||
#(some->> profile-id (retrieve-profile pool))
|
||||
#(when (not= :not-found (:type (ex-data %))) (throw %)))
|
||||
{:id uuid/zero
|
||||
:fullname "Anonymous User"}))
|
||||
|
||||
(def ^:private sql:default-profile-team
|
||||
"select t.id, name
|
||||
@@ -70,6 +75,10 @@
|
||||
[conn profile]
|
||||
(merge profile (retrieve-additional-data conn (:id profile))))
|
||||
|
||||
(defn filter-profile-props
|
||||
[props]
|
||||
(into {} (filter (fn [[k _]] (simple-ident? k))) props))
|
||||
|
||||
(defn decode-profile-row
|
||||
[{:keys [props] :as row}]
|
||||
(cond-> row
|
||||
@@ -83,24 +92,25 @@
|
||||
|
||||
(defn retrieve-profile
|
||||
[conn id]
|
||||
(let [profile (some->> (retrieve-profile-data conn id)
|
||||
(strip-private-attrs)
|
||||
(populate-additional-data conn))]
|
||||
(when (nil? profile)
|
||||
(ex/raise :type :not-found
|
||||
:hint "Object doest not exists."))
|
||||
(let [profile (->> (retrieve-profile-data conn id)
|
||||
(strip-private-attrs)
|
||||
(populate-additional-data conn))]
|
||||
(update profile :props filter-profile-props)))
|
||||
|
||||
profile))
|
||||
(def ^:private sql:profile-by-email
|
||||
"select p.* from profile as p
|
||||
where p.email = ?
|
||||
and (p.deleted_at is null or
|
||||
p.deleted_at > now())")
|
||||
|
||||
(defn retrieve-profile-data-by-email
|
||||
[conn email]
|
||||
(try
|
||||
(db/get-by-params conn :profile {:email (str/lower email)})
|
||||
(catch Exception _e)))
|
||||
(ex/ignoring
|
||||
(db/exec-one! conn [sql:profile-by-email (str/lower email)])))
|
||||
|
||||
;; --- Attrs Helpers
|
||||
|
||||
(defn strip-private-attrs
|
||||
"Only selects a publicy visible profile attrs."
|
||||
"Only selects a publicly visible profile attrs."
|
||||
[row]
|
||||
(dissoc row :password :deleted-at))
|
||||
|
||||
@@ -31,18 +31,31 @@
|
||||
where ppr.project_id = ?
|
||||
and ppr.profile_id = ?")
|
||||
|
||||
(defn- retrieve-project-permissions
|
||||
(defn- get-permissions
|
||||
[conn profile-id project-id]
|
||||
(db/exec! conn [sql:project-permissions
|
||||
project-id profile-id
|
||||
project-id profile-id]))
|
||||
(let [rows (db/exec! conn [sql:project-permissions
|
||||
project-id profile-id
|
||||
project-id profile-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-edition-check-fn retrieve-project-permissions))
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
|
||||
(def check-read-permissions!
|
||||
(perms/make-read-check-fn retrieve-project-permissions))
|
||||
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; --- Query: Projects
|
||||
|
||||
@@ -66,12 +79,14 @@
|
||||
where f.project_id = p.id
|
||||
and deleted_at is null) as count
|
||||
from project as p
|
||||
inner join team as t on (t.id = p.team_id)
|
||||
left join team_project_profile_rel as tpp
|
||||
on (tpp.project_id = p.id and
|
||||
tpp.team_id = p.team_id and
|
||||
tpp.profile_id = ?)
|
||||
where p.team_id = ?
|
||||
and p.deleted_at is null
|
||||
and t.deleted_at is null
|
||||
order by p.modified_at desc")
|
||||
|
||||
(defn retrieve-projects
|
||||
@@ -95,26 +110,26 @@
|
||||
(def sql:all-projects
|
||||
"select p1.*, t.name as team_name, t.is_default as is_default_team
|
||||
from project as p1
|
||||
inner join team as t
|
||||
on t.id = p1.team_id
|
||||
inner join team as t on (t.id = p1.team_id)
|
||||
where t.id in (select team_id
|
||||
from team_profile_rel as tpr
|
||||
where tpr.profile_id = ?
|
||||
and (tpr.can_edit = true or
|
||||
tpr.is_owner = true or
|
||||
tpr.is_admin = true))
|
||||
and t.deleted_at is null
|
||||
and p1.deleted_at is null
|
||||
union
|
||||
select p2.*, t.name as team_name, t.is_default as is_default_team
|
||||
from project as p2
|
||||
inner join team as t
|
||||
on t.id = p2.team_id
|
||||
inner join team as t on (t.id = p2.team_id)
|
||||
where p2.id in (select project_id
|
||||
from project_profile_rel as ppr
|
||||
where ppr.profile_id = ?
|
||||
and (ppr.can_edit = true or
|
||||
ppr.is_owner = true or
|
||||
ppr.is_admin = true))
|
||||
and t.deleted_at is null
|
||||
and p2.deleted_at is null
|
||||
order by team_name, name;")
|
||||
|
||||
|
||||
@@ -1,42 +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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.queries.recent-files
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.queries.files :refer [decode-row-xf]]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
;; DEPRECATED: should be removed on 1.6.x
|
||||
|
||||
(def sql:recent-files
|
||||
"with recent_files as (
|
||||
select f.*, row_number() over w as row_num
|
||||
from file as f
|
||||
join project as p on (p.id = f.project_id)
|
||||
where p.team_id = ?
|
||||
and p.deleted_at is null
|
||||
and f.deleted_at is null
|
||||
window w as (partition by f.project_id order by f.modified_at desc)
|
||||
order by f.modified_at desc
|
||||
)
|
||||
select * from recent_files where row_num <= 10;")
|
||||
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
|
||||
(s/def ::recent-files
|
||||
(s/keys :req-un [::profile-id ::team-id]))
|
||||
|
||||
(sv/defmethod ::recent-files
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id team-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(let [files (db/exec! conn [sql:recent-files team-id])]
|
||||
(into [] decode-row-xf files))))
|
||||
23
backend/src/app/rpc/queries/share_link.clj
Normal file
23
backend/src/app/rpc/queries/share_link.clj
Normal file
@@ -0,0 +1,23 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.queries.share-link
|
||||
(:require
|
||||
[app.db :as db]))
|
||||
|
||||
(defn decode-share-link-row
|
||||
[row]
|
||||
(-> row
|
||||
(update :flags db/decode-pgarray #{})
|
||||
(update :pages db/decode-pgarray #{})))
|
||||
|
||||
(defn retrieve-share-link
|
||||
[conn file-id share-id]
|
||||
(some-> (db/get-by-params conn :share-link
|
||||
{:id share-id :file-id file-id}
|
||||
{:check-not-found false})
|
||||
(decode-share-link-row)))
|
||||
|
||||
@@ -21,19 +21,34 @@
|
||||
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 tpr.team_id = ?
|
||||
and t.deleted_at is null")
|
||||
|
||||
(defn- retrieve-team-permissions
|
||||
(defn get-permissions
|
||||
[conn profile-id team-id]
|
||||
(db/exec! conn [sql:team-permissions 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-edition-check-fn retrieve-team-permissions))
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
|
||||
(def check-read-permissions!
|
||||
(perms/make-read-check-fn retrieve-team-permissions))
|
||||
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; --- Query: Teams
|
||||
|
||||
@@ -58,12 +73,26 @@
|
||||
join team as t on (t.id = tp.team_id)
|
||||
where t.deleted_at is null
|
||||
and tp.profile_id = ?
|
||||
order by t.created_at asc")
|
||||
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])))
|
||||
(->> (db/exec! conn [sql:teams (:default-team-id defaults) profile-id])
|
||||
(mapv process-permissions))))
|
||||
|
||||
;; --- Query: Team (by ID)
|
||||
|
||||
@@ -86,7 +115,7 @@
|
||||
(when-not result
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist))
|
||||
result))
|
||||
(process-permissions result)))
|
||||
|
||||
|
||||
;; --- Query: Team Members
|
||||
@@ -200,3 +229,21 @@
|
||||
(defn retrieve-team-stats
|
||||
[conn team-id]
|
||||
(db/exec-one! conn [sql:team-stats team-id 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")
|
||||
|
||||
(sv/defmethod ::team-invitations
|
||||
[{: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)))))
|
||||
|
||||
@@ -10,77 +10,72 @@
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.rpc.queries.files :as files]
|
||||
[app.rpc.queries.share-link :as slnk]
|
||||
[app.rpc.queries.teams :as teams]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
[clojure.spec.alpha :as s]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; --- Query: Viewer Bundle (by Page ID)
|
||||
|
||||
(declare check-shared-token!)
|
||||
(declare retrieve-shared-token)
|
||||
|
||||
(def ^:private
|
||||
sql:project
|
||||
"select p.id, p.name, p.team_id
|
||||
from project as p
|
||||
where p.id = ?
|
||||
and p.deleted_at is null")
|
||||
;; --- Query: View Only Bundle
|
||||
|
||||
(defn- retrieve-project
|
||||
[conn id]
|
||||
(db/exec-one! conn [sql:project id]))
|
||||
[pool id]
|
||||
(db/get-by-id pool :project id {:columns [:id :name :team-id]}))
|
||||
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::page-id ::us/uuid)
|
||||
(s/def ::token ::us/string)
|
||||
|
||||
(s/def ::viewer-bundle
|
||||
(s/keys :req-un [::file-id ::page-id]
|
||||
:opt-un [::profile-id ::token]))
|
||||
|
||||
(sv/defmethod ::viewer-bundle {:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id page-id token] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg :conn conn)
|
||||
file (files/retrieve-file cfg file-id)
|
||||
project (retrieve-project conn (:project-id file))
|
||||
page (get-in file [:data :pages-index page-id])
|
||||
file (merge (dissoc file :data)
|
||||
(select-keys (:data file) [:colors :media :typographies]))
|
||||
(defn- retrieve-bundle
|
||||
[{:keys [pool] :as cfg} file-id]
|
||||
(p/let [file (files/retrieve-file cfg file-id)
|
||||
project (retrieve-project pool (:project-id file))
|
||||
libs (files/retrieve-file-libraries cfg false file-id)
|
||||
users (teams/retrieve-users conn (:team-id project))
|
||||
users (teams/retrieve-users pool (:team-id project))
|
||||
|
||||
fonts (db/query conn :team-font-variant
|
||||
links (->> (db/query pool :share-link {:file-id file-id})
|
||||
(mapv slnk/decode-share-link-row))
|
||||
|
||||
fonts (db/query pool :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
:deleted-at nil})
|
||||
:deleted-at nil})]
|
||||
{:file file
|
||||
:users users
|
||||
:fonts fonts
|
||||
:project project
|
||||
:share-links links
|
||||
:libraries libs}))
|
||||
|
||||
bundle {:file file
|
||||
:page page
|
||||
:users users
|
||||
:fonts fonts
|
||||
:project project
|
||||
:libraries libs}]
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::share-id ::us/uuid)
|
||||
|
||||
(if (string? token)
|
||||
(do
|
||||
(check-shared-token! conn file-id page-id token)
|
||||
(assoc bundle :token token))
|
||||
(let [stoken (retrieve-shared-token conn file-id page-id)]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(assoc bundle :token (:token stoken)))))))
|
||||
(s/def ::view-only-bundle
|
||||
(s/keys :req-un [::file-id] :opt-un [::profile-id ::share-id]))
|
||||
|
||||
(defn check-shared-token!
|
||||
[conn file-id page-id token]
|
||||
(let [sql "select exists(select 1 from file_share_token where file_id=? and page_id=? and token=?) as exists"]
|
||||
(when-not (:exists (db/exec-one! conn [sql file-id page-id token]))
|
||||
(sv/defmethod ::view-only-bundle {:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id] :as params}]
|
||||
(p/let [slink (slnk/retrieve-share-link pool file-id share-id)
|
||||
perms (files/get-permissions pool profile-id file-id share-id)
|
||||
bundle (p/-> (retrieve-bundle cfg file-id)
|
||||
(assoc :permissions perms))]
|
||||
|
||||
;; When we have neither profile nor share, we just return a not
|
||||
;; found response to the user.
|
||||
(when (and (not profile-id)
|
||||
(not slink))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found))))
|
||||
|
||||
(defn retrieve-shared-token
|
||||
[conn file-id page-id]
|
||||
(let [sql "select * from file_share_token where file_id=? and page_id=?"]
|
||||
(db/exec-one! conn [sql file-id page-id])))
|
||||
:code :object-not-found))
|
||||
|
||||
;; When we have only profile, we need to check read permissions
|
||||
;; on file.
|
||||
(when (and profile-id (not slink))
|
||||
(files/check-read-permissions! pool profile-id file-id))
|
||||
|
||||
(cond-> bundle
|
||||
(some? slink)
|
||||
(assoc :share slink)
|
||||
|
||||
(and (some? slink)
|
||||
(not (contains? (:flags slink) "view-all-pages")))
|
||||
(update-in [:file :data] (fn [data]
|
||||
(let [allowed-pages (:pages slink)]
|
||||
(-> data
|
||||
(update :pages (fn [pages] (filterv #(contains? allowed-pages %) pages)))
|
||||
(update :pages-index (fn [index] (select-keys index allowed-pages))))))))))
|
||||
|
||||
45
backend/src/app/rpc/retry.clj
Normal file
45
backend/src/app/rpc/retry.clj
Normal file
@@ -0,0 +1,45 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.retry
|
||||
"A fault tolerance helpers. Allow retry some operations that we know
|
||||
we can retry."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[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))))
|
||||
|
||||
(defn wrap-retry
|
||||
[_ f {:keys [::matches ::sv/name]
|
||||
:or {matches (constantly false)}
|
||||
:as mdata}]
|
||||
|
||||
(when (::enabled mdata)
|
||||
(l/debug :hint "wrapping retry" :name name))
|
||||
|
||||
(if-let [max-retries (::max-retries mdata)]
|
||||
(fn [cfg params]
|
||||
(letfn [(run [retry]
|
||||
(-> (f cfg params)
|
||||
(p/catch (partial handle-error retry))))
|
||||
|
||||
(handle-error [retry cause]
|
||||
(if (matches cause)
|
||||
(let [current-retry (inc retry)]
|
||||
(l/trace :hint "running retry algorithm" :retry current-retry)
|
||||
(if (<= current-retry max-retries)
|
||||
(run current-retry)
|
||||
(throw cause)))
|
||||
(throw cause)))]
|
||||
(run 0)))
|
||||
f))
|
||||
|
||||
67
backend/src/app/rpc/rlimit.clj
Normal file
67
backend/src/app/rpc/rlimit.clj
Normal file
@@ -0,0 +1,67 @@
|
||||
;; 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) UXBOX Labs SL
|
||||
|
||||
(ns app.rpc.rlimit
|
||||
"Resource usage limits (in other words: semaphores)."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.services :as sv]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defprotocol IAsyncSemaphore
|
||||
(acquire! [_])
|
||||
(release! [_]))
|
||||
|
||||
(defn semaphore
|
||||
[{:keys [permits metrics name]}]
|
||||
(let [name (d/name name)
|
||||
used (volatile! 0)
|
||||
queue (volatile! (d/queue))
|
||||
labels (into-array String [name])]
|
||||
(reify IAsyncSemaphore
|
||||
(acquire! [this]
|
||||
(let [d (p/deferred)]
|
||||
(locking this
|
||||
(if (< @used permits)
|
||||
(do
|
||||
(vswap! used inc)
|
||||
(p/resolve! d))
|
||||
(vswap! queue conj d)))
|
||||
|
||||
(mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels })
|
||||
(mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
|
||||
(mtx/run! metrics {:id :rlimit-acquires-total :inc 1 :labels labels})
|
||||
d))
|
||||
|
||||
(release! [this]
|
||||
(locking this
|
||||
(if-let [item (peek @queue)]
|
||||
(do
|
||||
(vswap! queue pop)
|
||||
(p/resolve! item))
|
||||
(when (pos? @used)
|
||||
(vswap! used dec))))
|
||||
|
||||
(mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels})
|
||||
(mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
|
||||
))))
|
||||
|
||||
(defn wrap-rlimit
|
||||
[{:keys [metrics executors] :as cfg} f mdata]
|
||||
(if-let [permits (::permits mdata)]
|
||||
(let [sem (semaphore {:permits permits
|
||||
:metrics metrics
|
||||
:name (::sv/name mdata)})]
|
||||
(l/debug :hint "wrapping rlimit" :handler (::sv/name mdata) :permits permits)
|
||||
(fn [cfg params]
|
||||
(-> (acquire! sem)
|
||||
(p/then (fn [_] (f cfg params)) (:default executors))
|
||||
(p/finally (fn [_ _] (release! sem))))))
|
||||
f))
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user